From 48c88cc2786f9d04cc8b3ec3df5a54c4b4e4b7c4 Mon Sep 17 00:00:00 2001 From: Liet Blue <127093491+lietblue@users.noreply.github.com> Date: Thu, 27 Nov 2025 21:09:07 +0800 Subject: [PATCH] feat(stage-web): add table view, auto-process and hover preview to devtools/background-removal (#771) --- .../src/pages/devtools/background-removal.vue | 389 ++++++++++++++---- 1 file changed, 300 insertions(+), 89 deletions(-) diff --git a/apps/stage-web/src/pages/devtools/background-removal.vue b/apps/stage-web/src/pages/devtools/background-removal.vue index 705a9424a..a8fc7e8cf 100644 --- a/apps/stage-web/src/pages/devtools/background-removal.vue +++ b/apps/stage-web/src/pages/devtools/background-removal.vue @@ -2,20 +2,64 @@ import type { PreTrainedModel, Processor } from '@huggingface/transformers' import { AutoModel, AutoProcessor, env, RawImage } from '@huggingface/transformers' -import { InputFile } from '@proj-airi/ui' +import { Button } from '@proj-airi/stage-ui/components' +import { Checkbox, InputFile } from '@proj-airi/ui' import { check } from 'gpuu/webgpu' -import { computed, onMounted, ref } from 'vue' +import { computed, onMounted, ref, watch } from 'vue' const model = ref() const processor = ref() const error = ref() -const loading = ref() +const loading = ref(true) const processing = ref(false) const progressPercent = ref(0) -const processedImages = ref>() -const downloadReady = ref() +const currentProcessingIndex = ref(-1) +const autoProcess = ref(false) +const previewImage = ref(null) +const previewPosition = ref({ x: 0, y: 0 }) + +interface ImageItem { + file: File + originalUrl: string + processedUrl: string | null + status: 'pending' | 'processing' | 'done' | 'error' +} + +const imageItems = ref([]) const imageFiles = ref([]) -const imageFilesURLs = computed(() => imageFiles.value.map(img => URL.createObjectURL(img))) + +const pendingCount = computed(() => imageItems.value.filter(item => item.status === 'pending').length) +const doneCount = computed(() => imageItems.value.filter(item => item.status === 'done').length) + +// Watch for new files and add to imageItems +watch(imageFiles, (newFiles) => { + if (newFiles.length === 0) + return + + const existingNames = new Set(imageItems.value.map(item => item.file.name)) + const newItems: ImageItem[] = newFiles + .filter(file => !existingNames.has(file.name)) + .map(file => ({ + file, + originalUrl: URL.createObjectURL(file), + processedUrl: null, + status: 'pending' as const, + })) + + imageItems.value.push(...newItems) + + // Auto process if enabled + if (autoProcess.value && newItems.length > 0 && !processing.value) { + processAllImages() + } +}) + +// Watch for autoProcess toggle - process pending images when enabled +watch(autoProcess, (enabled) => { + if (enabled && !processing.value && pendingCount.value > 0) { + processAllImages() + } +}) onMounted(async () => { try { @@ -38,20 +82,16 @@ onMounted(async () => { loading.value = false }) -async function processImages() { - if (!model.value) - return - if (!processor.value) +async function processImage(item: ImageItem, index: number): Promise { + if (!model.value || !processor.value) return - processing.value = true - progressPercent.value = 0 - processedImages.value = [] - const totalImages = imageFilesURLs.value.length + try { + item.status = 'processing' + currentProcessingIndex.value = index - for (let i = 0; i < totalImages; ++i) { // Load image - const img = await RawImage.fromURL(imageFilesURLs.value[i]) + const img = await RawImage.fromURL(item.originalUrl) // Pre-process image const { pixel_values } = await processor.value(img) @@ -74,33 +114,50 @@ async function processImages() { // Update alpha channel const pixelData = ctx.getImageData(0, 0, img.width, img.height) - for (let i = 0; i < maskData.length; ++i) { - pixelData.data[4 * i + 3] = maskData[i] + for (let j = 0; j < maskData.length; ++j) { + pixelData.data[4 * j + 3] = maskData[j] } ctx.putImageData(pixelData, 0, 0) - processedImages.value.push(canvas.toDataURL('image/png')) + item.processedUrl = canvas.toDataURL('image/png') + item.status = 'done' + } + catch { + item.status = 'error' + } +} - // Update progress +async function processAllImages() { + if (!model.value || !processor.value || processing.value) + return + + processing.value = true + progressPercent.value = 0 + + const pendingItems = imageItems.value.filter(item => item.status === 'pending') + const totalImages = pendingItems.length + + for (let i = 0; i < totalImages; ++i) { + await processImage(pendingItems[i], imageItems.value.indexOf(pendingItems[i])) progressPercent.value = Math.round(((i + 1) / totalImages) * 100) } processing.value = false - downloadReady.value = true + currentProcessingIndex.value = -1 } function downloadImage(index: number) { - if (!processedImages.value || index >= processedImages.value.length || !imageFiles.value[index]) + const item = imageItems.value[index] + if (!item || !item.processedUrl) return // Get original filename and create new filename with suffix - const originalFileName = imageFiles.value[index].name + const originalFileName = item.file.name const fileNameWithoutExt = originalFileName.substring(0, originalFileName.lastIndexOf('.')) || originalFileName - const fileExt = originalFileName.substring(originalFileName.lastIndexOf('.')) || '.png' - const newFileName = `${fileNameWithoutExt}-background-removed${fileExt}` + const newFileName = `${fileNameWithoutExt}-background-removed.png` const link = document.createElement('a') - link.href = processedImages.value[index] + link.href = item.processedUrl link.download = newFileName document.body.appendChild(link) link.click() @@ -108,76 +165,230 @@ function downloadImage(index: number) { } function downloadAllImages() { - if (!processedImages.value || processedImages.value.length === 0) + const doneItems = imageItems.value.filter(item => item.status === 'done') + if (doneItems.length === 0) return - processedImages.value.forEach((_, index) => { - setTimeout(() => downloadImage(index), index * 100) + doneItems.forEach((_, i) => { + const index = imageItems.value.indexOf(doneItems[i]) + setTimeout(() => downloadImage(index), i * 100) }) } + +function removeImage(index: number) { + const item = imageItems.value[index] + if (item.originalUrl) + URL.revokeObjectURL(item.originalUrl) + imageItems.value.splice(index, 1) +} + +function clearAllImages() { + imageItems.value.forEach((item) => { + if (item.originalUrl) + URL.revokeObjectURL(item.originalUrl) + }) + imageItems.value = [] +} + +function showPreview(url: string, event: MouseEvent) { + previewImage.value = url + updatePreviewPosition(event) +} + +function updatePreviewPosition(event: MouseEvent) { + previewPosition.value = { + x: event.clientX + 16, + y: event.clientY + 16, + } +} + +function hidePreview() { + previewImage.value = null +}