From 7593ec9d9306181515f242330df4aab6accb2c50 Mon Sep 17 00:00:00 2001 From: Abdul Wahab Date: Thu, 16 Jul 2026 00:15:46 +0500 Subject: [PATCH] Matte transparent artifacts from two sheet passes; require square, well-lit generations Artifact crops now ship with real alpha: each sheet is generated twice (cream, then black via image edit) and fused by a new difference-matting command, sidestepping models' inability to emit alpha and the chroma key's spill. Crop and matte reject non-square inputs so squareness must come from the generation call's 1:1 parameter, and the hero prompt now demands bright, generous light so palettes stay sampleable. Co-authored-by: Cursor --- skill/reference/visual-cues.md | 171 ++++++++++++++++++++++----------- skill/scripts/visual-cues.mjs | 144 +++++++++++++++++++++++++-- 2 files changed, 253 insertions(+), 62 deletions(-) diff --git a/skill/reference/visual-cues.md b/skill/reference/visual-cues.md index 9a81e18f3..856ed4c5d 100644 --- a/skill/reference/visual-cues.md +++ b/skill/reference/visual-cues.md @@ -4,32 +4,32 @@ Loaded by `{{command_prefix}}impeccable document` seed mode (Step 4) when image Tell the user once, before starting: *"Generating visual cues; this can take a minute or two."* Then work without narration. Chat carries no per-image commentary, no palette tables, no prompt dumps; the folder is the deliverable. -## The two images +## The images -Each cue is **two generations by the same agent**, in sequence: +Each cue is **three generations by the same agent**, in sequence: the hero, then the artifact sheet rendered twice, once on cream and once on black. The parent fuses the two sheet passes into one transparent-background sheet before cropping. ```text -HERO [slug].png (1500x1500) ARTIFACT SHEET masters/[slug]-artifacts.png -+---------------------------+ +-------------+-------------+ 1500x1500 -| | | [obj A] | [obj B] | -| one close-framed scene, | | centered, | centered, | -| the product's world, | | ~2/3 of | clear | +HERO [slug].png (1500x1500) ARTIFACT SHEET, two passes (1500x1500) ++---------------------------+ +-------------+-------------+ +| | | [obj A] | [obj B] | pass 1: +| one close-framed scene, | | centered, | centered, | cream +| the product's world, | | ~2/3 of | clear | #FDFCF6 | all four artifact | | its cell | margins | -| objects visible in it, | +-------------+-------------+ 750 -| four palette colors | | [obj C] | [obj D] | -| as large color fields | | | | -| | | flat cream #FDFCF6 across | -+---------------------------+ | the whole canvas, no cell | - saved as-is, NO crop | borders, soft shadows OK | - +-------------+-------------+ - quadrant-cropped into - [slug]-2..5.png, NO matting +| objects visible in it, | +-------------+-------------+ +| four palette colors | pass 2: the SAME image, edit mode, +| as large color fields | background swapped to pure black +| | ++---------------------------+ parent: matte pass1 + pass2 -> + saved as-is, NO crop true-alpha RGBA sheet, quadrant- + cropped into [slug]-2..5.png + (transparent-background PNGs) ``` -- The **hero** is the visual cue: one tightly framed full-bleed composition that stages the concept's palette in large color fields; this is what the user will pick between, so every palette color gets real estate. No grid, no regions: the whole frame is the scene. The four artifact objects all appear inside it. +- The **hero** is the visual cue: one tightly framed full-bleed composition that stages the concept's palette in large color fields; this is what the user will pick between, so every palette color gets real estate. No grid, no regions: the whole frame is the scene. The four artifact objects all appear inside it. The hero stays fully opaque. - The **artifact sheet** is the second generation, with the hero attached as the reference image: the same four objects re-photographed individually, one per quadrant, each isolated and centered on one continuous flat warm-cream background, `#FDFCF6`. The objects inherit the hero's materials and colors; only the setting changes. ("Sheet" is our name for the file; the prompt never uses it.) -- **No transparency, ever.** No alpha channels, no chroma keys, no "transparent background" in any prompt (image models paint a checkerboard instead). Crops keep the cream; soft contact shadows are welcome, they ground the objects. -- **Isolation is a hard rule on the sheet.** Every object centered on its quadrant's center point, filling about two-thirds of it, with clear cream margin on every side: nothing comes near the canvas edge, another object, or the quadrant midlines. The crop cuts exactly at the midlines, so anything crossing one gets clipped. +- The **dark pass** is the third generation, with the cream sheet attached as the reference image: the identical sheet with only the background swapped to pure black. The two passes exist so the parent can compute a real alpha channel by difference matting (a pixel that changed with the backing is background; one that didn't is object). Contact shadows come out as soft semi-transparent black, exactly what a drop shadow should be. +- **Transparency comes only from the matte.** Image models cannot emit alpha channels: a prompt asking for a "transparent background" gets a painted checkerboard, and chroma keys spill their key color onto the objects. So the words "transparent", "alpha", and "checkerboard" never appear in any prompt; the artifact crops get real transparency from the two-pass matte, done by script, after generation. +- **Isolation is a hard rule on the sheet.** Every object centered on its quadrant's center point, filling about two-thirds of it, with clear background margin on every side: nothing comes near the canvas edge, another object, or the quadrant midlines. The crop cuts exactly at the midlines, so anything crossing one gets clipped; and the matte reads each pass's true backing color off the border ring, so an object touching the edge poisons the whole matte. Example, hero = a flower atelier's worktable: sheet quadrants carry the wrapping ribbon, a single stem, a row of loose petals, and the ceramic vase. All four are visible in the hero scene. @@ -121,26 +121,31 @@ Attach one one-line cue concept to the palette; the hero image stages it. ### HERO PROMPT skeleton -Written like screenplay direction, not a keyword list: subject doing something, in a place, in a light. Every palette color is named twice, as a plain-language color and as its hex, and tied to a physical carrier in the scene; a hex with no carrier gets ignored, and a carrier too small reads as noise. The cue's job is to show the palette, so the colors get **real estate**: frame tight on the subject rather than wide on the room, and stage each color as a large unbroken field. A wide atmospheric shot renders the palette as slivers the user cannot judge. Fill every `[bracketed]` slot; never leave template language in the prompt. +Written like screenplay direction, not a keyword list: subject doing something, in a place, in a light. Every palette color is named twice, as a plain-language color and as its hex, and tied to a physical carrier in the scene; a hex with no carrier gets ignored, and a carrier too small reads as noise. The cue's job is to show the palette, so the colors get **real estate**: frame tight on the subject rather than wide on the room, and stage each color as a large unbroken field. A wide atmospheric shot renders the palette as slivers the user cannot judge. + +Light the scene to reveal color, not to set a mood. In a dim, dusky, or nocturnal rendering every hex sinks into one warm-brown murk the user cannot sample from, so bright, generous light is a hard rule even when the concept's moment is dark: an "after hours" or "dawn" concept keeps its props and story but is lit like a studio still, not like the hour. Dark palettes are welcome; dark renderings are not; a near-black primary should read as a rich, clearly-lit surface, not as underexposure. Fill every `[bracketed]` slot; never leave template language in the prompt. ```text One full-bleed photograph, 1500x1500 pixels, framed close: [one scene from -the product's world: subject and what it is doing, setting, time of day], -the subject filling most of the frame, not a wide view of the room. The -scene contains [artifact A], [artifact B], [artifact C], and [artifact D], -all plainly visible. Lighting: [direction and quality, e.g. "low afternoon -window light raking in from the left"]. Camera: [close framing and lens, -e.g. "85mm still life close-up at waist level, shallow depth of field"]. -Mood: [two or three adjectives from the brief's personality]. The scene is -art-directed as bold color blocking in a strict four-color story, every -color a large unbroken field, none reduced to a sliver: [color name] +the product's world: subject and what it is doing, setting], the subject +filling most of the frame, not a wide view of the room. The scene contains +[artifact A], [artifact B], [artifact C], and [artifact D], all plainly +visible. Lighting: bright, clean, and generous, [direction and quality, +e.g. "soft daylight flooding in from a large window on the left"]; the +whole frame clearly lit, no area lost to darkness. Camera: [close framing +and lens, e.g. "85mm still life close-up at waist level, shallow depth of +field"]. Mood: [two or three adjectives from the brief's personality]. The +scene is art-directed as bold color blocking in a strict four-color story, +every color a large unbroken field, none reduced to a sliver: [color name] ([neutral hex]) as the ground, about half the frame; [color name] ([primary hex]) as one continuous mass over roughly a third of the frame, carried by [the main subject]; [color name] ([secondary hex]) as a clear supporting field on [a supporting element]; [color name] ([tertiary hex]) as one vivid accent, small but big enough to read at a glance, on -[a specific object]. Rich, saturated, editorial color. Photorealistic, -real texture. No text, no labels, no numbers, no borders, no watermark. +[a specific object]. Every color reads true and fully saturated in the +light, none sunk in shadow. Rich, saturated, editorial color; not a dim, +dusky, nocturnal, or candlelit image. Photorealistic, real texture. No +text, no labels, no numbers, no borders, no watermark. ``` ### SHEET PROMPT skeleton @@ -170,6 +175,20 @@ No frames, no cell borders, no dividing lines, no watermark, no typography of any kind; the background stays one uninterrupted #FDFCF6 everywhere. ``` +### DARK PASS prompt + +Runs as the **third** generation with the final cream sheet attached as the reference/input image. The matte's math needs the objects pixel-identical between passes and the backing as dark as the model will go, so the prompt is one narrow edit instruction; nothing about it varies per concept. Use it verbatim, no slots to fill: + +```text +Reproduce the attached photograph exactly, with a single change: replace +the flat warm-cream background with a perfectly flat, pure black +background, hex #000000, continuous edge to edge. Every object stays +pixel-identical: same position, same size, same colors, same lighting, +same details. Do not move, redraw, restyle, or relight anything. No text, +no borders, no watermark; the background becomes one uninterrupted pure +black everywhere the cream was. +``` + ## Step 2: Carve the territories Split the brief's color space into six **territories**, one per persona. Each is a one-line claim with two halves: a scene ground (a mood, a moment, a positioning angle) and, always, a **hue ground** it closes on (a named hue register). A hue-silent territory does not constrain color: give six specialists scenic territories and one shared brief, and every one of them will resolve to the brief's hue anchor; the hue ground is what makes the palettes diverge, the scene ground is what makes the stories diverge. Example set for a florist: "the delivery run before the city wakes: cold blue-teal dawn", "the atelier after hours: lacquer near-black with amber", "the potting bench: warm terracotta and unbleached paper", "gallery restraint: paper-white with one ink accent", "market-stall abundance: saturated market greens", "the drying room: muted botanical earth and rose". @@ -178,6 +197,7 @@ Hard rules: - **No two hue grounds share a hue family.** Six registers, six families. - **The Q1 hue anchor belongs to exactly one territory** (two only when the brief argues for it). Name its owner; Step 3 tells everyone else the anchor is off-limits. An anchor left unassigned is an anchor every persona obeys. +- **A territory claims colors, not lighting.** "Lacquer near-black with amber" means those hues, staged in bright, clear light like every other palette; the HERO PROMPT skeleton forbids dim renderings, and a dark-moment territory ("after hours", "dawn") does not override it. - The anti-reference (Q5) rules all six. Assign each territory to the persona whose method suits it best (the Naturalist takes the most material ground, the Dramaturge the most emotional, the Empath the one closest to the audience's state). @@ -186,7 +206,7 @@ Done when: six one-line territories exist, each closing on a hue ground, no two ## Step 3: The wave (parallel) -If the harness exposes any subagent/spawn tool (Task, spawn_agent, agents, or similar), parallel is **required**, not preferred: emit all six spawns as **one tool-call batch, a single message carrying six spawn calls**, one persona per subagent, each doing the full job (palette, concept, both images), and only then wait for the reports. Spawning one, waiting for its report, then spawning the next is a serial loop and a failure even though every spawn "used a subagent"; so is generating any image yourself while a subagent tool exists. The whole run must take only as long as the slowest single persona. Attach the harness's image-generation skill to each spawn when the harness expects that (Codex: the `imagegen` skill). (No subagent tool at all: Step 4.) +If the harness exposes any subagent/spawn tool (Task, spawn_agent, agents, or similar), parallel is **required**, not preferred: emit all six spawns as **one tool-call batch, a single message carrying six spawn calls**, one persona per subagent, each doing the full job (palette, concept, all three generations), and only then wait for the reports. Spawning one, waiting for its report, then spawning the next is a serial loop and a failure even though every spawn "used a subagent"; so is generating any image yourself while a subagent tool exists. The whole run must take only as long as the slowest single persona. Attach the harness's image-generation skill to each spawn when the harness expects that (Codex: the `imagegen` skill). (No subagent tool at all: Step 4.) Every spawn gets the same full template text, slots filled, shared blocks pasted verbatim. Writing spawn 1 in full and compressing spawns 2-6 down to summaries drops the rules exactly where the convergence risk is highest. @@ -194,7 +214,7 @@ Task template: ```text You are a color specialist. You compose one brand palette inside an -assigned territory, then stage it in two images. Use the harness's native +assigned territory, then stage it in images. Use the harness's native image generation tool; do not fall back to CLIs or APIs; do not edit repo files. @@ -231,27 +251,44 @@ hue family another territory claims. Stay inside your own. not own, is a failure to fix now, not one to ship. 4. Build the hero prompt from this skeleton and generate the HERO image, - 1500x1500 (or the nearest supported square). Five sibling specialists - share the generation tool's output folder, so a default output name is - a race that hands you a sibling's image: if the tool accepts an output - filename, pass [slug]-hero.png, and work only with the exact file path - the tool reports back for YOUR generation: + 1500x1500 (or the nearest supported square). Every image you generate + must be square: a "1500x1500" line inside the prompt does not pin the + canvas, so whenever the tool accepts a size or aspect-ratio parameter, + pass square (1:1) explicitly, on this and every later generation. Five + sibling specialists share the generation tool's output folder, so a + default output name is a race that hands you a sibling's image: if the + tool accepts an output filename, pass [slug]-hero.png, and work only + with the exact file path the tool reports back for YOUR generation: [the HERO PROMPT skeleton, with its fill rules] + Open the result before moving on and check its light: if the image is + dim, dusky, or nocturnal, with palette colors sinking into shadow + instead of reading bright and true, regenerate the HERO once, same + prompt, plus this line appended: "Render the scene in bright, generous + daylight-quality studio light; every color fully lit and clearly + readable, no darkness anywhere in the frame." Keep the second result + regardless; the sheet in step 5 must reference whichever hero you + keep. + 5. Build the sheet prompt from this skeleton and generate the ARTIFACT - SHEET, same size, output filename [slug]-artifacts.png, passing the + SHEET, square again, output filename [slug]-artifacts.png, passing the hero file path the tool reported in step 4 as the reference/input image (the tool's image-edit or reference-image mode). Never point the reference at a generic hero.png in a shared folder: [the SHEET PROMPT skeleton, with its notes] -6. Open both files you are about to ship and confirm they are yours: the - hero shows your scene staging your palette, the sheet shows your four - artifacts on cream. A wrong subject or palette means you picked up a - sibling's file from the race in step 4: regenerate that image once - with the [slug] filename. Then check the sheet's geometry: if any +6. Open both files you are about to ship and check them. First the + canvas: each must be exactly square; the compile step rejects + non-square images, so a landscape or portrait result is a failed + generation, and the fix is regenerating with the tool's square (1:1) + size/aspect parameter actually set, not editing the file. Then confirm + they are yours: the hero shows your scene staging your palette, the + sheet shows your four artifacts on cream. A wrong subject or palette + means you picked up a sibling's file from the race in step 4: + regenerate that image once with the [slug] filename. Then check the + sheet's geometry: if any object crosses the canvas edge or the horizontal or vertical centerline, or any text appears anywhere, regenerate the ARTIFACT SHEET once: same reference image, same prompt, plus this line @@ -260,12 +297,24 @@ hue family another territory claims. Stay inside your own. between the objects and around the edges." Never retry more than once per check; keep the second result regardless. -7. Reply with exactly these four lines and nothing else, the paths being - the files you verified in step 6: +7. Generate the DARK PASS with this prompt verbatim, output filename + [slug]-dark.png, passing the final cream sheet from step 6 as the + reference/input image (the tool's image-edit mode). The matte needs + the two passes at identical dimensions, so pass the same size/aspect + parameter as step 5. Open the result: if it is not the exact size of + the cream sheet, if the background is not black, or if the objects + moved or changed against the cream sheet, regenerate it once with the + same prompt and keep the second result: + +[the DARK PASS prompt] + +8. Reply with exactly these five lines and nothing else, the paths being + the files you verified in steps 6 and 7: COMPLETED [slug] HERO [absolute path to the hero PNG] -ARTIFACTS [absolute path to the final sheet PNG] +ARTIFACTS [absolute path to the final cream sheet PNG] +ARTIFACTS-DARK [absolute path to the dark pass PNG] PALETTE primary=#RRGGBB;secondary=#RRGGBB;tertiary=#RRGGBB;neutral=#RRGGBB If either generation fails, reply instead with one line: @@ -274,28 +323,42 @@ ERROR [persona number] [short reason] Six spawns fit the observed Codex ceiling of 6 concurrent subagents, so the wave normally runs whole. If a spawn is rejected with a thread-limit error, collect the accepted spawns, close those agents to release their slots, then run a second pass for the rejects. If every spawn ERRORs because subagents lack the image tool, fall back to Step 4's loop using the territories you already carved. Close every agent after collecting its report. If two reports share a slug, rename one before Step 5 (the crop `--slug` flag controls the filenames). -Done when: every persona has either a four-line COMPLETED report or an ERROR line. An ERROR persona is dropped, not retried more than once; five good cues beat a stalled pipeline. +Done when: every persona has either a five-line COMPLETED report or an ERROR line. An ERROR persona is dropped, not retried more than once; five good cues beat a stalled pipeline. ## Step 4: Serial path (no subagents) -Only when the harness has no subagent tool at all: keep the same six territories and play all **six** personas yourself, one at a time and honestly in-method (the Naturalist names physical sources; the Constraint Poet writes its constraints before composing), following the Step 3 task from its step 1 (palette inside the territory, concept, hero, sheet, look-and-retry) and recording the same four facts a subagent would report (slug, both paths, palette). The user still gets six cues; only the clock differs. +Only when the harness has no subagent tool at all: keep the same six territories and play all **six** personas yourself, one at a time and honestly in-method (the Naturalist names physical sources; the Constraint Poet writes its constraints before composing), following the Step 3 task from its step 1 (palette inside the territory, concept, hero, sheet, look-and-retry, dark pass) and recording the same facts a subagent would report (slug, all three paths, palette). The user still gets six cues; only the clock differs. Same done-condition as Step 3, over all six personas. -## Step 5: Crop and compile +## Step 5: Matte, crop, and compile -Before cropping, hash every reported hero (`md5 [paths]`): each must be unique. Two identical heroes mean two subagents raced on a shared default output filename; re-spawn one of the pair (same task) and take its fresh files before compiling. +Before anything else, two gates on the reported files: -For each COMPLETED report, run one command, carrying the report's slug and its `PALETTE` line: +- **Unique**: hash every reported hero (`md5 [paths]`); each must be unique. Two identical heroes mean two subagents raced on a shared default output filename; re-spawn one of the pair (same task) and take its fresh files before compiling. +- **Square**: check every reported image's dimensions (`sips -g pixelWidth -g pixelHeight [paths]` on macOS); width must equal height. Both scripts below reject non-square inputs, and squaring after the fact is off the table (cropping eats scene, padding invents background), so a non-square hero or cream sheet is a failed generation: re-spawn that persona once, with its same task, and take the fresh files. Still non-square after the re-spawn: drop the cue. (A non-square dark pass alone is handled below without a re-spawn.) + +For each COMPLETED report, first fuse the two sheet passes into a transparent-background sheet: ```text -node {{scripts_path}}/visual-cues.mjs crop [hero.png] [artifacts.png] \ +node {{scripts_path}}/visual-cues.mjs matte [artifacts.png] [artifacts-dark.png] \ + --out [slug]-matted.png +``` + +Judge the matte by the stats it prints: healthy is `transparentPct` of at least 30 and `opaquePct` of at least 5. A tiny `transparentPct` means the dark pass never replaced the background; a tiny `opaquePct` means the model redrew the objects between passes; a size-mismatch error means the tool changed canvas between passes. Any of these, or any other command error: crop the **cream sheet** instead of the matted one below; an opaque cue beats a dropped cue. Do not retry the matte with the same inputs, its output is deterministic. + +A dark pass alone failing the square gate does not need a re-spawn: matte rejects it as a command error and the cream-sheet fallback above covers the cue. + +Then crop, carrying the report's slug and its `PALETTE` line: + +```text +node {{scripts_path}}/visual-cues.mjs crop [hero.png] [slug]-matted.png \ --slug [slug] \ --palette "primary=#RRGGBB;secondary=#RRGGBB;tertiary=#RRGGBB;neutral=#RRGGBB" \ --out .impeccable/visual-cues ``` -The script copies the hero untouched to `[slug].png`, keeps the sheet under `masters/[slug]-artifacts.png`, and quadrant-crops the sheet into `[slug]-2.png` through `[slug]-5.png` (cream stays; nothing is matted). For each palette role it searches the hero for the closest rendered pixel (`snapped`, with its hero position), then updates `cues.json`: +The script copies the hero untouched to `[slug].png`, keeps the sheet it was given under `masters/[slug]-artifacts.png`, and quadrant-crops it into `[slug]-2.png` through `[slug]-5.png`, preserving the matte's alpha so each artifact ships as a transparent-background PNG. For each palette role it searches the hero for the closest rendered pixel (`snapped`, with its hero position), then updates `cues.json`: ```json { diff --git a/skill/scripts/visual-cues.mjs b/skill/scripts/visual-cues.mjs index 0c3a2e02f..cd22aba07 100644 --- a/skill/scripts/visual-cues.mjs +++ b/skill/scripts/visual-cues.mjs @@ -2,17 +2,30 @@ // visual-cues.mjs — crop + compile for document seed visual cues. // Pipeline doc: skill/reference/visual-cues.md (canonical; this help text is not). // -// Each cue is two images: a full-bleed hero scene and an artifact sheet -// (four objects on one flat cream canvas, one per quadrant). No alpha, no -// chroma key: crops keep the cream. +// Each cue is a full-bleed hero scene plus an artifact sheet (four objects, +// one per quadrant) rendered twice: on cream and on black. `matte` fuses +// the two passes into one transparent-background RGBA sheet; `crop` cuts +// any sheet into per-artifact PNGs, preserving whatever alpha it carries. // // node visual-cues.mjs crop --slug // [--palette "primary=#RRGGBB;secondary=...;tertiary=...;neutral=..."] // [--out ] (default: .impeccable/visual-cues) -// Copies the hero untouched to .png, keeps the sheet under -// /masters/-artifacts.png, quadrant-crops the sheet into -// -2..5.png, finds each planned palette hex's closest pixel in -// the hero, and updates /cues.json. +// Copies the hero untouched to .png, keeps the sheet it was +// given under /masters/-artifacts.png, quadrant-crops the +// sheet into -2..5.png (alpha preserved), finds each planned +// palette hex's closest pixel in the hero, and updates /cues.json. +// Both inputs must be square: generation happens on a square canvas +// (a size/aspect parameter, not just a prompt line), and a non-square +// input is a generation to redo, not an image to fix up here. +// +// node visual-cues.mjs matte --out +// Difference matting: the same artifact sheet rendered twice, once on +// the cream backing and once on pure black, fuses into one RGBA PNG +// with a computed alpha channel. For image models that can't emit +// alpha natively (prompting for "transparent" gets a painted +// checkerboard; chroma keys spill). Prints coverage stats so the +// caller can tell a failed matte (background never changed between +// passes / objects moved between passes). // // Dependency-free: PNG decode/encode on node:zlib. Rejects interlaced and // indexed-color PNGs; convert those with sips/ImageMagick/PIL first. @@ -226,6 +239,16 @@ function cropRegion(img, r) { return out; } +// The pipeline ships squares, and squaring after the fact always loses +// something (cropping eats scene, padding invents background), so square +// is required at the source: the generation call must pin a 1:1 canvas. +// A non-square input here means that call must be redone. +function requireSquare(img, label) { + if (img.width !== img.height) { + throw new Error(`${label} is ${img.width}x${img.height}, not square; regenerate it with the tool's square (1:1) size/aspect parameter, a prompt line alone does not pin the canvas`); + } +} + // ----------------------------------------------------------------- palette // role=#RRGGBB per entry; a legacy trailing @x,y is accepted and ignored @@ -281,6 +304,108 @@ function snapPalette(img, palette) { return out; } +// ------------------------------------------------------------------- matte + +// Difference matting (triangulation matting with two known backings). +// An observed pixel is foreground composited over a backing: +// observed = alpha * color + (1 - alpha) * backing +// With the same foreground shot over a light backing L and a dark backing +// D, subtracting the two observations cancels the foreground term: +// observedL - observedD = (1 - alpha) * (L - D) +// so per channel: alpha = 1 - (observedL - observedD) / (L - D), and the +// true color unpremultiplies from the dark observation, whose backing +// contributes nothing: color = darkObs / alpha. No key color means nothing +// to spill (what broke the chroma approach); contact shadows fall out as +// semi-transparent black, a natural drop shadow. + +// The model never renders the backing at its exact nominal hex, so read +// the real backing off each image instead: per-channel median of the +// outer border ring, which the sheet's isolation rules guarantee is pure +// background. +function estimateBacking(img, inset = 4) { + const { width: w, height: h, rgba } = img; + const samples = [[], [], []]; + const take = (x, y) => { + const o = (y * w + x) * 4; + for (let c = 0; c < 3; c++) samples[c].push(rgba[o + c]); + }; + for (let x = 0; x < w; x += 3) { take(x, inset); take(x, h - 1 - inset); } + for (let y = 0; y < h; y += 3) { take(inset, y); take(w - 1 - inset, y); } + return samples.map((arr) => { + arr.sort((a, b) => a - b); + return arr[arr.length >> 1]; + }); +} + +function cmdMatte(args) { + const [lightFile, darkFile] = args._; + if (!lightFile || !darkFile || !args.out) { + fail('usage: visual-cues.mjs matte --out '); + } + const light = decodePng(readFileSync(resolve(lightFile))); + const dark = decodePng(readFileSync(resolve(darkFile))); + requireSquare(light, 'light pass'); + requireSquare(dark, 'dark pass'); + if (light.width !== dark.width || light.height !== dark.height) { + fail(`size mismatch: light ${light.width}x${light.height} vs dark ${dark.width}x${dark.height}; regenerate the dark pass at the light pass's exact size`); + } + const backingL = estimateBacking(light); + const backingD = estimateBacking(dark); + const meanSpan = (backingL[0] - backingD[0] + backingL[1] - backingD[1] + backingL[2] - backingD[2]) / 3; + if (meanSpan < 96) { + fail(`backings too similar to matte (light ${JSON.stringify(backingL)} vs dark ${JSON.stringify(backingD)}); the dark pass likely kept the light background`); + } + + const n = light.width * light.height; + const out = Buffer.alloc(n * 4); + let transparent = 0; + let opaque = 0; + for (let i = 0; i < n; i++) { + const o = i * 4; + // Average the per-channel alpha estimates; channels where the two + // backings barely differ carry no signal and are skipped. + let sum = 0; + let used = 0; + for (let c = 0; c < 3; c++) { + const span = backingL[c] - backingD[c]; + if (Math.abs(span) < 48) continue; + sum += 1 - (light.rgba[o + c] - dark.rgba[o + c]) / span; + used++; + } + let a = used ? Math.round(255 * Math.max(0, Math.min(1, sum / used))) : 255; + // Snap the ends: generation noise leaves alpha a few counts off the + // rails, which would otherwise put a faint film over the whole + // background and pinholes inside solid objects. + if (a <= 16) a = 0; + else if (a >= 240) a = 255; + if (a === 0) transparent++; + else if (a === 255) opaque++; + for (let c = 0; c < 3; c++) { + // Recover true color from the dark pass, removing its (near-black) + // backing contribution before unpremultiplying. + if (a === 0) { out[o + c] = 0; continue; } + const fg = dark.rgba[o + c] - ((255 - a) / 255) * backingD[c]; + out[o + c] = Math.max(0, Math.min(255, Math.round((fg * 255) / a))); + } + out[o + 3] = a; + } + writeFileSync(resolve(args.out), encodePng(out, light.width, light.height)); + console.log(JSON.stringify({ + ok: true, + out: resolve(args.out), + width: light.width, + height: light.height, + // Callers judge the matte from these: a healthy four-object sheet cuts + // out to a mostly-transparent canvas with solid object cores. Tiny + // transparentPct means the dark pass never replaced the background; + // tiny opaquePct means the two passes disagree everywhere (the model + // moved or redrew the objects between passes). + transparentPct: Math.round((100 * transparent) / n), + opaquePct: Math.round((100 * opaque) / n), + partialPct: Math.round((100 * (n - transparent - opaque)) / n), + }, null, 2)); +} + // ---------------------------------------------------------------- cues.json // Reads the existing cues.json (if any) and merges this cue in, so cropping @@ -337,6 +462,8 @@ function cmdCrop(args) { const outDir = resolve(args.out || '.impeccable/visual-cues'); const hero = decodePng(readFileSync(resolve(heroFile))); const sheet = decodePng(readFileSync(resolve(sheetFile))); + requireSquare(hero, 'hero'); + requireSquare(sheet, 'artifact sheet'); mkdirSync(join(outDir, 'masters'), { recursive: true }); const heroPath = join(outDir, `${slug}.png`); @@ -383,7 +510,8 @@ function main() { const args = parseArgs(rest); try { if (cmd === 'crop') cmdCrop(args); - else fail('usage: visual-cues.mjs crop --slug [options] (see reference/visual-cues.md)'); + else if (cmd === 'matte') cmdMatte(args); + else fail('usage: visual-cues.mjs ... (see reference/visual-cues.md)'); } catch (err) { fail(err.message); }