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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/package.json +1 -1
- package/skills/video-studio/CHANGELOG.md +10 -0
- package/skills/video-studio/SKILL.md +4 -3
- package/skills/video-studio/references/styles/flat-vector.md +6 -1
- package/skills/video-studio/references/styles/mosaic.md +5 -0
- package/skills/video-studio/references/styles/pixel.md +7 -2
- package/skills/video-studio/scripts/qa.mjs +64 -2
- package/skills/video-studio/scripts/qa.test.mjs +29 -1
package/package.json
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
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:
|
|
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);
|