feat(stage-web): add table view, auto-process and hover preview to devtools/background-removal (#771)

This commit is contained in:
Liet Blue
2025-11-27 21:09:07 +08:00
committed by GitHub
parent 66337b3f0f
commit 48c88cc278
@@ -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<PreTrainedModel>()
const processor = ref<Processor>()
const error = ref<unknown>()
const loading = ref()
const loading = ref(true)
const processing = ref(false)
const progressPercent = ref(0)
const processedImages = ref<Array<string>>()
const downloadReady = ref<boolean>()
const currentProcessingIndex = ref(-1)
const autoProcess = ref(false)
const previewImage = ref<string | null>(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<ImageItem[]>([])
const imageFiles = ref<File[]>([])
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<void> {
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
}
</script>
<template>
<div flex flex-col items-center gap-4>
<button
bg="neutral-100 dark:neutral-800"
w-full cursor-pointer rounded-lg px-3 py-2
:disabled="processing"
@click="processImages"
>
{{ processing ? 'Processing...' : 'Process' }}
</button>
<div h-full w-full flex gap-2>
<div w="[50%]" border="2 solid neutral-200 dark:neutral-800" bg="neutral-50 dark:neutral-900" min-h="120" h="auto" overflow-hidden rounded-lg>
<img v-for="(url, index) in imageFilesURLs" :key="index" :src="url" h-full w-full object-cover>
</div>
<div w="[50%]" border="2 solid neutral-200 dark:neutral-800" bg="neutral-50 dark:neutral-900" min-h="120" h="auto" relative overflow-hidden rounded-lg>
<!-- Processing overlay with progress bar -->
<div v-if="processing" bg="black/50" absolute inset-0 z-10 flex flex-col items-center justify-center>
<div mb-4 text-white font-medium>
{{ progressPercent }}%
</div>
<div bg="gray-200/30" w="70%" h-2 overflow-hidden rounded-full>
<div
bg="emerald-500"
h-full
:style="{ width: `${progressPercent}%` }"
transition-all duration-200
/>
</div>
</div>
<!-- Processed images -->
<div v-for="(url, index) in processedImages" :key="index" relative class="group" h-full w-full>
<img :src="url" h-full w-full object-cover>
<div bg="black/0 group-hover:black/20" absolute inset-0 flex items-center justify-center transition-all duration-200>
<button
bg="emerald-500 hover:emerald-600"
rounded-full p-2 text-white opacity-0 transition-all duration-200 group-hover:opacity-100
@click="downloadImage(index)"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
</button>
</div>
</div>
<!-- Download all button -->
<div v-if="processedImages && processedImages.length > 1" absolute bottom-2 right-2>
<button
bg="emerald-500 hover:emerald-600"
rounded-full p-2 text-white
@click="downloadAllImages"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
</svg>
</button>
</div>
</div>
<div flex flex-col gap-4>
<!-- Loading state -->
<div v-if="loading" flex items-center justify-center gap-2 py-8 text-neutral-500>
<div i-svg-spinners:ring-resize text-2xl />
<span>Loading model...</span>
</div>
<InputFile v-model="imageFiles" w-full />
<!-- Error state -->
<div v-else-if="error" rounded-lg bg-red-100 p-4 text-red-700 dark:bg-red-900 dark:text-red-200>
{{ error }}
</div>
<!-- Main content -->
<template v-else>
<!-- File upload area -->
<InputFile v-model="imageFiles" accept="image/*" multiple w-full />
<!-- Controls -->
<div flex flex-wrap items-center justify-between gap-4>
<div flex items-center gap-3>
<label flex cursor-pointer items-center gap-2>
<Checkbox v-model="autoProcess" />
<span text-sm>Auto process on upload</span>
</label>
</div>
<div flex gap-2>
<Button
v-if="pendingCount > 0"
:label="processing ? `Processing... ${progressPercent}%` : `Process ${pendingCount} image${pendingCount > 1 ? 's' : ''}`"
:disabled="processing || !model"
:loading="processing"
@click="processAllImages"
/>
<Button
v-if="doneCount > 0"
variant="secondary"
:label="`Download All (${doneCount})`"
icon="i-solar:download-minimalistic-bold"
@click="downloadAllImages"
/>
<Button
v-if="imageItems.length > 0"
variant="secondary-muted"
label="Clear All"
icon="i-solar:trash-bin-trash-line-duotone"
@click="clearAllImages"
/>
</div>
</div>
<!-- Image Table -->
<div overflow-x-auto rounded-lg border="1 solid neutral-200 dark:neutral-700">
<table w-full text-left text-sm>
<thead bg="neutral-100 dark:neutral-800">
<tr>
<th px-4 py-3 font-medium>
Original
</th>
<th px-4 py-3 font-medium>
Processed
</th>
<th px-4 py-3 font-medium>
Filename
</th>
<th px-4 py-3 font-medium>
Status
</th>
<th px-4 py-3 font-medium>
Actions
</th>
</tr>
</thead>
<tbody>
<tr v-if="imageItems.length === 0">
<td colspan="5" px-4 py-8 text-center text-neutral-400>
No images uploaded yet
</td>
</tr>
<tr
v-for="(item, index) in imageItems"
:key="item.file.name"
border="t 1 solid neutral-200 dark:neutral-700"
:class="item.status === 'processing' ? 'bg-emerald-50 dark:bg-emerald-900/20' : ''"
>
<!-- Original thumbnail -->
<td px-4 py-3>
<div
h-16 w-16 cursor-pointer overflow-hidden rounded-lg border="1 solid neutral-200 dark:neutral-700"
@mouseenter="showPreview(item.originalUrl, $event)"
@mousemove="updatePreviewPosition"
@mouseleave="hidePreview"
>
<img :src="item.originalUrl" h-full w-full object-cover>
</div>
</td>
<!-- Processed thumbnail -->
<td px-4 py-3>
<div
h-16 w-16 overflow-hidden rounded-lg border="1 solid neutral-200 dark:neutral-700"
:class="item.processedUrl ? 'cursor-pointer' : ''"
bg="[url('data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2216%22%20height%3D%2216%22%3E%3Crect%20width%3D%228%22%20height%3D%228%22%20fill%3D%22%23ccc%22%2F%3E%3Crect%20x%3D%228%22%20y%3D%228%22%20width%3D%228%22%20height%3D%228%22%20fill%3D%22%23ccc%22%2F%3E%3C%2Fsvg%3E')]"
@mouseenter="item.processedUrl && showPreview(item.processedUrl, $event)"
@mousemove="updatePreviewPosition"
@mouseleave="hidePreview"
>
<img v-if="item.processedUrl" :src="item.processedUrl" h-full w-full object-cover>
<div v-else-if="item.status === 'processing'" h-full w-full flex items-center justify-center bg="neutral-100 dark:neutral-800">
<div i-svg-spinners:ring-resize text-emerald-500 />
</div>
<div v-else h-full w-full flex items-center justify-center bg="neutral-100 dark:neutral-800" text-neutral-400>
-
</div>
</div>
</td>
<!-- Filename -->
<td max-w-48 truncate px-4 py-3 :title="item.file.name">
{{ item.file.name }}
</td>
<!-- Status -->
<td px-4 py-3>
<span
inline-flex items-center gap-1 rounded-full px-2 py-1 text-xs font-medium
:class="{
'bg-neutral-100 text-neutral-600 dark:bg-neutral-800 dark:text-neutral-400': item.status === 'pending',
'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/50 dark:text-emerald-400': item.status === 'processing',
'bg-blue-100 text-blue-700 dark:bg-blue-900/50 dark:text-blue-400': item.status === 'done',
'bg-red-100 text-red-700 dark:bg-red-900/50 dark:text-red-400': item.status === 'error',
}"
>
<div v-if="item.status === 'processing'" i-svg-spinners:ring-resize text-xs />
<div v-else-if="item.status === 'done'" i-solar:check-circle-bold text-xs />
<div v-else-if="item.status === 'error'" i-solar:close-circle-bold text-xs />
<div v-else i-solar:clock-circle-linear text-xs />
{{ item.status === 'pending' ? 'Pending' : item.status === 'processing' ? 'Processing' : item.status === 'done' ? 'Done' : 'Error' }}
</span>
</td>
<!-- Actions -->
<td px-4 py-3>
<div flex gap-2>
<Button
v-if="item.status === 'done'"
size="sm"
icon="i-solar:download-minimalistic-bold"
@click="downloadImage(index)"
/>
<Button
variant="danger"
size="sm"
icon="i-solar:trash-bin-trash-bold"
:disabled="item.status === 'processing'"
@click="removeImage(index)"
/>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</template>
<!-- Preview tooltip -->
<Teleport to="body">
<div
v-if="previewImage"
pointer-events-none fixed z-50 overflow-hidden rounded-xl shadow-2xl
border="1 solid neutral-200 dark:neutral-700"
bg="[url('data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2216%22%20height%3D%2216%22%3E%3Crect%20width%3D%228%22%20height%3D%228%22%20fill%3D%22%23ccc%22%2F%3E%3Crect%20x%3D%228%22%20y%3D%228%22%20width%3D%228%22%20height%3D%228%22%20fill%3D%22%23ccc%22%2F%3E%3C%2Fsvg%3E')]"
:style="{
left: `${previewPosition.x}px`,
top: `${previewPosition.y}px`,
maxWidth: '400px',
maxHeight: '400px',
}"
>
<img :src="previewImage" max-h-96 max-w-96 object-contain>
</div>
</Teleport>
</div>
</template>