From 6cb1e59bfb67535c55b90d63be3efca62aa7469c Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Tue, 15 Sep 2026 14:27:54 -0700 Subject: [PATCH] Preserve manual AC brush edits and steps when returning to No Paint --- .../aesthetic.computer/disks/nopaint.mjs | 40 ++++--- .../aesthetic.computer/lib/nopaint-pieces.mjs | 65 ++++++++++- .../lib/nopaint-recording.mjs | 16 ++- system/tests/nopaint-handoff.test.mjs | 64 ++++++++++ tests/browser/nopaint-handoff.test.mjs | 109 ++++++++++++++++++ 5 files changed, 271 insertions(+), 23 deletions(-) create mode 100644 system/tests/nopaint-handoff.test.mjs create mode 100644 tests/browser/nopaint-handoff.test.mjs diff --git a/system/public/aesthetic.computer/disks/nopaint.mjs b/system/public/aesthetic.computer/disks/nopaint.mjs index cc9e59f792..1998ba2393 100644 --- a/system/public/aesthetic.computer/disks/nopaint.mjs +++ b/system/public/aesthetic.computer/disks/nopaint.mjs @@ -39,6 +39,7 @@ import { createNoPaintPiece, createNoPaintProposalLayer, recoverNoPaintPiece, + reconcileNoPaintPiece, } from "../lib/nopaint-pieces.mjs"; import { createNoPaintRecording } from "../lib/nopaint-recording.mjs"; import { timestamp } from "../lib/num.mjs"; @@ -1048,18 +1049,17 @@ function boot({ colon, debug, hud, net, num, params, query = {}, screen, store, width: system.painting.width, height: system.painting.height, }; - const recoveredPiece = freshStart ? null : recoverNoPaintPiece( - system.nopaint.piece || store[NOPAINT_PIECE_STORE_KEY], - paintingResolution.width, - paintingResolution.height, - ); - if (recoveredPiece) { - system.nopaint.piece = recoveredPiece; - system.painting.pixels.set(recoveredPiece.composite.pixels); - substrateFresh = false; - } else { + if (needsStarterSubstrate || archiveId) { initializePiece({ ...api, store, system }, sessionSeed, needsStarterSubstrate ? "substrate" : "legacy-raster"); + } else { + const cachedPiece = system.nopaint.piece || store[NOPAINT_PIECE_STORE_KEY]; + const recoveredPiece = recoverNoPaintPiece(cachedPiece, cachedPiece?.width, cachedPiece?.height); + system.nopaint.piece = reconcileNoPaintPiece( + recoveredPiece, system.painting, system.nopaint.record, sessionSeed, timestamp(), + ); + persistPiece({ store, system }); + substrateFresh = false; } store["painting:resolution-lock"] = true; store.persist("painting:resolution-lock", "local:db"); @@ -1377,11 +1377,7 @@ async function completePainting($) { publishTestState(); try { - const record = createNoPaintRecording($, $.system.nopaint.piece); - $.system.nopaint.record = record; - $.system.nopaint.recording = true; - $.store["painting:record"] = record; - $.store.persist("painting:record", "local:db"); + const record = preserveRecording($); const reportProgress = (progress) => { completionProgress = Math.max(0, Math.min(1, Number(progress) || 0)); $.needsPaint(); @@ -1729,7 +1725,19 @@ function act($) { // never commit a proposal behind the participant's back. function bake() {} -function leave() { +function preserveRecording($) { + const record = createNoPaintRecording($, $.system.nopaint.piece); + $.system.nopaint.record = record; + $.system.nopaint.recording = true; + $.store["painting:record"] = record; + $.store.persist("painting:record", "local:db"); + return record; +} + +function leave($) { + // AC brushes append their next strokes to this same recording. Keeping the + // last accepted frame supplies the boundary for the next No Paint visit. + if ($?.system?.nopaint?.piece) preserveRecording($); stopBrushCue(); testApi?.cursor?.("native"); if (typeof window !== "undefined") delete window.__acNoPaintTest; diff --git a/system/public/aesthetic.computer/lib/nopaint-pieces.mjs b/system/public/aesthetic.computer/lib/nopaint-pieces.mjs index 0e23e1b1a4..7963db7e40 100644 --- a/system/public/aesthetic.computer/lib/nopaint-pieces.mjs +++ b/system/public/aesthetic.computer/lib/nopaint-pieces.mjs @@ -43,6 +43,69 @@ function scoreCode(score) { }; } +function nextLayerId(piece) { + let index = piece.layers.length; + while (piece.layers.some((layer) => layer.id === `${piece.id}:layer:${index}`)) index++; + return `${piece.id}:layer:${index}`; +} + +export function sameNoPaintPixels(a, b) { + return Boolean(a?.pixels && b?.pixels && a.width === b.width && a.height === b.height && + a.pixels.length === b.pixels.length && a.pixels.every((value, index) => value === b.pixels[index])); +} + +function appendRecordedPainting(piece, painting, step = {}) { + if (sameNoPaintPixels(piece.composite, painting)) return piece; + const { width, height } = painting; + const pixels = layerPixels(painting.pixels, width, height, "composite"); + if (!pixels) throw new Error("AC painting step has invalid pixels"); + const layer = { + id: nextLayerId(piece), + operation: "raster", + code: scoreCode({ kind: "raster", role: "ac-brush", label: step.label, width, height }), + pixels, + ...(step.label ? { label: step.label } : {}), + ...(step.timestamp ? { timestamp: step.timestamp } : {}), + ...(step.gesture?.length ? { gesture: cloneValue(step.gesture) } : {}), + }; + return appendNoPaintLayer({ ...piece, width, height }, layer, painting.pixels); +} + +// The shared AC canvas is authoritative on re-entry. A cached No Paint score +// supplies history, never a replacement for pixels edited by another brush. +export function reconcileNoPaintPiece(piece, painting, record = [], seed, timestamp) { + if (piece && sameNoPaintPixels(piece.composite, painting)) return piece; + let pending = []; + if (piece) { + let boundary = -1; + for (let index = record.length - 1; index >= 0; index--) { + if (sameNoPaintPixels(record[index].painting, piece.composite)) { + boundary = index; + break; + } + } + if (boundary >= 0) pending = record.slice(boundary + 1); + } else { + // Import an existing AC recording only when it reaches this canvas. + const lastPicture = record.findLast((step) => step.painting)?.painting; + pending = sameNoPaintPixels(lastPicture, painting) + ? record.filter((step) => step.painting) : []; + const first = pending.shift(); + piece = createNoPaintPiece({ seed, ...(first?.painting || painting), role: "ac-brush" }); + Object.assign(piece.layers[0], { + timestamp: first?.timestamp || timestamp, + ...(first?.label ? { label: first.label } : {}), + ...(first?.gesture?.length ? { gesture: cloneValue(first.gesture) } : {}), + }); + } + for (const step of pending) { + if (step.painting) piece = appendRecordedPainting(piece, step.painting, step); + } + // Also covers edits without a recording, and AC undo/redo entries which + // reference prior frames instead of carrying their own pixel snapshot. + return appendRecordedPainting(piece, painting, { label: "ac-brush", timestamp }); +} + function layerPixels(pixels, width, height, mode) { const full = pixelsFrom(pixels, width * height * 4); if (!full) return null; @@ -121,7 +184,7 @@ export function createNoPaintProposalLayer({ proposal, }); return { - id: `${piece.id}:layer:${proposalNumber}`, + id: nextLayerId(piece), operation: score.kind, code: scoreCode(score), pixels: pixelPayload, diff --git a/system/public/aesthetic.computer/lib/nopaint-recording.mjs b/system/public/aesthetic.computer/lib/nopaint-recording.mjs index d0ef5d48df..3fbc7eae65 100644 --- a/system/public/aesthetic.computer/lib/nopaint-recording.mjs +++ b/system/public/aesthetic.computer/lib/nopaint-recording.mjs @@ -3,13 +3,16 @@ // save path existed, without recording rejected or still-running proposals. export function createNoPaintRecording(api, piece) { if (!piece?.layers?.length) throw new Error("No Paint has no steps to save"); - const { width, height } = piece; - const composite = api.painting(width, height, (p) => p.wipe(0, 0, 0, 0)); + let composite; + const screen = api.screen; try { return piece.layers.map((layer, index) => { const pixels = layer.pixels; if (pixels.mode === "composite") { - composite.pixels.set(pixels.data); + composite = { + width: pixels.width, height: pixels.height, + pixels: new Uint8ClampedArray(pixels.data), + }; } else if (pixels.width && pixels.height) { api.page(composite).paste({ width: pixels.width, @@ -21,11 +24,12 @@ export function createNoPaintRecording(api, piece) { return { timestamp: layer.timestamp || api.num.timestamp(), // The index keeps filenames unique even for old layers without times. - label: index === 0 ? "nopaint" : `nopaint~${index}~${layer.operation}`, - painting: { width, height, pixels: new Uint8ClampedArray(composite.pixels) }, + label: layer.label || (index === 0 ? "nopaint" : `nopaint~${index}~${layer.operation}`), + ...(layer.gesture?.length ? { gesture: layer.gesture } : {}), + painting: { ...composite, pixels: new Uint8ClampedArray(composite.pixels) }, }; }); } finally { - api.page(api.screen); + api.page(screen); } } diff --git a/system/tests/nopaint-handoff.test.mjs b/system/tests/nopaint-handoff.test.mjs new file mode 100644 index 0000000000..b6ee052954 --- /dev/null +++ b/system/tests/nopaint-handoff.test.mjs @@ -0,0 +1,64 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { createNoPaintPiece, createNoPaintProposalLayer, appendNoPaintLayer, + reconcileNoPaintPiece, recoverNoPaintPiece } from "../public/aesthetic.computer/lib/nopaint-pieces.mjs"; +import { createNoPaintRecording } from "../public/aesthetic.computer/lib/nopaint-recording.mjs"; + +const picture = (color, width = 3, height = 2) => ({ + width, height, pixels: new Uint8ClampedArray(width * height * 4).fill(color), +}); +const step = (painting, label, timestamp) => ({ painting, label, timestamp }); +const exportRecord = (piece) => createNoPaintRecording({ + page() {}, screen: {}, num: { timestamp: () => "now" }, +}, piece); + +test("AC → No Paint → AC → No Paint retains manual steps and the latest canvas", () => { + const base = picture(255), red = picture(80), proposed = picture(120); + const blue = picture(160), green = picture(200); + const initial = [step(base, "new", "1"), step(red, "line red", "2")]; + let piece = reconcileNoPaintPiece(null, red, initial, "handoff", "2"); + assert.deepEqual(exportRecord(piece).map(({ painting }) => painting), [base, red]); + const layer = createNoPaintProposalLayer({ piece, proposal: { kind: "line" }, + proposalNumber: 1, proposalFrame: 1, pixels: proposed.pixels, pixelMode: "composite" }); + piece = appendNoPaintLayer(piece, layer, proposed.pixels); + const original = structuredClone(piece); + const record = [...exportRecord(piece), step(blue, "line blue", "4"), step(green, "line green", "5")]; + const currentPixels = new Uint8ClampedArray(green.pixels); + const resumed = reconcileNoPaintPiece(piece, green, record, "another-seed", "6"); + assert.equal(resumed.id, piece.id); + assert.deepEqual(resumed.layers.slice(0, 3), piece.layers); + assert.deepEqual(exportRecord(resumed).map(({ painting }) => painting), [base, red, proposed, blue, green]); + assert.deepEqual(resumed.composite, green); + assert.deepEqual(green.pixels, currentPixels, "re-entry never overwrites the live canvas"); + assert.deepEqual(piece, original, "the earlier layer history remains intact"); + assert.equal(reconcileNoPaintPiece(resumed, green, record, "ignored", "7"), resumed, + "returning without edits does not duplicate steps"); + const next = createNoPaintProposalLayer({ piece: resumed, proposal: { kind: "line" }, + proposalNumber: 1, proposalFrame: 1, pixels: base.pixels, pixelMode: "composite" }); + assert(!resumed.layers.some(({ id }) => id === next.id), "restarted proposals have unique layer IDs"); +}); + +test("manual edits without a recording and undo still take priority over the cached composite", () => { + const base = picture(255), manual = picture(100), undone = picture(160); + const piece = createNoPaintPiece({ seed: "fallback", ...base }); + const resumed = reconcileNoPaintPiece(piece, manual, [], "ignored", "2"); + assert.deepEqual(resumed.composite, manual); + const record = [...exportRecord(resumed), step(undone, "line", "3"), { label: "no", timestamp: "4" }]; + const restored = reconcileNoPaintPiece(resumed, base, record, "ignored", "5"); + assert.deepEqual(restored.composite, base); + assert.deepEqual(exportRecord(restored).at(-1).painting, base); + assert.equal(reconcileNoPaintPiece(restored, base, record, "ignored", "6"), restored); + assert.equal(reconcileNoPaintPiece(null, manual, [step(base, "old painting", "1")], "new", "2").layers.length, 1, + "unrelated AC recordings are not imported"); +}); + +test("a resized AC painting retains its earlier frame dimensions in playback", () => { + const base = picture(255), resized = picture(100, 5, 4); + const piece = createNoPaintPiece({ seed: "resize", ...base }); + const resumed = reconcileNoPaintPiece(piece, resized, + [step(base, "new", "1"), step(resized, "crop", "2")], "ignored", "3"); + const recovered = recoverNoPaintPiece(structuredClone(resumed), 5, 4); + assert.deepEqual(exportRecord(recovered).map(({ painting }) => painting), [base, resized]); + assert.equal(recovered.width, 5); + assert.equal(recovered.height, 4); +}); diff --git a/tests/browser/nopaint-handoff.test.mjs b/tests/browser/nopaint-handoff.test.mjs new file mode 100644 index 0000000000..928bac1d32 --- /dev/null +++ b/tests/browser/nopaint-handoff.test.mjs @@ -0,0 +1,109 @@ +// Run with AC_TEST_URL=http://localhost:8892 against ac-static-server.mjs. +import assert from "node:assert/strict"; +import JSZip from "jszip"; +import sharp from "sharp"; +import { ACSession, CONFIG } from "./ac-harness.mjs"; +import { mockNoPaintUploads } from "./nopaint-upload-mock.mjs"; + +const ac = await ACSession.open(); +const uploads = await mockNoPaintUploads(ac.page, CONFIG.baseURL); +const fingerprint = (pixels) => { + let hash = 2166136261; + const stride = Math.max(1, Math.floor(pixels.length / 4096)); + for (let i = 0; i < pixels.length; i += stride) { hash ^= pixels[i]; hash = Math.imul(hash, 16777619); } + return (hash >>> 0).toString(16).padStart(8, "0"); +}; +async function recordedPaintings() { + return ac.page.evaluate(async () => { + const store = await import("/aesthetic.computer/lib/store.mjs"); + const record = await store.get("painting:record") || []; + return record.filter((step) => step.painting).map(({ label, painting }) => { + let hash = 2166136261; + const stride = Math.max(1, Math.floor(painting.pixels.length / 4096)); + for (let i = 0; i < painting.pixels.length; i += stride) { + hash ^= painting.pixels[i]; hash = Math.imul(hash, 16777619); + } + return { label, width: painting.width, height: painting.height, fingerprint: (hash >>> 0).toString(16).padStart(8, "0") }; + }); + }); +} +async function jump(piece) { + const previousSeed = (await ac.nopaintState())?.seed; + await ac.page.evaluate((piece) => window.acSEND({ type: "jump", content: { piece, ahistorical: false, alias: false } }), piece); + if (piece === "nopaint") { + await ac.page.waitForFunction((seed) => { + const state = window.__acNoPaintTest?.(); + return state?.ready && state.seed !== seed; + }, { timeout: 30000 }, previousSeed); + } else { + await ac.page.waitForFunction(() => location.pathname.startsWith("/line"), { timeout: 30000 }); + } + await ac.wait(700); +} +async function stroke(y) { + await ac.page.mouse.move(160, y); + await ac.page.mouse.down(); + await ac.page.mouse.move(580, y + 90, { steps: 14 }); + await ac.page.mouse.up(); + await ac.wait(400); +} +async function click(box) { + const state = await ac.nopaintState(); + const rect = await ac.page.evaluate(() => { + const r = [...document.querySelectorAll("canvas")].map((c) => c.getBoundingClientRect()) + .sort((a, b) => b.width * b.height - a.width * a.height)[0]; + return { x: r.x, y: r.y, w: r.width, h: r.height }; + }); + await ac.page.mouse.click(rect.x + (box.x + box.w / 2) * rect.w / state.layout.screenResolution.width, + rect.y + (box.y + box.h / 2) * rect.h / state.layout.screenResolution.height); + await ac.wait(200); +} + +try { + await ac.page.setViewport({ width: 760, height: 620 }); + await ac.boot("line:3~red?noauth=1&workerbundle=1"); + await ac.page.mouse.click(380, 300); + await ac.wait(1200); + await stroke(190); + await jump("nopaint"); + const initial = await recordedPaintings(); + assert(initial.length >= 2); + let state = await ac.nopaintState(); + assert.equal(state.paintingFingerprint, initial.at(-1).fingerprint, "first entry imports the manual canvas"); + assert.equal(state.piece.layerCount, initial.length, "first entry keeps the AC brush steps"); + await ac.measureNopaintDecision("ArrowRight"); + const accepted = await ac.nopaintState(); + await jump("line:4~blue"); + await stroke(290); + await stroke(380); + await jump("nopaint"); + const manual = await recordedPaintings(); + assert.notEqual(manual.at(-1).fingerprint, accepted.paintingFingerprint); + const returned = await ac.nopaintState(); + assert.equal(returned.piece.id, accepted.piece.id, "the same painting continues"); + assert.equal(returned.paintingFingerprint, manual.at(-1).fingerprint, "manual edits survive re-entry"); + assert.equal(returned.piece.layerCount, accepted.piece.layerCount + 2, "both manual strokes join the history"); + await ac.shot("nopaint-handoff/manual-edits-returned"); + await jump("line:4~blue"); + await jump("nopaint"); + state = await ac.nopaintState(); + assert.equal(state.paintingFingerprint, returned.paintingFingerprint); + assert.equal(state.piece.layerCount, returned.piece.layerCount, "an unchanged visit adds no duplicate step"); + await ac.measureNopaintDecision("ArrowRight"); + const final = await ac.nopaintState(); + await click(final.paintingButton); + await click((await ac.nopaintState()).controls.done); + await ac.page.waitForFunction(() => window.__acNoPaintTest?.()?.completion.code === "test", { timeout: 30000 }); + const zip = await JSZip.loadAsync(uploads.files.zip); + const steps = JSON.parse(await zip.file("painting.json").async("string")); + const expected = [...manual.map((step) => step.fingerprint), final.paintingFingerprint]; + assert.equal(steps.length, expected.length, "saved playback includes AC and No Paint steps"); + for (let i = 0; i < steps.length; i++) { + const png = await zip.file(`${steps[i].step}.png`).async("nodebuffer"); + assert.equal(fingerprint(await sharp(png).ensureAlpha().raw().toBuffer()), expected[i], `saved frame ${i}`); + } + console.log(JSON.stringify({ result: "passed", steps: steps.length, checks: [ + "manual AC painting imports with its steps", "No Paint → AC brush → No Paint retains both manual strokes", + "same piece identity", "no duplicate steps on unchanged return", "saved ZIP matches the whole mixed history", + ] })); +} finally { await ac.close(); } -- 2.51.2