luckiest-co 1.0.45 → 1.0.47

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -9,7 +9,7 @@
9
9
  "name": "luckiest",
10
10
  "source": "./",
11
11
  "description": "Plan, go, finish, plus bundled free Luckiest skills that work in Claude Code, web chat, and Cowork.",
12
- "version": "0.1.44",
12
+ "version": "0.1.46",
13
13
  "author": { "name": "Luckiest" }
14
14
  }
15
15
  ]
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "luckiest",
3
3
  "description": "Plan, go, finish. Guided planning, progress dashboards, and your luckiest.co tribe inside Claude Code.",
4
- "version": "0.1.44",
4
+ "version": "0.1.46",
5
5
  "author": { "name": "Luckiest" }
6
6
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "luckiest-co",
3
- "version": "1.0.45",
3
+ "version": "1.0.47",
4
4
  "description": "Luckiest for Claude Code: plan, go, finish. Your luckiest.co skills and tribe, inside Claude.",
5
5
  "bin": {
6
6
  "luckiest-co": "./bin/install.js"
@@ -1,5 +1,15 @@
1
1
  # Changelog — luckiest-video-studio
2
2
 
3
+ ## 1.18.0 — 2026-10-13
4
+
5
+ - New QA gate `node qa.mjs logo <run-dir> [--svg path] [--max 8]`: for every logo scene it takes the clip's last frame, finds the mark's box, draws the logo SVG (default `brand/logo.svg`) into the same box on the same background with HyperFrames, and fails when they differ. Enforces the "resolve frame equals the master logo" rule in the style cards' Logo construction sections.
6
+
7
+ ## 1.17.3 — 2026-10-13
8
+
9
+ - Logo construction on the `mosaic`, `pixel` and `flat-vector` style cards: mosaic lays stones along the mark's outline then fill and grout; pixel draws the mark into the low-res grid then steps up to the crisp SVG; flat-vector flies in the mark's own shapes with overshoot. Each resolves to the flat logo SVG (a visible difference is a Block).
10
+ - Pixel and flat-vector no longer call for an invented logo ("PIXEL QUEST", "invented brand lockup").
11
+ - Adapted from kaankiziltug `logo-design` (MIT); see ATTRIBUTION.md.
12
+
3
13
  ## 1.17.2 — 2026-10-12
4
14
 
5
15
  - Logo construction on the `3d-render` style card (Best for: brand sting): the jelly hero is the logo SVG's own paths extruded and dropped in construction order with damped-spring squash, and the hold frame flattens to the flat logo SVG (a visible difference is a Block). The Look line no longer calls for an invented brand name.
@@ -4,7 +4,7 @@ description: "Make product launch videos, product demos with motion design betwe
4
4
  license: See ATTRIBUTION.md
5
5
  allowed-tools: Bash, Read, Write, Edit, Glob, Grep, AskUserQuestion
6
6
  metadata:
7
- version: "1.17.2"
7
+ version: "1.18.0"
8
8
  listing_id: luckiest-video-studio
9
9
  author: luckiest
10
10
  ---
@@ -19,13 +19,13 @@ an MP4.
19
19
  ## Staying current
20
20
 
21
21
  On activation, call the Luckiest MCP `check_updates` tool with
22
- `{ listingId: "aff888e4-9ee9-4f73-af08-74d5d09d3c10", installedSemver: "1.17.2" }`. If it returns
22
+ `{ listingId: "aff888e4-9ee9-4f73-af08-74d5d09d3c10", installedSemver: "1.18.0" }`. If it returns
23
23
  `upToDate: false`, surface the `notice` to the user once, then continue. Do nothing
24
24
  further if `upToDate: true`. Never block on this check; if the tool is unavailable,
25
25
  proceed.
26
26
 
27
27
  When the skill's work is done, call the Luckiest MCP `report_usage` tool once with
28
- `{ listing_id: "luckiest-video-studio", skill_version: "1.17.2", matched: true, success: <true if the skill completed, false otherwise> }`.
28
+ `{ listing_id: "luckiest-video-studio", skill_version: "1.18.0", matched: true, success: <true if the skill completed, false otherwise> }`.
29
29
  Metadata only, never prompt text. Never block on it; if the tool is unavailable,
30
30
  skip silently.
31
31
 
@@ -313,6 +313,7 @@ node <skill-dir>/scripts/qa.mjs avsync <output-dir>/final.mp4 --expect <hero
313
313
  node <skill-dir>/scripts/qa.mjs facts <output-dir> # on-screen numbers not in storyboard facts, em dashes on screen
314
314
  node <skill-dir>/scripts/qa.mjs loop <output-dir>/final.mp4 # only for looping pieces: last frame matches the first
315
315
  node <skill-dir>/scripts/qa.mjs safe <output-dir> # text or logo edges in each format's platform-covered margins
316
+ node <skill-dir>/scripts/qa.mjs logo <output-dir> [--svg <logo.svg>] # logo scenes: last frame vs the logo SVG in the same box (default brand/logo.svg)
316
317
  node <skill-dir>/scripts/qa.mjs endhold <output-dir>/final.mp4 # end card held over 1.4 s, final shot over a quarter of the film
317
318
  node <skill-dir>/scripts/qa.mjs blankframes <output-dir>/final.mp4 # more than 3 frames of empty swatch in a row
318
319
  node <skill-dir>/scripts/qa.mjs hud <output-dir>/composition # HUD counters, timecodes, BPM/FPS, greetings, "label · $value"
@@ -29,7 +29,7 @@ Elastic overshoot is the soul: cubic-bezier(0.34,1.56,0.64,1), or a bezier with
29
29
  - 1.2-3s: circle becomes a sun, flat city pops up building by building, clouds slide, birds flap.
30
30
  - 3-5.5s: camera pushes into one window, geometric character at a desk reacts, phone pings.
31
31
  - 5.5-8s (hero at 60-75%): notification bubble expands as a shape wipe, icons burst in on rhythm (bars grow, check draws, heart pops, coin flips).
32
- - 8-10s: everything collapses into an invented brand lockup plus tagline, elastic settle, hold 1s with micro-motion.
32
+ - 8-10s: everything collapses into the brand's real logo lockup plus tagline, elastic settle, hold 1s with micro-motion.
33
33
 
34
34
  ## Sound
35
35
  Upbeat future-bass or pop, about 120 BPM. Pop, boing and whoosh on every entry, synced to the frame.
@@ -37,5 +37,10 @@ Upbeat future-bass or pop, about 120 BPM. Pop, boing and whoosh on every entry,
37
37
  ## Build it in HyperFrames
38
38
  SVG composition driven by GSAP with a paused timeline seeked per frame. Put the overshoot curve in a CustomEase or the bezier above. Use nested groups with transform-origin set to joints for limb rigs. Shape wipes are a scaled circle or rect in a palette color. For motion blur on fast moves, render sub-frames (about 4) and average, or skip blur and keep moves short.
39
39
 
40
+ ## Logo construction
41
+ Rule from logo-design (MIT, kaankiziltug): the entrance is built from the mark's own construction, and the resolve frame equals the master logo. Before animating, write the mark's construction in one line in `plan.md` (its primitives, strokes or modules, and the order they would be drawn). The resolve frame is the real logo file (`brand/logo.svg` or the SVG the user supplied), never a styled stand-in or an invented wordmark: render the last frame and the logo SVG at the same size and position, and any visible difference is a Block. Sting length 1 to 3 seconds, easing from this card.
42
+ - Construction: the lockup is assembled from the mark's own shapes flying in, in its construction order (largest primitive first, then cuts, counters and the wordmark), each with the card's anticipation, overshoot and 2-4 frame stagger.
43
+ - Resolve: the elastic settle ends exactly on the logo SVG at its master size and position, with micro-motion stopping for the last frame. An invented brand lockup is never the resolve.
44
+
40
45
  ## Watch out
41
46
  Template look is the main failure: same icon-burst grid, no idea. Avoid more than one hero moving. Keep every color from the palette. Do not let gradients sneak in through shadows. Check kerning on the lockup.
@@ -44,6 +44,11 @@ Canvas 2D with typed arrays; build once at init (about 200 ms).
44
44
  - Distance transform (Felzenszwalb-Huttenlocher) on a structure map, then walk iso-contours placing stones spaced at the tile size using a spatial hash with minimum spacing 0.8 x size; fill remaining gaps with staggered rows.
45
45
  - Per pixel store tile id and shade; per frame sample the flat drawing at each tile center and write color x shade. Draw to an offscreen canvas then `drawImage` (direct `putImageData` ignores scaling and transitions).
46
46
 
47
+ ## Logo construction
48
+ Rule from logo-design (MIT, kaankiziltug): the entrance is built from the mark's own construction, and the resolve frame equals the master logo. Before animating, write the mark's construction in one line in `plan.md` (its primitives, strokes or modules, and the order they would be drawn). The resolve frame is the real logo file (`brand/logo.svg` or the SVG the user supplied), never a styled stand-in or an invented wordmark: render the last frame and the logo SVG at the same size and position, and any visible difference is a Block. Sting length 1 to 3 seconds, easing from this card.
49
+ - Construction: stones are laid along the mark's own outline first, in its construction order (outer silhouette, then cuts and counters), then the fill rows set in, then the grout darkens in, each tile with a stone click. The halo ring goes on last. Lettering in the mark uses the logo's letterforms, never the card's monospace tile letters.
50
+ - Resolve: in the last 0.3s the grout, edge shading and jitter fade out and the tiles merge into the flat logo SVG in brand colors on the hold frame. A tiled rendering of the name is never the resolve.
51
+
47
52
  ## Watch out
48
53
  - Current weak spot: facial detail gets lost at small tile sizes.
49
54
  - Hand, cup and face crowd together at 6-8.5 px tiles. Use bigger gestures or sprite inserts.
@@ -9,7 +9,7 @@
9
9
  - Clock: stepped 10-12 fps for sprites (30 fps timeline for scrolling)
10
10
 
11
11
  ## Look
12
- Internal resolution 320x180 scaled x6 to 1920x1080 with nearest-neighbour. Limited palette (PICO-8-like 16-32 colors), dithered sky gradient. 3-4 parallax layers (sky, far mountains, mid forest or city, near ground tiles). Sprites hand-authored 16x16 to 24x24 as pixel arrays. Chunky pixel logo (for example "PIXEL QUEST"), pixel font dialogue box. Optional CRT pass: scanlines, slight barrel, phosphor glow, without blurring pixels into mush.
12
+ Internal resolution 320x180 scaled x6 to 1920x1080 with nearest-neighbour. Limited palette (PICO-8-like 16-32 colors), dithered sky gradient. 3-4 parallax layers (sky, far mountains, mid forest or city, near ground tiles). Sprites hand-authored 16x16 to 24x24 as pixel arrays. Chunky pixel version of the real logo, pixel font dialogue box. Optional CRT pass: scanlines, slight barrel, phosphor glow, without blurring pixels into mush.
13
13
 
14
14
  ## Motion
15
15
  Strictly integer pixel positions and stepped timing, no smooth easing on sprites. Run cycle 6-8 frames at about 10-12 fps. Coin sparkle, slime bop, chest opens with a light burst. Parallax layers move by whole pixels at different speeds. Palette shifts day to sunset. Dialogue types out character by character. Logo drops in with a shine sweep.
@@ -28,7 +28,7 @@ Strictly integer pixel positions and stepped timing, no smooth easing on sprites
28
28
  - 1.2-4s: run, jump, bop a slime, parallax scrolling.
29
29
  - 4-7s (hero at 60-75%): palette shifts to sunset, chest opens with a light burst.
30
30
  - 7-8.5s: dialogue typed out.
31
- - 8.5-10s: pixel logo drops in with shine sweep, held with blink.
31
+ - 8.5-10s: the real logo builds in pixels with a shine sweep, then resolves to the logo SVG (see Logo construction).
32
32
 
33
33
  ## Sound
34
34
  Chiptune: pulse lead, triangle bass, noise drums, plus jump, coin and chest SFX.
@@ -36,6 +36,11 @@ Chiptune: pulse lead, triangle bass, noise drums, plus jump, coin and chest SFX.
36
36
  ## Build it in HyperFrames
37
37
  Canvas 2D at 320x180 with imageSmoothingEnabled = false, then drawImage upscaled x6 (or CSS image-rendering: pixelated). Sprite frame = floor(t*10) mod N. Parallax offset = floor(t * speed). Palette as an array of hex values, palette shift by lerping and re-quantising. Ordered-dither (Bayer 4x4) for gradients. CRT pass applied after upscale.
38
38
 
39
+ ## Logo construction
40
+ Rule from logo-design (MIT, kaankiziltug): the entrance is built from the mark's own construction, and the resolve frame equals the master logo. Before animating, write the mark's construction in one line in `plan.md` (its primitives, strokes or modules, and the order they would be drawn). The resolve frame is the real logo file (`brand/logo.svg` or the SVG the user supplied), never a styled stand-in or an invented wordmark: render the last frame and the logo SVG at the same size and position, and any visible difference is a Block. Sting length 1 to 3 seconds, easing from this card.
41
+ - Construction: the mark is drawn into the low-res grid in its construction order (outer silhouette, then cuts and counters), stepped a few pixels per frame at the card's 10-12 fps, palette-quantized from the logo's own colors. The shine sweep runs once the shape is complete.
42
+ - Resolve: on the hold the pixel grid steps up to full resolution in 2 to 3 hard steps (no smooth scale), ending on the crisp logo SVG in brand colors. A chunky pixel wordmark is never the resolve.
43
+
39
44
  ## Watch out
40
45
  Fractional positions cause shimmer. Never use a smooth scale or rotation on sprites. Scanlines that are too heavy hide the art. Pixel CJK fonts must be OFL.
41
46
 
@@ -20,13 +20,15 @@
20
20
  // node qa.mjs storyboard <run-dir> scene lengths, picture-only, text-led share, camera streaks, style id
21
21
  // node qa.mjs facts <run-dir> on-screen numbers not in storyboard facts, em dashes on screen
22
22
  // node qa.mjs safe <run-dir> [--max 0.004] [--stills] text or logo edges in the margins each format's platforms cover
23
+ // node qa.mjs logo <run-dir> [--svg brand/logo.svg] [--max 8] last frame of each logo scene must match the logo SVG drawn in the same box
23
24
  // node qa.mjs judge <new.mp4> <old.mp4> [--out dir] [--cmd "claude -p --model sonnet"] [--verdicts A,B]
24
25
  // new vs old, asked twice with the order swapped; the new one is kept only if it wins both
25
26
  //
26
27
  // A gate that has never failed proves nothing: qa.test.mjs runs each one on a control
27
28
  // built to fail it.
28
29
 
29
- import { readFileSync, readdirSync, existsSync, statSync, mkdirSync, writeFileSync } from "node:fs";
30
+ import { readFileSync, readdirSync, existsSync, statSync, mkdirSync, writeFileSync, mkdtempSync, rmSync } from "node:fs";
31
+ import { tmpdir } from "node:os";
30
32
  import { join, resolve, extname } from "node:path";
31
33
  import { spawnSync } from "node:child_process";
32
34
  import { fileURLToPath } from "node:url";
@@ -556,6 +558,65 @@ B: ${b}
556
558
  Open both images. Judge which reads better to a viewer: every card legible, the speaker's face never covered, visuals change with what is said, no empty or cluttered stretches, real evidence instead of filler, and motion that feels designed: elements arrive fast and settle instead of creeping in or popping from nothing, moves match each other in speed and easing, and nothing drifts while it is being read.
557
559
  Name the two biggest differences, then end with exactly one line: WINNER: A or WINNER: B`;
558
560
 
561
+ // ---------- logo resolve ----------
562
+
563
+ // The SVG drawn into `box` on a plain `bg` page at W x H, captured with HyperFrames (headless Chrome),
564
+ // the same snapshot path render-scenes.mjs --stills uses. The mark is fitted by its own bounding box.
565
+ export function rasterLogo({ svg, W, H, box, bg, out, version = "latest" }) {
566
+ const proj = mkdtempSync(join(tmpdir(), "qa-logo-"));
567
+ writeFileSync(join(proj, "index.html"), `<!doctype html><html><head><meta charset="UTF-8"><script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
568
+ <style>*{margin:0;padding:0}html,body{width:${W}px;height:${H}px;overflow:hidden;background:${bg}}#m{position:absolute;left:${box.x}px;top:${box.y}px;width:${box.w}px;height:${box.h}px}#m svg{width:100%;height:100%;display:block}</style></head>
569
+ <body><div id="root" data-composition-id="main" data-start="0" data-duration="1" data-width="${W}" data-height="${H}"><div id="m" class="clip" data-start="0" data-duration="1" data-track-index="0">${readFileSync(svg, "utf8")}</div></div>
570
+ <script>const s=document.querySelector("#m svg"),b=s.getBBox();s.setAttribute("viewBox",[b.x,b.y,b.width,b.height].join(" "));
571
+ window.__timelines={main:gsap.timeline({paused:true}).to({},{duration:1})};</script></body></html>`);
572
+ const hf = spawnSync("hyperframes", ["--version"]).error ? ["npx", `hyperframes@${version}`] : ["hyperframes"];
573
+ const r = spawnSync(hf[0], [...hf.slice(1), "snapshot", proj, "--at", "0.5", "--no-end", "-o", join(proj, ".snap"), "--describe", "false"], { encoding: "utf8" });
574
+ const png = existsSync(join(proj, ".snap")) && readdirSync(join(proj, ".snap")).find((f) => f.startsWith("frame-"));
575
+ if (r.status !== 0 || !png) throw new Error(`logo snapshot failed: ${(r.stderr ?? "").trim().split("\n").pop()}`);
576
+ ff(["-v", "error", "-y", "-i", join(proj, ".snap", png), "-vf", `scale=${W}:${H}`, out]);
577
+ rmSync(proj, { recursive: true, force: true });
578
+ return out;
579
+ }
580
+
581
+ // Each logo scene's last frame against the logo SVG drawn at the same size and position.
582
+ // The box is the ink that differs from the frame's corner color; any visible difference inside it is a Block.
583
+ // ponytail: box from ink, so a caption beside the mark widens it and fails; add a scene logoBox override when a run needs it.
584
+ export function logoResolve(runDir, { svg, max = 8, raster = rasterLogo, ink = 24 } = {}) {
585
+ const sb = JSON.parse(readFileSync(join(runDir, "storyboard.json"), "utf8"));
586
+ const logo = resolve(runDir, svg ?? sb.brand?.logo ?? "brand/logo.svg");
587
+ const targets = Array.isArray(sb.formats) ? sb.formats.map((f) => [f, ...FORMATS[f], join(runDir, "clips", f)]) : [[null, sb.format.width, sb.format.height, join(runDir, "clips")]];
588
+ const hits = [], tmp = mkdtempSync(join(tmpdir(), "qa-logo-"));
589
+ let checked = 0;
590
+ for (const [fmt, W, H, dir] of targets) {
591
+ const w = Math.round(W / 4), h = Math.round(H / 4);
592
+ sb.scenes.forEach((s, i) => {
593
+ if (s.kind !== "logo") return;
594
+ const name = `${String(i + 1).padStart(2, "0")}-${s.id}`, clip = join(dir, `${name}.mp4`), tag = { ...(fmt ? { format: fmt } : {}), scene: s.id };
595
+ if (!existsSync(clip)) return hits.push({ ...tag, missing: clip });
596
+ const last = join(tmp, `${fmt ?? "main"}-${name}.png`);
597
+ ff(["-v", "error", "-y", "-sseof", "-0.25", "-i", clip, "-update", "1", last]);
598
+ const [f] = grayFrames(last, w, h);
599
+ if (!f) return hits.push({ ...tag, missing: last });
600
+ let x0 = w, y0 = h, x1 = -1, y1 = -1;
601
+ for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) {
602
+ if (Math.abs(f[y * w + x] - f[0]) > ink) { x0 = Math.min(x0, x); y0 = Math.min(y0, y); x1 = Math.max(x1, x); y1 = Math.max(y1, y); }
603
+ }
604
+ if (x1 < 0) return hits.push({ ...tag, blank: true });
605
+ const rgb = ff(["-v", "error", "-i", last, "-vf", "crop=1:1:0:0,format=rgb24", "-f", "rawvideo", "-"]).stdout;
606
+ const bg = `#${[...rgb.subarray(0, 3)].map((v) => v.toString(16).padStart(2, "0")).join("")}`;
607
+ const box = { x: x0 * 4, y: y0 * 4, w: (x1 - x0 + 1) * 4, h: (y1 - y0 + 1) * 4 };
608
+ const [ref] = grayFrames(raster({ svg: logo, W, H, box, bg, out: join(tmp, `${fmt ?? "main"}-${name}-ref.png`), version: sb.hyperframes }), w, h);
609
+ let sum = 0, n = 0;
610
+ for (let y = y0; y <= y1; y++) for (let x = x0; x <= x1; x++) { sum += Math.abs(f[y * w + x] - ref[y * w + x]); n++; }
611
+ const diff = Number((sum / n).toFixed(2));
612
+ checked++;
613
+ if (diff > max) hits.push({ ...tag, diff, box });
614
+ });
615
+ }
616
+ rmSync(tmp, { recursive: true, force: true });
617
+ return { ok: hits.length === 0, hits, checked, svg: logo };
618
+ }
619
+
559
620
  // ---------- CLI ----------
560
621
 
561
622
  if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
@@ -563,7 +624,7 @@ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.ur
563
624
  if (gate === "judge" && rest[0] && !rest[0].startsWith("--")) rest.unshift("--old", rest.shift());
564
625
  const opt = (n, d) => { const i = rest.indexOf(n); return i >= 0 ? rest[i + 1] : d; };
565
626
  const num = (n, d) => Number(opt(n, d));
566
- if (!gate || !target) { console.error("Usage: qa.mjs <tokens|crossfade|motion|legibility|pops|deadframes|presence|beatsync|beatgrid|face|avsync|loop|endhold|blankframes|hud|facts|safe|judge> <path> [options]"); process.exit(1); }
627
+ if (!gate || !target) { console.error("Usage: qa.mjs <tokens|crossfade|motion|legibility|pops|deadframes|presence|beatsync|beatgrid|face|avsync|loop|endhold|blankframes|hud|facts|safe|logo|judge> <path> [options]"); process.exit(1); }
567
628
  let result;
568
629
  if (gate === "tokens") {
569
630
  const tokens = opt("--tokens") ? JSON.parse(readFileSync(opt("--tokens"), "utf8")) : DEFAULT_TOKENS;
@@ -607,6 +668,7 @@ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.ur
607
668
  }
608
669
  else if (gate === "storyboard") result = lintStoryboard(JSON.parse(readFileSync(join(target, "storyboard.json"), "utf8")));
609
670
  else if (gate === "safe") result = safeAreas(target, { max: num("--max", 0.004), stills: rest.includes("--stills") });
671
+ else if (gate === "logo") result = logoResolve(target, { svg: opt("--svg"), max: num("--max", 8) });
610
672
  else if (gate === "facts") {
611
673
  const sb = JSON.parse(readFileSync(join(target, "storyboard.json"), "utf8"));
612
674
  const comp = join(target, "composition");
@@ -10,7 +10,7 @@ import {
10
10
  scanTokens, scanCrossfades, scanMotion, legibility, findPops, deadFrames, presence,
11
11
  beatSync, findAnchor, cutTimes, beatGrid, grayFrames, probe,
12
12
  faceCover, pairVerdict, parseWinner, audioOnsets, avSync, visibleText, scanFacts, loopCheck,
13
- fullFrameSelectors, safeAreas, endHold, blankRuns, scanHud, lintStoryboard,
13
+ fullFrameSelectors, safeAreas, endHold, blankRuns, scanHud, lintStoryboard, logoResolve,
14
14
  } from "./qa.mjs";
15
15
 
16
16
  const dir = mkdtempSync(join(tmpdir(), "qa-gates-"));
@@ -209,6 +209,34 @@ test("loop: last frame matching the first passes, a different end frame fails",
209
209
  assert.equal(cli("loop", ends).status, 1);
210
210
  });
211
211
 
212
+ test("logo: a sting ending on the logo passes, one ending on a different shape fails", () => {
213
+ // A dark mark at 120,60 (80x60) on paper; the stand-in rasterizer draws the "logo" as that filled box.
214
+ const run = (name, end) => {
215
+ const r = join(dir, name);
216
+ mkdirSync(join(r, "clips"), { recursive: true });
217
+ const g = spawnSync("ffmpeg", ["-v", "error", "-y", "-f", "lavfi", "-i", "color=c=0xf3efe6:size=320x180:rate=30:duration=1",
218
+ "-vf", `drawbox=x=120:y=60:w=80:h=60:color=0x1a1a1a:t=${end},format=yuv420p`, "-c:v", "libx264", "-crf", "12", join(r, "clips", "01-sting.mp4")]);
219
+ assert.equal(g.status, 0, g.stderr?.toString());
220
+ writeFileSync(join(r, "storyboard.json"), JSON.stringify({ format: { width: 320, height: 180, fps: 30 }, scenes: [{ id: "sting", kind: "logo", duration: 1 }] }));
221
+ return r;
222
+ };
223
+ const raster = ({ W, H, box, bg, out }) => {
224
+ const g = spawnSync("ffmpeg", ["-v", "error", "-y", "-f", "lavfi", "-i", `color=c=${bg}:size=${W}x${H}`, "-vf", `drawbox=x=${box.x}:y=${box.y}:w=${box.w}:h=${box.h}:color=0x1a1a1a:t=fill`, "-frames:v", "1", out]);
225
+ assert.equal(g.status, 0, g.stderr?.toString());
226
+ return out;
227
+ };
228
+ const same = logoResolve(run("logo-same", "fill"), { raster });
229
+ assert.equal(same.ok, true, JSON.stringify(same));
230
+ assert.equal(same.checked, 1);
231
+ const off = logoResolve(run("logo-outline", 6), { raster });
232
+ assert.equal(off.ok, false);
233
+ assert.equal(off.hits[0].scene, "sting");
234
+ const missing = join(dir, "logo-missing");
235
+ mkdirSync(missing, { recursive: true });
236
+ writeFileSync(join(missing, "storyboard.json"), JSON.stringify({ format: { width: 320, height: 180, fps: 30 }, scenes: [{ id: "sting", kind: "logo", duration: 1 }] }));
237
+ assert.equal(cli("logo", missing).status, 1);
238
+ });
239
+
212
240
  test("endhold: a 2 s static end card fails, a short resolved hold and a one-shot film pass", () => {
213
241
  const long = video("endhold-long.mp4", [["testsrc", 2], ["0x202020", 2]]);
214
242
  const r = endHold(long);