Switch artifact sheets to per-cue chroma keys kept in crops

Drop the two-pass matte pipeline in favor of one chroma-key sheet per
cue: each specialist picks the farthest-hue key, stages larger artifacts
with shadowless crisp edges, reports CHROMA in its reply, and crop records
the hex in cues.json for browser keying downstream.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Abdul Wahab
2026-09-01 10:00:42 +05:00
co-authored by Cursor
parent 7593ec9d93
commit f8981c61dd
2 changed files with 111 additions and 220 deletions
+83 -97
View File
@@ -6,30 +6,32 @@ Tell the user once, before starting: *"Generating visual cues; this can take a m
## The images
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.
Each cue is **two generations by the same agent**, in sequence: the hero, then the artifact sheet on a chroma-key background the specialist chooses.
```text
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
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, | | filling | thin |
| all four artifact | | its cell | margins |
| 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)
| four palette colors | | [obj C] | [obj D] |
| as large color fields | | | |
| | | one flat chroma-key color |
+---------------------------+ | everywhere, no shadows, |
saved as-is, NO crop | crisp edges, no bounce |
+-------------+-------------+
quadrant-cropped into
[slug]-2..5.png, key kept;
the browser keys it out
```
- 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.)
- 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.
- 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 **artifact sheet** is the second generation, with the hero attached as the reference image: the same four objects re-photographed individually, one per quadrant, on one continuous flat field of the cue's **chroma key**. 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.)
- **The key is chosen per cue, and it ships.** Each specialist picks its key from the candidate set in the task, whichever sits farthest in hue from its palette and its artifact materials, and reports the hex. Crops keep the key; `cues.json` records it; the browser canvas keys it out downstream. No matting, no alpha work, no background removal anywhere in this pipeline.
- **Edges decide whether keying works.** Shadows, blurred silhouettes, and bounce light each blend key into object and leave an edge fringe no threshold removes cleanly; the SHEET PROMPT skeleton carries the counter-rules and the task's keying-plate check enforces them.
- **Isolation is a hard rule on the sheet.** Every object centered on its quadrant's center point, filling nearly the whole quadrant, with a thin clear margin on every side: nothing touches the canvas edge, another object, or the quadrant midlines. The crop cuts exactly at the midlines, so anything crossing one gets clipped.
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.
@@ -49,7 +51,7 @@ Write one self-contained text block that a specialist with zero context can desi
- **The interview**: Q1 color strategy and hue anchor, Q2 type direction, Q4 the three named references, Q5 the anti-reference. State that the anti-reference is a hard constraint on every palette.
- **The assets**: the seed Step 2 observations (logo colors, recurring materials, photo moods).
Label it `BRIEF PACKET` and reuse the same block verbatim in every spawn of both phases; a packet that drifts between spawns invalidates the comparison. Do **not** add your own palette leanings to it: the personas do the leaning.
Label it `BRIEF PACKET` and reuse the same block verbatim in every spawn; a packet that drifts between spawns invalidates the comparison. Do **not** add your own palette leanings to it: the personas do the leaning.
## The six personas
@@ -150,43 +152,36 @@ text, no labels, no numbers, no borders, no watermark.
### SHEET PROMPT skeleton
Runs as the **second** generation with the hero attached as the reference/input image, so the objects match the scene instead of being reinvented. It describes plain product photography; any word that implies an editorial layout ("catalog", "sheet", "spread", "grid") invites the model to design a page with titles and captions. The cream is constant across all cues (`#FDFCF6`; the artifacts land on this exact surface downstream); never restyle it per concept.
Runs as the **second** generation with the hero attached as the reference/input image, so the objects match the scene instead of being reinvented. It describes plain product photography; any word that implies an editorial layout ("catalog", "sheet", "spread", "grid") invites the model to design a page with titles and captions.
**Choose the chroma key first.** Candidates: chroma green `#00FF00`, chroma magenta `#FF00FF`, chroma cyan `#00FFFF`, chroma blue `#0000FF`. Pick the one farthest in hue from every palette color and every artifact material (a florist's green stems rule out green; a magenta-flowered concept rules out magenta), and fill it into every `[key color name]` and `[key hex]` slot. The key ships in the crops and gets keyed out later, so the prompt's one job beyond the objects is a keyable backdrop: one pure, flat field of the key, crisp silhouettes, and no shadow, blur, or bounce light at the edges, because each of those blends key into object and leaves a fringe no threshold removes.
```text
Using the attached photograph as the exact reference for objects, materials,
and colors: one square photograph, 1500x1500 pixels, of four objects from
that scene, each re-photographed individually from directly overhead in soft
even studio light. This is a plain photograph of objects resting on a bare
surface. Absolutely no text anywhere in the image: no letters, no words, no
numbers, no labels, no captions, no title.
that scene, each re-photographed individually from directly overhead in
flat, even, shadowless studio light. This is a plain photograph of objects
laid on a professional chroma-key backdrop. Absolutely no text anywhere in
the image: no letters, no words, no numbers, no labels, no captions.
The surface is one perfectly flat warm-cream background, hex #FDFCF6,
continuous edge to edge. Picture the canvas divided into four equal
quadrants: [artifact A] top-left, [artifact B] top-right, [artifact C]
bottom-left, [artifact D] bottom-right.
The backdrop is one perfectly flat, uniform [key color name] field, hex
[key hex], the identical pure color from edge to edge, like a keying
screen in a studio. The objects cast no shadow onto it and pick up none
of its color; every silhouette is crisp and in sharp focus against the
flat [key color name], and the [key color name] appears nowhere on the
objects themselves.
Each object sits exactly centered on its quadrant's center point, filling
about two-thirds of its quadrant, with clear cream margin on every side:
nothing comes anywhere near the canvas edges or the horizontal and vertical
centerlines of the image. A soft gentle contact shadow under each object is
welcome.
Picture the canvas divided into four equal quadrants: [artifact A]
top-left, [artifact B] top-right, [artifact C] bottom-left, [artifact D]
bottom-right. Each object sits exactly centered on its quadrant's center
point and fills its quadrant almost completely, as large as it can be
while a thin clear band of backdrop stays visible on every side: nothing
touches the canvas edges or the horizontal and vertical centerlines of
the image.
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.
No frames, no cell borders, no dividing lines, no watermark, no shadows,
no reflections, no gradients; the backdrop stays one uninterrupted
[key hex] everywhere.
```
## Step 2: Carve the territories
@@ -206,7 +201,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, 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.)
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.)
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.
@@ -271,11 +266,12 @@ hue family another territory claims. Stay inside your own.
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, 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:
5. Choose your chroma key by the skeleton's rule, then build the sheet
prompt and generate the ARTIFACT 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]
@@ -285,36 +281,33 @@ hue family another territory claims. Stay inside your own.
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:
sheet shows your four artifacts on your chroma key. 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 as a keying plate: the backdrop one flat field of your key, the
silhouettes crisp, the objects free of shadows, blur, and key-colored
bounce at their edges, and the key color absent from the objects
themselves. If the backdrop fails any of that, regenerate the ARTIFACT
SHEET once: same reference image, same prompt, plus this line
appended: "Make every object smaller, at most half of its quadrant,
pulled in tight to its quadrant's center, with even more empty cream
between the objects and around the edges." Never retry more than once
per check; keep the second result regardless.
appended: "The backdrop must be one perfectly uniform [key hex] with
zero shadows and zero color bounce; every object edge razor-sharp
against it." Finally check the 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 appended: "Make every object
smaller, at most two-thirds of its quadrant, pulled in tight to its
quadrant's center, with a wider band of clear backdrop between the
objects and around the edges." Never retry more than once per check;
keep the second result regardless.
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:
7. Reply with exactly these five lines and nothing else, the paths being
the files you verified in step 6 and the key being the hex you filled
into the sheet prompt:
COMPLETED [slug]
HERO [absolute path to the hero PNG]
ARTIFACTS [absolute path to the final cream sheet PNG]
ARTIFACTS-DARK [absolute path to the dark pass PNG]
ARTIFACTS [absolute path to the final sheet PNG]
CHROMA #RRGGBB
PALETTE primary=#RRGGBB;secondary=#RRGGBB;tertiary=#RRGGBB;neutral=#RRGGBB
If either generation fails, reply instead with one line:
@@ -327,38 +320,28 @@ Done when: every persona has either a five-line COMPLETED report or an ERROR lin
## 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, 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.
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 facts a subagent would report (slug, both paths, chroma key, palette). The user still gets six cues; only the clock differs.
Same done-condition as Step 3, over all six personas.
## Step 5: Matte, crop, and compile
## Step 5: Crop and compile
Before anything else, two gates on the reported files:
- **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.)
- **Square**: check every reported image's dimensions (`sips -g pixelWidth -g pixelHeight [paths]` on macOS); width must equal height. The crop script rejects non-square inputs, and squaring after the fact is off the table (cropping eats scene, padding invents background), so a non-square hero or 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.
For each COMPLETED report, first fuse the two sheet passes into a transparent-background sheet:
For each COMPLETED report, run one command, carrying the report's slug and its `CHROMA` and `PALETTE` lines:
```text
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 \
node {{scripts_path}}/visual-cues.mjs crop [hero.png] [artifacts.png] \
--slug [slug] \
--chroma "#RRGGBB" \
--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 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`:
The script copies the hero untouched to `[slug].png`, keeps the sheet under `masters/[slug]-artifacts.png`, and quadrant-crops it into `[slug]-2.png` through `[slug]-5.png`. The crops keep the key background: keying happens later in the browser canvas, which reads each cue's key from `cues.json`, so `--chroma` must carry the report's exact hex. For each palette role the script searches the hero for the closest rendered pixel (`snapped`, with its hero position), then updates `cues.json`:
```json
{
@@ -366,13 +349,16 @@ The script copies the hero untouched to `[slug].png`, keeps the sheet it was giv
"supporting-artifacts": {
"amber-dusk": ["amber-dusk-2", "amber-dusk-3", "amber-dusk-4", "amber-dusk-5"]
},
"chroma": {
"amber-dusk": "#FF00FF"
},
"palette": {
"amber-dusk": { "primary": { "hex": "#B8422E", "snapped": "#B4402F", "at": [312, 540] } }
}
}
```
Done when: `cues.json` lists one entry per completed palette and every listed slug has its five PNGs on disk (hero plus four artifacts).
Done when: `cues.json` lists one entry per completed palette, every listed slug has its five PNGs on disk (hero plus four artifacts), and every slug has its chroma key recorded.
## Step 6: Pause
+28 -123
View File
@@ -2,31 +2,24 @@
// 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 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.
// Each cue is a full-bleed hero scene plus an artifact sheet: four objects,
// one per quadrant, on a single flat chroma-key background. The key stays
// in the shipped crops; the browser canvas removes it downstream, using the
// key hex this script records in cues.json.
//
// node visual-cues.mjs crop <hero.png> <artifacts.png> --slug <two-word-slug>
// [--chroma "#00FF00"]
// [--palette "primary=#RRGGBB;secondary=...;tertiary=...;neutral=..."]
// [--out <dir>] (default: .impeccable/visual-cues)
// Copies the hero untouched to <slug>.png, keeps the sheet it was
// given under <out>/masters/<slug>-artifacts.png, quadrant-crops the
// sheet into <slug>-2..5.png (alpha preserved), finds each planned
// palette hex's closest pixel in the hero, and updates <out>/cues.json.
// Copies the hero untouched to <slug>.png, keeps the sheet under
// <out>/masters/<slug>-artifacts.png, quadrant-crops the sheet into
// <slug>-2..5.png (key background kept), finds each planned palette
// hex's closest pixel in the hero, records the cue's chroma key, and
// updates <out>/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 <light.png> <dark.png> --out <final.png>
// 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.
@@ -304,114 +297,12 @@ 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 <light.png> <dark.png> --out <final.png>');
}
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
// the six concepts one after another accumulates into one shared manifest
// instead of each crop overwriting the last.
function updateCuesJson(outDir, slug, artifactIds, palette) {
function updateCuesJson(outDir, slug, artifactIds, palette, chroma) {
const path = join(outDir, 'cues.json');
let data = {};
if (existsSync(path)) data = JSON.parse(readFileSync(path, 'utf8'));
@@ -423,6 +314,11 @@ function updateCuesJson(outDir, slug, artifactIds, palette) {
data.palette = data.palette || {};
data.palette[slug] = palette;
}
if (chroma) {
// The key the browser canvas keys out of this cue's artifact crops.
data.chroma = data.chroma || {};
data.chroma[slug] = chroma;
}
writeFileSync(path, JSON.stringify(data, null, 2) + '\n');
return data;
}
@@ -492,7 +388,16 @@ function cmdCrop(args) {
let palette = null;
if (args.palette) palette = snapPalette(hero, parsePalette(args.palette));
updateCuesJson(outDir, slug, artifactIds, palette);
// --chroma records the sheet's key color so the browser canvas knows
// what to key out of the artifact crops; the crops themselves keep it.
let chroma = null;
if (args.chroma) {
const m = /^#?([0-9a-fA-F]{6})$/.exec(args.chroma.trim());
if (!m) fail(`bad chroma "${args.chroma}" (expected #RRGGBB)`);
chroma = `#${m[1].toUpperCase()}`;
}
updateCuesJson(outDir, slug, artifactIds, palette, chroma);
console.log(JSON.stringify({
ok: true,
@@ -501,6 +406,7 @@ function cmdCrop(args) {
artifacts: keptSheet,
files,
palette,
chroma,
cuesJson: join(outDir, 'cues.json'),
}, null, 2));
}
@@ -510,8 +416,7 @@ function main() {
const args = parseArgs(rest);
try {
if (cmd === 'crop') cmdCrop(args);
else if (cmd === 'matte') cmdMatte(args);
else fail('usage: visual-cues.mjs <crop|matte> ... (see reference/visual-cues.md)');
else fail('usage: visual-cues.mjs crop <hero.png> <artifacts.png> --slug <slug> [options] (see reference/visual-cues.md)');
} catch (err) {
fail(err.message);
}