Files
aigen/components/ImageV2MaskPainter.vue
T
TowstyandCursor a2895b8054 Add Image v2 Refine for on-device regional edits.
Mask painter and denoise strength sit beside Edit and Compose. The canned hand/chest prompt helper is gone so that text stays yours.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 22:03:20 -05:00

220 lines
6.3 KiB
Vue

<template>
<div class="space-y-2">
<div
ref="stage"
class="relative overflow-hidden rounded-xl bg-black/40 touch-none"
@pointerdown.prevent="onPointerDown"
@pointermove.prevent="onPointerMove"
@pointerup.prevent="onPointerUp"
@pointercancel.prevent="onPointerUp"
@pointerleave.prevent="onPointerUp"
>
<img
ref="photo"
:src="src"
alt="Refine canvas"
class="pointer-events-none block max-h-80 w-full object-contain"
:class="hidePreview ? 'opacity-0' : ''"
@load="fitCanvas"
>
<div
v-if="hidePreview"
class="absolute inset-0 flex min-h-44 items-center justify-center"
>
<span class="sensitive-glow absolute inset-3 rounded-xl" />
<p class="relative text-sm text-zinc-300">Canvas loaded · paint the mask</p>
</div>
<canvas
ref="paint"
class="absolute cursor-crosshair"
:style="overlayStyle"
/>
</div>
<div class="flex flex-wrap items-center gap-2 text-xs">
<button
type="button"
class="rounded-full border px-2.5 py-1"
:class="tool === 'brush' ? 'border-amber-300/70 text-amber-100' : 'border-white/10 text-zinc-400'"
@click="tool = 'brush'"
>
Brush
</button>
<button
type="button"
class="rounded-full border px-2.5 py-1"
:class="tool === 'eraser' ? 'border-amber-300/70 text-amber-100' : 'border-white/10 text-zinc-400'"
@click="tool = 'eraser'"
>
Eraser
</button>
<label class="flex items-center gap-2 text-zinc-400">
Size
<input v-model.number="size" type="range" min="8" max="96" step="2" class="w-24">
<span class="tabular-nums text-zinc-300">{{ size }}</span>
</label>
<button type="button" class="rounded-full border border-white/10 px-2.5 py-1 text-zinc-400 hover:text-white" @click="clear">
Clear
</button>
</div>
<p class="text-xs text-zinc-500">White paint = edit. Black = keep. Prompt only what should change in the painted area.</p>
</div>
</template>
<script setup lang="ts">
const props = defineProps<{
src: string
hidePreview?: boolean
}>()
const emit = defineEmits<{
painted: [has: boolean]
}>()
const stage = ref<HTMLElement | null>(null)
const photo = ref<HTMLImageElement | null>(null)
const paint = ref<HTMLCanvasElement | null>(null)
const tool = ref<'brush' | 'eraser'>('brush')
const size = ref(28)
const drawing = ref(false)
const dirty = ref(false)
const overlayStyle = ref<Record<string, string>>({
left: '0px',
top: '0px',
width: '100%',
height: '100%'
})
function ctx() {
const canvas = paint.value
if (!canvas) return null
return canvas.getContext('2d')
}
function contentBox() {
const image = photo.value
const wrap = stage.value
if (!image || !wrap) return null
const box = wrap.getBoundingClientRect()
const img = image.getBoundingClientRect()
return {
left: img.left - box.left,
top: img.top - box.top,
width: Math.max(1, img.width),
height: Math.max(1, img.height),
naturalWidth: Math.max(1, image.naturalWidth),
naturalHeight: Math.max(1, image.naturalHeight)
}
}
function fitCanvas() {
const canvas = paint.value
const box = contentBox()
if (!canvas || !box) return
const prev = canvas.width && canvas.height ? ctx()?.getImageData(0, 0, canvas.width, canvas.height) : null
canvas.width = Math.round(box.width)
canvas.height = Math.round(box.height)
overlayStyle.value = {
left: `${box.left}px`,
top: `${box.top}px`,
width: `${box.width}px`,
height: `${box.height}px`
}
if (prev) ctx()?.putImageData(prev, 0, 0)
}
function point(event: PointerEvent) {
const canvas = paint.value
if (!canvas) return null
const box = canvas.getBoundingClientRect()
return {
x: (event.clientX - box.left) * (canvas.width / box.width),
y: (event.clientY - box.top) * (canvas.height / box.height)
}
}
function stamp(event: PointerEvent) {
const context = ctx()
const at = point(event)
if (!context || !at) return
context.globalCompositeOperation = tool.value === 'eraser' ? 'destination-out' : 'source-over'
context.fillStyle = 'rgba(255, 80, 80, 0.55)'
context.beginPath()
context.arc(at.x, at.y, size.value / 2, 0, Math.PI * 2)
context.fill()
dirty.value = true
emit('painted', true)
}
function onPointerDown(event: PointerEvent) {
paint.value?.setPointerCapture(event.pointerId)
drawing.value = true
stamp(event)
}
function onPointerMove(event: PointerEvent) {
if (!drawing.value) return
stamp(event)
}
function onPointerUp() {
drawing.value = false
}
function clear() {
const canvas = paint.value
const context = ctx()
if (!canvas || !context) return
context.clearRect(0, 0, canvas.width, canvas.height)
dirty.value = false
emit('painted', false)
}
async function exportPng(): Promise<Blob | null> {
const overlay = paint.value
const box = contentBox()
if (!overlay || !dirty.value || !box) return null
const out = document.createElement('canvas')
out.width = box.naturalWidth
out.height = box.naturalHeight
const context = out.getContext('2d')
if (!context) return null
context.fillStyle = '#000000'
context.fillRect(0, 0, out.width, out.height)
const scaled = document.createElement('canvas')
scaled.width = box.naturalWidth
scaled.height = box.naturalHeight
const scaledCtx = scaled.getContext('2d')
if (!scaledCtx) return null
scaledCtx.drawImage(overlay, 0, 0, box.naturalWidth, box.naturalHeight)
const pixels = scaledCtx.getImageData(0, 0, out.width, out.height)
const white = context.createImageData(out.width, out.height)
for (let i = 0; i < pixels.data.length; i += 4) {
const painted = pixels.data[i + 3] > 8
const value = painted ? 255 : 0
white.data[i] = value
white.data[i + 1] = value
white.data[i + 2] = value
white.data[i + 3] = 255
}
context.putImageData(white, 0, 0)
return await new Promise((resolve) => {
out.toBlob(blob => resolve(blob), 'image/png')
})
}
onMounted(() => {
fitCanvas()
window.addEventListener('resize', fitCanvas)
})
onUnmounted(() => {
window.removeEventListener('resize', fitCanvas)
})
watch(() => props.src, () => {
dirty.value = false
emit('painted', false)
nextTick(fitCanvas)
})
defineExpose({ exportPng, clear, dirty })
</script>