luckiest-co 1.0.46 → 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 +4 -0
- package/skills/video-studio/SKILL.md +4 -3
- 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,9 @@
|
|
|
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
|
+
|
|
3
7
|
## 1.17.3 — 2026-10-13
|
|
4
8
|
|
|
5
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).
|
|
@@ -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"
|
|
@@ -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);
|