Compare commits

...

4 Commits

Author SHA1 Message Date
Terry Jia
38f57b4e9a debug: add temporary logs to painter serializeValue path 2026-05-12 09:08:23 -04:00
Christian Byrne
3bd6331690 Merge branch 'main' into fix/painter-serialize-preserves-modelvalue-on-remount 2026-05-11 12:19:44 -07:00
bymyself
b32cd539d9 fix: detect canvas emptiness from pixel data, not stroke flags
Read actual canvas pixel data in serializeValue instead of trusting the
closure-local hasStrokes/isDirty flags. Per Slack thread evidence (Matt
Miller, 2026-05-05), the canvas can hold visible strokes (~9% non-zero
alpha pixels) while hasStrokes stays false. Two known triggers:

1. WidgetPainter remount swaps usePainter() for a fresh closure with
   hasStrokes=false; the registered serializeValue on the mask widget
   then disagrees with what the user sees on the canvas.
2. handlePointerDown early-returns on e.button !== 0, which can fire
   for some touchpad / pen pointer-event variants. startStroke never
   runs and hasStrokes never flips, even though pixels reach the
   canvas via other paths.

The new isCanvasPixelEmpty(el) reads ImageData and scans the alpha
channel. When canvas is empty, serializeValue defers to modelValue so
workflow-restored references survive a pre-image-load remount;
handleClear now also resets modelValue so a user-initiated clear still
resolves to ''.

Removes the now-unused hasStrokes flag and isCanvasEmpty helper.

Adds unit tests covering: pixel-driven upload despite isDirty=false,
empty canvas + empty modelValue → '', and post-handleClear → ''.
2026-05-06 00:19:38 -07:00
bymyself
1521e6956f fix: painter widget preserves mask reference across WidgetPainter remount
The painter's serializeValue closure captured hasStrokes and isDirty as
local variables that reset on every WidgetPainter.vue mount. After a
remount (e.g. NodeWidgets re-render driven by useProcessedWidgets, added
in #10966), serializeValue saw isCanvasEmpty()=true and returned '' even
when modelValue still held a valid mask reference from a workflow load
or a prior upload — backend then received an empty mask, and on cloud
this surfaced as ImageDownloadError.

Reorder the short-circuits so a non-dirty painter returns its existing
modelValue.value before the canvas-empty guard, and fall back to the
prior modelValue.value if the canvas element is no longer mounted.
2026-05-05 20:37:50 -07:00
2 changed files with 194 additions and 12 deletions

View File

@@ -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.
*/
@@ -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)
const { modelValue } = mountPainter()
modelValue.value = 'painter/existing.png [temp]'
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 { 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('')

View File

@@ -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
@@ -413,7 +412,10 @@ export function usePainter(nodeId: string, options: UsePainterOptions) {
isDrawing = true
isDirty.value = true
hasStrokes = true
console.warn('[painter] startStroke: isDirty=true', {
nodeId,
modelValue: modelValue.value
})
snapshotBrush()
strokeProcessor = new StrokeProcessor(Math.max(1, strokeBrush!.radius / 2))
strokeProcessor.addPoint(point)
@@ -513,7 +515,13 @@ 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 = ''
console.warn('[painter] handleClear: canvas cleared, modelValue=""', {
nodeId
})
}
function updateCursorPos(e: PointerEvent) {
@@ -619,17 +627,73 @@ 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 (!el) {
console.warn('[painter] serializeValue: no canvas el', {
nodeId,
modelValue: modelValue.value
})
return modelValue.value
}
if (isCanvasEmpty()) return ''
const pixelEmpty = isCanvasPixelEmpty(el)
console.warn('[painter] serializeValue: entry', {
nodeId,
isDirty: isDirty.value,
modelValue: modelValue.value,
pixelEmpty,
canvasW: el.width,
canvasH: el.height
})
if (!isDirty.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 (pixelEmpty) {
console.warn(
'[painter] serializeValue: canvas pixel-empty → return modelValue',
{
nodeId,
modelValue: modelValue.value
}
)
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) {
console.warn(
'[painter] serializeValue: !isDirty && modelValue → reuse cached',
{
nodeId,
modelValue: modelValue.value
}
)
return modelValue.value
}
const blob = await new Promise<Blob | null>((resolve) =>
el.toBlob(resolve, 'image/png')
@@ -683,6 +747,11 @@ export function usePainter(nodeId: string, options: UsePainterOptions) {
: `painter/${data.name} [temp]`
modelValue.value = result
isDirty.value = false
console.warn('[painter] serializeValue: upload OK', {
nodeId,
result,
isDirtyAfter: isDirty.value
})
return result
}
@@ -709,6 +778,11 @@ export function usePainter(nodeId: string, options: UsePainterOptions) {
const url = api.apiURL('/view?' + params.toString())
const img = new Image()
img.crossOrigin = 'anonymous'
console.warn('[painter] restoreCanvas: start loading', {
nodeId,
url,
modelValue: modelValue.value
})
img.onload = () => {
const el = canvasEl.value
if (!el) return
@@ -716,10 +790,21 @@ export function usePainter(nodeId: string, options: UsePainterOptions) {
canvasHeight.value = img.naturalHeight
mainCtx = null
getCtx()?.drawImage(img, 0, 0)
const isDirtyBefore = isDirty.value
isDirty.value = false
hasStrokes = true
console.warn(
'[painter] restoreCanvas: onload → drawImage + isDirty=false',
{
nodeId,
isDirtyBefore,
modelValue: modelValue.value
}
)
}
img.onerror = () => {
console.warn('[painter] restoreCanvas: onerror → modelValue=""', {
nodeId
})
modelValue.value = ''
}
img.src = url
@@ -741,6 +826,10 @@ export function usePainter(nodeId: string, options: UsePainterOptions) {
watch(backgroundColor, syncBackgroundColorToWidget)
function initialize() {
console.warn('[painter] mounted / initialize', {
nodeId,
modelValue: modelValue.value
})
syncCanvasSizeFromWidgets()
resizeCanvas()
registerWidgetSerialization()
@@ -752,6 +841,11 @@ export function usePainter(nodeId: string, options: UsePainterOptions) {
onMounted(initialize)
onUnmounted(() => {
console.warn('[painter] unmounted', {
nodeId,
modelValue: modelValue.value,
isDirty: isDirty.value
})
if (rafId) {
cancelAnimationFrame(rafId)
rafId = null