Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 91 additions & 3 deletions src/composables/painter/usePainter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,35 @@ function makeWidget(name: string, value: unknown = null): IBaseWidget {
} as unknown as IBaseWidget
}

/**
* Builds a minimal HTMLCanvasElement-like stub with a 2D context that exposes
* the methods `usePainter` actually calls (`getImageData`, `clearRect`,
* `drawImage`, `toBlob`). jsdom's canvas implementation is incomplete, so we
* synthesize one to drive the pixel-emptiness check deterministically.
*/
function makeFakeCanvas(
width: number,
height: number,
pixels: Uint8ClampedArray
): HTMLCanvasElement {
const ctx = {
getImageData: vi.fn(() => ({ data: pixels })),
clearRect: vi.fn(),
drawImage: vi.fn(),
save: vi.fn(),
restore: vi.fn(),
fillRect: vi.fn(),
fill: vi.fn(),
stroke: vi.fn()
}
return {
width,
height,
getContext: vi.fn(() => ctx),
toBlob: (cb: BlobCallback) => cb(new Blob(['x']))
} as unknown as HTMLCanvasElement
}

/**
* Mounts a thin wrapper component so Vue lifecycle hooks fire.
*/
Expand Down Expand Up @@ -359,12 +388,71 @@ describe('usePainter', () => {
expect(result).toBe('')
})

it('returns empty string when canvas has no strokes even if modelValue is set', async () => {
it('returns existing modelValue when not dirty (regression: WidgetPainter remount must not blank a workflow-restored mask reference)', async () => {
const maskWidget = makeWidget('mask', '')
mockWidgets.push(maskWidget)

mountPainter('test-node', 'painter/existing.png [temp]')

const result = await maskWidget.serializeValue!({} as LGraphNode, 0)
expect(result).toBe('painter/existing.png [temp]')
})

it('uploads canvas content even when the isDirty flag is false (regression: stroke-tracking flag can desync from real canvas pixel data on remount or non-primary pointerdown)', async () => {
const maskWidget = makeWidget('mask', '')
mockWidgets.push(maskWidget)

const fetchApiMock = vi.mocked(api.fetchApi)
fetchApiMock.mockResolvedValueOnce({
status: 200,
json: async () => ({ name: 'uploaded.png' })
} as Response)

const { canvasEl } = mountPainter('test-node', '')
// Simulate a remount-style scenario: closure flags say "no strokes",
// but the canvas itself has visible pixel content (e.g. produced by a
// pointerdown path that bypassed startStroke, or compositeStrokeToMain
// that ran before the new closure was installed).
const paintedPixels = new Uint8ClampedArray(4 * 4 * 4)
// Mark pixel 0 as opaque red.
paintedPixels[3] = 255
canvasEl.value = makeFakeCanvas(4, 4, paintedPixels)
await nextTick()

const result = await maskWidget.serializeValue!({} as LGraphNode, 0)
expect(result).toBe('painter/uploaded.png [temp]')
expect(fetchApiMock).toHaveBeenCalledWith(
'/upload/image',
expect.objectContaining({ method: 'POST' })
)
})

it('returns empty string when canvas has no pixels and modelValue is empty', async () => {
const maskWidget = makeWidget('mask', '')
mockWidgets.push(maskWidget)

const { canvasEl } = mountPainter('test-node', '')
// All-zero alpha — canvas considered empty.
canvasEl.value = makeFakeCanvas(4, 4, new Uint8ClampedArray(4 * 4 * 4))
await nextTick()

const result = await maskWidget.serializeValue!({} as LGraphNode, 0)
expect(result).toBe('')
})

it('returns empty string after handleClear even when modelValue previously held an upload reference', async () => {
const maskWidget = makeWidget('mask', '')
mockWidgets.push(maskWidget)

const { modelValue } = mountPainter()
modelValue.value = 'painter/existing.png [temp]'
const { painter, canvasEl, modelValue } = mountPainter(
'test-node',
'painter/old-upload.png [temp]'
)
canvasEl.value = makeFakeCanvas(4, 4, new Uint8ClampedArray(4 * 4 * 4))
await nextTick()

painter.handleClear()
expect(modelValue.value).toBe('')

const result = await maskWidget.serializeValue!({} as LGraphNode, 0)
expect(result).toBe('')
Expand Down
44 changes: 33 additions & 11 deletions src/composables/painter/usePainter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,6 @@ export function usePainter(nodeId: string, options: UsePainterOptions) {
let baseCanvas: HTMLCanvasElement | null = null
let baseCtx: CanvasRenderingContext2D | null = null
let hasBaseSnapshot = false
let hasStrokes = false

let dirtyX0 = 0
let dirtyY0 = 0
Expand Down Expand Up @@ -413,7 +412,6 @@ export function usePainter(nodeId: string, options: UsePainterOptions) {

isDrawing = true
isDirty.value = true
hasStrokes = true
snapshotBrush()
strokeProcessor = new StrokeProcessor(Math.max(1, strokeBrush!.radius / 2))
strokeProcessor.addPoint(point)
Expand Down Expand Up @@ -513,7 +511,10 @@ export function usePainter(nodeId: string, options: UsePainterOptions) {
if (!el || !ctx) return
ctx.clearRect(0, 0, el.width, el.height)
isDirty.value = true
hasStrokes = false
// Clear any cached upload reference. Without this, an empty canvas
// combined with a stale `modelValue` would resurrect the previously
// uploaded mask on the next serialize.
modelValue.value = ''
}

function updateCursorPos(e: PointerEvent) {
Expand Down Expand Up @@ -619,17 +620,39 @@ export function usePainter(nodeId: string, options: UsePainterOptions) {
return { filename, subfolder, type }
}

function isCanvasEmpty(): boolean {
return !hasStrokes
/**
* Reads canvas pixel data to determine whether the canvas has any visible
* content. Robust against state-flag drift caused by closure resets on
* remount, handleClear edge cases, and pointerdown variants where
* `e.button !== 0` short-circuits `startStroke`.
*/
function isCanvasPixelEmpty(el: HTMLCanvasElement): boolean {
const ctx = el.getContext('2d')
if (!ctx) return true
const { data } = ctx.getImageData(0, 0, el.width, el.height)
for (let i = 3; i < data.length; i += 4) {
if (data[i] !== 0) return false
}
return true
}

async function serializeValue(): Promise<string> {
const el = canvasEl.value
if (!el) return ''

if (isCanvasEmpty()) return ''

if (!isDirty.value) return modelValue.value
if (!el) return modelValue.value

// Authoritative emptiness check: read actual pixel data instead of
// relying on the `isDirty` flag, which can desync from canvas content
// on WidgetPainter remount or on non-primary pointerdown variants where
// the closure-local stroke bookkeeping was bypassed.
// When the canvas is empty, defer to `modelValue` so a workflow-restored
// mask reference (or a pending image-restore) survives. `handleClear`
// explicitly resets `modelValue` so a user-initiated clear still yields ''.
if (isCanvasPixelEmpty(el)) return modelValue.value

// Canvas has visible content. If we already uploaded this exact content
// (no new strokes since last successful upload) and the cached value is
// valid, reuse it to avoid redundant uploads.
if (!isDirty.value && modelValue.value) return modelValue.value
Comment on lines +643 to +655
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Do not reuse modelValue after a dirty erase-to-empty path.

If a previously restored mask is fully erased with the eraser, isDirty.value is true but isCanvasPixelEmpty(el) is also true, so this returns the old upload reference instead of serializing an empty mask. That brings deleted content back on the next save. Only fall back to modelValue for an empty canvas when nothing changed since the last successful serialize; otherwise clear the cached reference and return ''. Please also add a regression test for “erase restored mask to fully transparent pixels” since handleClear() does not cover that path.

Suggested fix
-    if (isCanvasPixelEmpty(el)) return modelValue.value
+    if (isCanvasPixelEmpty(el)) {
+      if (isDirty.value) {
+        modelValue.value = ''
+        return ''
+      }
+      return modelValue.value
+    }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Authoritative emptiness check: read actual pixel data instead of
// relying on the `isDirty` flag, which can desync from canvas content
// on WidgetPainter remount or on non-primary pointerdown variants where
// the closure-local stroke bookkeeping was bypassed.
// When the canvas is empty, defer to `modelValue` so a workflow-restored
// mask reference (or a pending image-restore) survives. `handleClear`
// explicitly resets `modelValue` so a user-initiated clear still yields ''.
if (isCanvasPixelEmpty(el)) return modelValue.value
// Canvas has visible content. If we already uploaded this exact content
// (no new strokes since last successful upload) and the cached value is
// valid, reuse it to avoid redundant uploads.
if (!isDirty.value && modelValue.value) return modelValue.value
// Authoritative emptiness check: read actual pixel data instead of
// relying on the `isDirty` flag, which can desync from canvas content
// on WidgetPainter remount or on non-primary pointerdown variants where
// the closure-local stroke bookkeeping was bypassed.
// When the canvas is empty, defer to `modelValue` so a workflow-restored
// mask reference (or a pending image-restore) survives. `handleClear`
// explicitly resets `modelValue` so a user-initiated clear still yields ''.
if (isCanvasPixelEmpty(el)) {
if (isDirty.value) {
modelValue.value = ''
return ''
}
return modelValue.value
}
// Canvas has visible content. If we already uploaded this exact content
// (no new strokes since last successful upload) and the cached value is
// valid, reuse it to avoid redundant uploads.
if (!isDirty.value && modelValue.value) return modelValue.value
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/composables/painter/usePainter.ts` around lines 643 - 655, When detecting
an empty canvas in usePainter (the isCanvasPixelEmpty(el) check), do not return
the old modelValue if the canvas was modified (isDirty.value === true); instead
clear the cached reference and return '' so erased/restored masks do not
reappear. Concretely: in the empty-canvas branch, if isDirty.value is true set
modelValue.value = '' (or otherwise clear the cached upload ref) and return '';
only fall back to modelValue when the canvas is empty AND isDirty is false.
Update the logic around isCanvasPixelEmpty, isDirty, and modelValue and add a
regression test "erase restored mask to fully transparent pixels" that simulates
restoring a mask then erasing it to fully transparent to assert serialize/upload
yields '' (handleClear remains unchanged).


const blob = await new Promise<Blob | null>((resolve) =>
el.toBlob(resolve, 'image/png')
Expand Down Expand Up @@ -717,7 +740,6 @@ export function usePainter(nodeId: string, options: UsePainterOptions) {
mainCtx = null
getCtx()?.drawImage(img, 0, 0)
isDirty.value = false
hasStrokes = true
}
img.onerror = () => {
modelValue.value = ''
Expand Down
Loading