diff --git a/papers/nopaint-3-full-shape/nopaint-3-full-shape.tex b/papers/nopaint-3-full-shape/nopaint-3-full-shape.tex index f2723f0906..7839468042 100644 --- a/papers/nopaint-3-full-shape/nopaint-3-full-shape.tex +++ b/papers/nopaint-3-full-shape/nopaint-3-full-shape.tex @@ -295,6 +295,14 @@ Data minimization & A removal request asks for archive id, reason, and only enou Recommended review states are \mono{pending}, \mono{machine-flagged}, \mono{human-review}, \mono{restricted}, \mono{removal-requested}, \mono{removed}, and \mono{cleared-with-date}. A face detector is a triage aid, not a privacy verdict: false negatives are expected, stylized or embedded photos may evade detection, and non-face personal data still matters. Default public behavior should blur/restrict strong photographic-person matches pending review; removed records retain only a non-public minimal tombstone sufficient to prevent accidental re-publication. +\subsection{Continuation and productization} + +Each record is also a fork point. \textbf{No} opens a fresh deterministic canvas while retaining a private provenance note that the archive image was declined as a starting surface. \textbf{Paint} imports the top 256-by-256 artwork (not its baked caption bar), establishes it as the undo base, and carries a \mono{derivedFrom} archive id, record URL, and image URL into the No Paint session. The displayed save date, title/OCR text, visual description, tags, colors, and subject remain attached to the public archive record. + +The existing AC mug checkout already supplies Stripe payment, Printful mockups, fulfillment, product records, and order recovery. Archive commerce should reuse it through a narrow, allowlisted image-source adapter rather than accepting arbitrary URLs. A record exposes merchandise only when it has an explicit \mono{merchEligible: true} following face/privacy and rights review; missing or false means ``merchandise review pending.'' Removal must disable future checkout and product discovery and remove cached previews where appropriate. + +The product ladder is \textbf{mug first}, then \textbf{standard mouse pad}, then \textbf{desk mat}; posters, framed prints, and stickers follow only after the review system is operating. Mouse pads suit full-surface sublimation, but should use a generalized product catalog (product, variant, print geometry, placement, retail price, mockup, eligibility) rather than a copied mug endpoint. Printful mockups are asynchronous and rate-limited, so generate on product intent and cache the result instead of creating mockups during every record-page view. + \section{Painting to code, without pretending} A raster cannot reveal one true source program. The product should call the result a \textbf{reading}, \textbf{response}, or \textbf{inferred score}. diff --git a/system/netlify/functions/pixel.js b/system/netlify/functions/pixel.js index 17dec086a5..bc11619fb0 100644 --- a/system/netlify/functions/pixel.js +++ b/system/netlify/functions/pixel.js @@ -25,7 +25,26 @@ import { join, dirname } from "path"; import { fileURLToPath } from "url"; import { execSync } from "child_process"; import { respond } from "../../backend/http.mjs"; -const dev = process.env.CONTEXT === "dev"; +const dev = process.env.CONTEXT === "dev"; +let nopaintArchiveIds; + +function isNopaintArchiveId(id) { + if (!/^[A-Za-z0-9]{4,32}$/.test(id)) return false; + if (!nopaintArchiveIds) { + try { + const here = dirname(fileURLToPath(import.meta.url)); + const manifest = JSON.parse(readFileSync( + join(here, "../../public/nopaint.art/gallery/gallery.json"), + "utf8", + )); + nopaintArchiveIds = new Set(manifest.paintings.map((painting) => painting.id)); + } catch (error) { + console.error("Could not load No Paint archive allowlist:", error.message); + nopaintArchiveIds = new Set(); + } + } + return nopaintArchiveIds.has(id); +} // Load Comic Relief Bold font and register with fontconfig let comicReliefFontAvailable = false; @@ -74,7 +93,7 @@ async function fun(event, context) { const printCodePattern = /^\+([a-z0-9]{6})(\.png|\.webp)?$/; const printCodeMatch = slug.match(printCodePattern); - if (printCodeMatch) { + if (printCodeMatch) { const productCode = printCodeMatch[1]; console.log(`๐Ÿ–จ๏ธ Looking up print/product by code: +${productCode}`); @@ -94,12 +113,23 @@ async function fun(event, context) { console.error(`โŒ Error looking up product code: ${error.message}`); imageUrl = `https://art.aesthetic.computer/products/${productCode}.webp`; } - } + } + + // Explicit, allowlisted bridge from the historical No Paint archive into + // AC's resize/product pipeline. Never accept an arbitrary external URL. + const nopaintMatch = !imageUrl && slug.match(/^np-([A-Za-z0-9]{4,32})(?:\.png)?$/); + if (nopaintMatch) { + const archiveId = nopaintMatch[1]; + if (!isNopaintArchiveId(archiveId)) { + return respond(404, { message: "No Paint archive record not found." }); + } + imageUrl = `https://pix.nopaint.art/${archiveId}.png`; + } // Check if slug is a painting code (short alphanumeric without path separators) // Examples: "mgy.png", "abc", "t84.png", "8BzEwZGt.png" const codePattern = /^([a-zA-Z0-9]{2,10})(\.png)?$/; - const codeMatch = !imageUrl && slug.match(codePattern); + const codeMatch = !imageUrl && slug.match(codePattern); if (codeMatch) { const code = codeMatch[1]; // Extract code without extension diff --git a/system/public/aesthetic.computer/disks/nopaint.mjs b/system/public/aesthetic.computer/disks/nopaint.mjs index b01e21c658..f42e3ea14d 100644 --- a/system/public/aesthetic.computer/disks/nopaint.mjs +++ b/system/public/aesthetic.computer/disks/nopaint.mjs @@ -23,6 +23,7 @@ let saveCount = 0; let lastDownload = null; let testApi = null; let testChannel = null; +let archiveOrigin = null; function initialNavigationURL() { if (typeof window === "undefined") return null; @@ -94,17 +95,60 @@ function persistPainting({ store, system }) { store.persist("painting", "nopaint:session", "local:db"); } +async function loadArchivePainting(api, archiveId) { + const id = String(archiveId || "").trim(); + if (!/^[A-Za-z0-9]{4,32}$/.test(id)) return; + + archiveOrigin = { + type: "nopaint-archive", + id, + record: `https://nopaint.art/${id}`, + image: `https://pix.nopaint.art/${id}.png`, + status: "loading", + }; + api.store["nopaint:origin"] = { ...archiveOrigin }; + api.store.persist("nopaint:origin", "local:db"); + + try { + const loaded = await api.net.preload({ + path: archiveOrigin.image, + extension: "png", + }); + const bitmap = loaded?.img || loaded; + const source = api.painting(256, 256, (p) => p.paste(bitmap, 0, 0)); + api.system.nopaint.replace(api, source, `nopaint-archive:${id}`); + api.system.nopaint.buffer = api.painting(256, 256, (p) => + p.wipe(255, 255, 255, 0) + ); + archiveOrigin.status = "ready"; + api.store["nopaint:origin"] = { ...archiveOrigin }; + api.store.persist("nopaint:origin", "local:db"); + proposalNumber = 0; + chooseProposal(api); + api.hud.label(`No Paint โ† archive ${id}`); + publishTestState(); + } catch (error) { + archiveOrigin.status = "error"; + archiveOrigin.error = error?.message || "Archive painting could not load"; + api.store["nopaint:origin"] = { ...archiveOrigin }; + api.store.persist("nopaint:origin", "local:db"); + console.error(`No Paint archive load failed for ${id}:`, error); + } +} + function recordDecision({ store }, decision) { decisions.push(Object.freeze({ number: proposalNumber, operation: proposal?.kind, decision, })); - store["nopaint:session"] = { + const session = { version: NOPAINT_VERSION, seed: sessionSeed, decisions: decisions.slice(), }; + if (archiveOrigin) session.origin = { ...archiveOrigin }; + store["nopaint:session"] = session; store.persist("nopaint:session", "local:db"); } @@ -185,6 +229,7 @@ function testSnapshot() { } : null, }, paintingFingerprint: paintingFingerprint(testApi?.system?.painting), + origin: archiveOrigin ? { ...archiveOrigin } : null, }; } @@ -194,7 +239,8 @@ function publishTestState() { function installTestHook(debug) { const explicitlyTesting = initialNavigationURL()?.searchParams.has("test"); - if (!debug && !explicitlyTesting && typeof window !== "undefined" && !window.acDEBUG) return; + const windowDebug = typeof window !== "undefined" && window.acDEBUG; + if (!debug && !explicitlyTesting && !windowDebug) return; if (typeof BroadcastChannel !== "undefined") { testChannel?.close(); @@ -224,7 +270,9 @@ function boot({ colon, debug, hud, net, num, params, screen, system, ui, ...api // The runtime may rewrite the visible route before the piece boots. The // Navigation Timing entry retains the original tutorial/test URL. const urlSeed = initialNavigationURL()?.searchParams.get("seed") || null; - const requestedSeed = urlSeed || colon[0] || params[0]; + const archiveId = params[0] === "archive" ? params[1] : null; + const freshFromId = params[0] === "new" ? params[1] : null; + const requestedSeed = urlSeed || archiveId || freshFromId || colon[0] || params[0]; const numericSeed = /^\d+$/.test(requestedSeed || "") ? Number(requestedSeed) >>> 0 : null; @@ -239,7 +287,19 @@ function boot({ colon, debug, hud, net, num, params, screen, system, ui, ...api decisions = []; saveCount = 0; lastDownload = null; - testApi = { ...api, screen, system }; + archiveOrigin = archiveId ? { + type: "nopaint-archive", + id: archiveId, + record: `https://nopaint.art/${archiveId}`, + image: `https://pix.nopaint.art/${archiveId}.png`, + status: "queued", + } : freshFromId ? { + type: "nopaint-archive", + id: freshFromId, + record: `https://nopaint.art/${freshFromId}`, + action: "rejected-as-start", + } : null; + testApi = { ...api, hud, net, screen, system }; stateBeforePause = "proposing"; noButton = new ui.TextButton("No"); @@ -248,10 +308,11 @@ function boot({ colon, debug, hud, net, num, params, screen, system, ui, ...api positionButtons(screen); hud.label("No Paint โ€” No [N] / Paint [Enter]"); - net.rewrite(`/nopaint:${sessionSeed}`); + net.rewrite(archiveId ? `/nopaint~archive~${archiveId}` : `/nopaint:${sessionSeed}`); installTestHook(debug); chooseProposal(testApi); publishTestState(); + if (archiveId) loadArchivePainting(testApi, archiveId); } // ๐Ÿงฎ Sim diff --git a/system/public/nopaint.art/painting/index.html b/system/public/nopaint.art/painting/index.html index ac82274ef8..a527ec0fc8 100644 --- a/system/public/nopaint.art/painting/index.html +++ b/system/public/nopaint.art/painting/index.html @@ -24,6 +24,8 @@ .actions { display:flex; flex-wrap:wrap; gap:8px; } .button { display:inline-block; padding:7px 11px; border:2px solid #222; background:#fff; font-size:17px; text-decoration:none; } .button.danger { background:#222; color:#fff; } + .button[aria-disabled="true"] { color:#777; border-color:#aaa; background:#ddd; cursor:not-allowed; } + .product { margin-top:18px; padding-top:14px; border-top:2px solid #222; } #missing { display:none; padding:28px 0; font-size:20px; } @media(max-width:650px){ body{padding:12px}.record{grid-template-columns:1fr}.image{max-width:520px} } @@ -40,6 +42,8 @@
saved
description
tags
+
colors
+
subject
privacy
@@ -51,9 +55,19 @@ You can request review for an accidental camera image, recognizable person, minor-safety concern, personal information, creator request, or other rights issue. You do not need to claim copyright to raise a privacy or safety concern. Include this archive id and only the minimum contact information needed for a reply.
+ No โ€” start fresh + Paint โ€” continue working flag / request removal open original PNG
+
+ Make it physical +

Merchandise review pending for this painting.

+
+ Mug ยท $18 + Mouse pad ยท planned +
+
This archive record was not found. Browse the gallery.
@@ -71,12 +85,24 @@ text("id", id); text("title", painting.t || `No Paint ${id}`); text("date", painting.d ? new Date(painting.d).toLocaleString() : "Unknown"); text("description", painting.desc); text("tags", painting.tags); + text("colors", painting.c); text("subject", painting.s); text("privacy", painting.unsafe ? "flagged for photographic-face review" : "archive-wide face/privacy review pending"); const image = document.getElementById("image"); image.src = original; image.alt = painting.desc || `No Paint archive painting ${id}`; document.getElementById("original").href = original; + document.getElementById("no").href = `https://aesthetic.computer/nopaint~new~${encodeURIComponent(id)}`; + document.getElementById("paint").href = `https://aesthetic.computer/nopaint~archive~${encodeURIComponent(id)}`; const subject = encodeURIComponent(`No Paint archive review: ${id}`); const body = encodeURIComponent(`Archive record: https://nopaint.art/${id}\nReason for review/removal:\n\nRelationship to the image (optional):\nPreferred reply address:`); document.getElementById("flag").href = `mailto:mail@aesthetic.computer?subject=${subject}&body=${body}`; + if (painting.merchEligible === true) { + const mug = document.getElementById("mug"); + mug.href = `https://aesthetic.computer/mug~np-${encodeURIComponent(id)}`; + mug.removeAttribute("aria-disabled"); + document.getElementById("merchStatus").textContent = "Approved for made-to-order products."; + } + for (const disabled of document.querySelectorAll('[aria-disabled="true"]')) { + disabled.addEventListener("click", event => event.preventDefault()); + } record.hidden = false; }).catch(() => { missing.style.display = "block"; }); diff --git a/tests/browser/ac-harness.mjs b/tests/browser/ac-harness.mjs index e3e07e9bf5..fc73f92591 100644 --- a/tests/browser/ac-harness.mjs +++ b/tests/browser/ac-harness.mjs @@ -85,7 +85,9 @@ export class ACSession { async boot(piece = "prompt") { const url = `${CONFIG.baseURL}/${piece}`; console.log(` โ†’ ${url}`); - await this.page.goto(url, { waitUntil: "networkidle2", timeout: 45000 }); + // Pieces may intentionally keep media/CDN requests open (archive imports, + // cameras, streams), so readiness comes from AC's boot marker/test hook. + await this.page.goto(url, { waitUntil: "domcontentloaded", timeout: 45000 }); // Prefer the prompt test hook; fall back to the boot marker; then a // fixed settle so the canvas has painted. try { diff --git a/tests/browser/nopaint-journey.test.mjs b/tests/browser/nopaint-journey.test.mjs index 9a62b02b51..96a02597db 100644 --- a/tests/browser/nopaint-journey.test.mjs +++ b/tests/browser/nopaint-journey.test.mjs @@ -96,6 +96,20 @@ try { "the browser received a PNG download or native-share export request", ); }); + + await scenario("An archive record can become the starting painting", async (expect) => { + await ac.boot("nopaint~archive~l4f0ipzy"); + let state = await ac.nopaintState(); + for (let attempt = 0; attempt < 40 && state?.origin?.status !== "ready"; attempt++) { + await ac.wait(250); + state = await ac.nopaintState(); + } + await receipt("06-archive-origin"); + expect(state?.origin?.id === "l4f0ipzy", "archive id remains attached as provenance"); + expect(state?.origin?.status === "ready", `archive pixels load (${state?.origin?.status})`); + expect(state?.paintingFingerprint !== null, "archive image establishes a painting base"); + expect(state?.proposalNumber === 1, "proposal sequence restarts over the imported painting"); + }); } finally { await ac.close(); }