@waits/cadence 0.2.0 → 0.3.0

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/README.md CHANGED
@@ -21,16 +21,22 @@ no generic AI-video look, no hosted service. Free and open source.
21
21
  npx skills add ryanwaits/cadence # adds the skill to Claude Code / Cursor / Codex
22
22
  ```
23
23
 
24
- Then just ask your agent. Or drive the CLI directly:
24
+ Then just ask your agent. Or drive the CLI directly — install the `cadence`
25
+ binary globally, or run it with `npx` (no install):
25
26
 
26
27
  ```bash
27
- npx cadence create --release owner/name --install "npm i your-pkg" # a repo a video
28
- npx cadence create my.beats.json --format 9x16 # a beats file → a video
29
- npx cadence themes # list built-in themes
28
+ npm i -g @waits/cadence # a persistent `cadence` binary
29
+ cadence create --release owner/name --install "npm i your-pkg" # a repo → a video
30
+ cadence create my.beats.json --format 9x16 # a beats file → a video
31
+ cadence themes # list built-in themes
32
+
33
+ npx @waits/cadence create --release owner/name --install "npm i …" # …or run without installing
30
34
  ```
31
35
 
32
36
  A render runs entirely on your machine (or your own GitHub Actions) — free, no API key.
33
37
 
38
+ → **New here? Start with the [guides](docs/guides/)** — install, changelog, announcement, milestone, branding, and CI walkthroughs.
39
+
34
40
  ## Why it doesn't look AI-generated
35
41
 
36
42
  Most AI video either fabricates a fake product or reaches for the same neon-on-black
@@ -60,6 +66,7 @@ milestones. The agent (via the skill) writes the beats; you tweak and render.
60
66
  Panels visualize what a change *produces* — pick by result:
61
67
  `feed · data-table · status · stat · proof · stream-resume · fork · upload-progress · diagram`.
62
68
 
69
+ - **[docs/guides/](docs/guides/)** — task-oriented walkthroughs (install, changelog, announcement, milestone, branding, iterate, CI).
63
70
  - **[docs/recipes.md](docs/recipes.md)** — worked examples (changelog, announcement, showcase, brand-from-URL, CI).
64
71
  - **[docs/gallery.md](docs/gallery.md)** — the same engine, visibly different videos.
65
72
  - **[WALKTHROUGH.md](WALKTHROUGH.md)** — architecture + how to adjust each part.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@waits/cadence",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -53,6 +53,7 @@
53
53
  "devDependencies": {
54
54
  "@changesets/changelog-git": "^0.2.1",
55
55
  "@changesets/cli": "^2.31.0",
56
+ "@types/node": "22",
56
57
  "@types/react": "19.2.16",
57
58
  "typescript": "5.7.3"
58
59
  }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Heuristic beats checks, as a pure function so both `cadence audit` and
3
+ * `cadence storyboard` can surface them. Advisory only — no auto-fix. Mirrors
4
+ * the authoring guidance in the cadence skill (3-6 beats, ~2-10s each, short
5
+ * declarative headlines, an install/CTA closer).
6
+ */
7
+ import type { Beat } from "../src/schema/beats";
8
+ import { FPS } from "./_beats";
9
+
10
+ export type Level = "error" | "warn" | "info";
11
+ export type Finding = { level: Level; msg: string };
12
+
13
+ /** Ranked findings for a parsed beats array. */
14
+ export function auditBeats(beats: Beat[]): Finding[] {
15
+ const findings: Finding[] = [];
16
+ const add = (level: Level, msg: string) => findings.push({ level, msg });
17
+
18
+ // 1. Beat count — skill guidance is to keep it tight (3-6).
19
+ if (beats.length < 2) add("warn", `only ${beats.length} beat — most videos want 3-6`);
20
+ if (beats.length > 7) add("warn", `${beats.length} beats — tighten toward 3-6; long videos lose attention`);
21
+
22
+ // 2. Per-beat duration (≈2-10s at 30fps).
23
+ beats.forEach((b, i) => {
24
+ const s = (b.durationInFrames / FPS).toFixed(1);
25
+ if (b.durationInFrames < 45) add("warn", `beat ${i + 1} "${b.headline}" is ${s}s — too short to read`);
26
+ else if (b.durationInFrames > 360) add("warn", `beat ${i + 1} "${b.headline}" is ${s}s — likely too long`);
27
+ });
28
+
29
+ // 3. Headline length — short + declarative.
30
+ beats.forEach((b, i) => {
31
+ if (b.headline.length > 48)
32
+ add("warn", `beat ${i + 1} headline is ${b.headline.length} chars — shorten ("${b.headline.slice(0, 40)}…")`);
33
+ });
34
+
35
+ // 4. Motion monotony — every beat with an explicit enter uses the same one.
36
+ const enters = beats.map((b) => b.headlineMotion?.enter).filter(Boolean);
37
+ if (enters.length >= 3 && new Set(enters).size === 1)
38
+ add("info", `every headline uses the "${enters[0]}" enter — vary it for rhythm`);
39
+
40
+ // 5. Install/CTA closer — heuristic: last beat centered with a bash command or a badge/caption.
41
+ const last = beats[beats.length - 1];
42
+ const isCloser = last.layout === "center" && (last.code?.lang === "bash" || !!last.badge || !!last.caption);
43
+ if (!isCloser) add("info", "last beat doesn't look like an install/CTA closer (centered + install command)");
44
+
45
+ return findings;
46
+ }
47
+
48
+ const order: Record<Level, number> = { error: 0, warn: 1, info: 2 };
49
+ /** Ranked error → warn → info (stable copy). */
50
+ export const rankFindings = (findings: Finding[]): Finding[] =>
51
+ [...findings].sort((a, b) => order[a.level] - order[b.level]);
52
+
53
+ export const ICON: Record<Level, string> = { error: "✗", warn: "▲", info: "·" };
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Shared beats helpers: load + validate a beats module, and compute per-beat
3
+ * timings. Used by render / audit / storyboard so the "how do I read a beats
4
+ * file" logic lives in one place. Beats are laid end-to-end by the Changelog
5
+ * composition (`<Series.Sequence durationInFrames>`), so a beat's start frame is
6
+ * the running sum of prior durations — see `src/components/Changelog.tsx`.
7
+ */
8
+ import { readFileSync } from "node:fs";
9
+ import { resolve } from "node:path";
10
+ import { pathToFileURL } from "node:url";
11
+ import { changelogSchema, type Beat, type ChangelogVideo } from "../src/schema/beats";
12
+
13
+ export const FPS = 30;
14
+
15
+ /** Load a `.ts`/`.js` module (default export) or a `.json` file, then validate. */
16
+ export async function loadBeats(file: string): Promise<ChangelogVideo> {
17
+ const raw = file.endsWith(".json")
18
+ ? JSON.parse(readFileSync(resolve(file), "utf8"))
19
+ : (await import(pathToFileURL(resolve(file)).href)).default;
20
+ return changelogSchema.parse(raw);
21
+ }
22
+
23
+ export type BeatTiming = { start: number; mid: number; dur: number };
24
+
25
+ /**
26
+ * Per-beat `{start, mid, dur}` frames + total. `mid` (start + ⌊dur/2⌋) is the
27
+ * representative frame for a still — entrance motion has settled by then.
28
+ */
29
+ export function beatTimings(beats: Beat[]): { timings: BeatTiming[]; totalFrames: number } {
30
+ let start = 0;
31
+ const timings = beats.map((b) => {
32
+ const dur = b.durationInFrames;
33
+ const t = { start, mid: start + Math.floor(dur / 2), dur };
34
+ start += dur;
35
+ return t;
36
+ });
37
+ return { timings, totalFrames: start };
38
+ }
package/scripts/audit.ts CHANGED
@@ -1,14 +1,12 @@
1
1
  /**
2
2
  * `cadence audit <beats>` — static, heuristic checks on a beats file. Advisory
3
3
  * only: ranked findings, no auto-fix (edit content by hand; use `redesign` to
4
- * re-skin). Mirrors the authoring guidance in the cadence skill.
4
+ * re-skin). The checks live in `_audit.ts` so `storyboard` can reuse them.
5
5
  *
6
6
  * cadence audit src/content/streams-launch.beats.ts
7
7
  */
8
- import { readFileSync } from "node:fs";
9
- import { resolve } from "node:path";
10
- import { pathToFileURL } from "node:url";
11
- import { changelogSchema } from "../src/schema/beats";
8
+ import { loadBeats } from "./_beats";
9
+ import { auditBeats, ICON, rankFindings } from "./_audit";
12
10
 
13
11
  const file = process.argv.slice(2).find((a) => !a.startsWith("-"));
14
12
  if (!file) {
@@ -16,48 +14,9 @@ if (!file) {
16
14
  process.exit(1);
17
15
  }
18
16
 
19
- const raw = file.endsWith(".json")
20
- ? JSON.parse(readFileSync(resolve(file), "utf8"))
21
- : (await import(pathToFileURL(resolve(file)).href)).default;
22
- const parsed = changelogSchema.parse(raw);
17
+ const parsed = await loadBeats(file);
23
18
  const beats = parsed.beats;
24
- const FPS = 30;
25
-
26
- type Level = "error" | "warn" | "info";
27
- const findings: { level: Level; msg: string }[] = [];
28
- const add = (level: Level, msg: string) => findings.push({ level, msg });
29
-
30
- // 1. Beat count — skill guidance is to keep it tight (3-6).
31
- if (beats.length < 2) add("warn", `only ${beats.length} beat — most videos want 3-6`);
32
- if (beats.length > 7) add("warn", `${beats.length} beats — tighten toward 3-6; long videos lose attention`);
33
-
34
- // 2. Per-beat duration (≈2-10s at 30fps).
35
- beats.forEach((b, i) => {
36
- const s = (b.durationInFrames / FPS).toFixed(1);
37
- if (b.durationInFrames < 45) add("warn", `beat ${i + 1} "${b.headline}" is ${s}s — too short to read`);
38
- else if (b.durationInFrames > 360) add("warn", `beat ${i + 1} "${b.headline}" is ${s}s — likely too long`);
39
- });
40
-
41
- // 3. Headline length — short + declarative.
42
- beats.forEach((b, i) => {
43
- if (b.headline.length > 48)
44
- add("warn", `beat ${i + 1} headline is ${b.headline.length} chars — shorten ("${b.headline.slice(0, 40)}…")`);
45
- });
46
-
47
- // 4. Motion monotony — every beat with an explicit enter uses the same one.
48
- const enters = beats.map((b) => b.headlineMotion?.enter).filter(Boolean);
49
- if (enters.length >= 3 && new Set(enters).size === 1)
50
- add("info", `every headline uses the "${enters[0]}" enter — vary it for rhythm`);
51
-
52
- // 5. Install/CTA closer — heuristic: last beat centered with a bash command or a badge/caption.
53
- const last = beats[beats.length - 1];
54
- const isCloser = last.layout === "center" && (last.code?.lang === "bash" || !!last.badge || !!last.caption);
55
- if (!isCloser) add("info", "last beat doesn't look like an install/CTA closer (centered + install command)");
56
-
57
- // Report, ranked: error → warn → info. "Issues" = error+warn; info are notes.
58
- const order: Record<Level, number> = { error: 0, warn: 1, info: 2 };
59
- findings.sort((a, b) => order[a.level] - order[b.level]);
60
- const icon: Record<Level, string> = { error: "✗", warn: "▲", info: "·" };
19
+ const findings = rankFindings(auditBeats(beats));
61
20
  const issues = findings.filter((f) => f.level !== "info").length;
62
21
 
63
22
  if (issues === 0) {
@@ -66,4 +25,4 @@ if (issues === 0) {
66
25
  } else {
67
26
  console.log(`${file}: ${issues} issue${issues > 1 ? "s" : ""} across ${beats.length} beats`);
68
27
  }
69
- for (const f of findings) console.log(` ${icon[f.level]} ${f.msg}`);
28
+ for (const f of findings) console.log(` ${ICON[f.level]} ${f.msg}`);
package/scripts/cli.ts CHANGED
@@ -22,6 +22,7 @@ const sub = rawSub ? (ALIAS[rawSub] ?? rawSub) : rawSub;
22
22
  const SCRIPTS: Record<string, string> = {
23
23
  guide: "scripts/guide.ts", // interactive walkthrough
24
24
  render: "scripts/render.ts", // a beats file → a video (create delegates here)
25
+ storyboard: "scripts/storyboard.ts", // a beats file → a preview sheet (no MP4)
25
26
  study: "scripts/theme.ts", // brand color / URL / screenshot → a theme
26
27
  audit: "scripts/audit.ts", // check a beats file for issues
27
28
  redesign: "scripts/redesign.ts", // re-skin a beats file
@@ -33,6 +34,7 @@ const HELP = `cadence — turn a repo / release into a changelog or announcement
33
34
 
34
35
  commands:
35
36
  create a repo OR a beats file → a video cadence create --release owner/name --install "npm i pkg"
37
+ storyboard a beats file → a preview sheet cadence storyboard x.beats.ts (or: cadence create … --dry-run)
36
38
  study a brand color / URL → a theme cadence study --from-url https://acme.dev --name acme
37
39
  audit check a beats file for issues cadence audit src/content/x.beats.ts
38
40
  redesign re-skin a beats file (new look) cadence redesign x.beats.ts --theme slate
@@ -45,7 +47,8 @@ utilities:
45
47
  templates list available templates
46
48
  themes list available themes
47
49
 
48
- flags shared by create/render/redesign: --format 16x9|1x1|9x16, --theme <name>, --theme-file <path>, --frame <n>`;
50
+ flags shared by create/render/redesign: --format 16x9|1x1|9x16, --theme <name>, --theme-file <path>, --frame <n>
51
+ preview before rendering: cadence storyboard <beats> · cadence create … --dry-run (plan + one still per beat, no MP4)`;
49
52
 
50
53
  if (!sub || sub === "help" || sub === "--help") {
51
54
  console.log(HELP);
@@ -65,7 +68,11 @@ if (sub === "themes") {
65
68
  function resolveScript(verb: string, args: string[]): string | undefined {
66
69
  if (verb === "create") {
67
70
  const beatsFile = args.find((a) => !a.startsWith("-") && /\.(beats\.)?(ts|js|json)$/.test(a));
68
- return beatsFile ? "scripts/render.ts" : "scripts/make.ts";
71
+ const dryRun = args.includes("--dry-run");
72
+ // beats-file flow: --dry-run → storyboard (preview sheet), else render (MP4).
73
+ if (beatsFile) return dryRun ? "scripts/storyboard.ts" : "scripts/render.ts";
74
+ // repo flow: make.ts handles --dry-run itself (generates beats → storyboard).
75
+ return "scripts/make.ts";
69
76
  }
70
77
  return SCRIPTS[verb];
71
78
  }
package/scripts/make.ts CHANGED
@@ -67,10 +67,19 @@ const beats = template(manifest, {
67
67
 
68
68
  console.error(`· ${manifest.product} ${manifest.version}: ${manifest.features.length} features${manifest.dropped ? ` (+${manifest.dropped} dropped)` : ""} → ${templateName}`);
69
69
 
70
- // 3. Write beats JSON + render
70
+ // 3. Write beats JSON, then render — or, with --dry-run, storyboard it (preview
71
+ // sheet + plan, no MP4).
71
72
  mkdirSync("out", { recursive: true });
72
73
  const jsonPath = join("out", `make-${manifest.product}.beats.json`);
73
74
  writeFileSync(jsonPath, JSON.stringify(beats));
75
+
76
+ if (args.includes("--dry-run")) {
77
+ // repo → storyboard. --frame is meaningless here; --theme-file is supported.
78
+ const passthru = ["--format", "--theme", "--theme-file"].flatMap((f) => (flag(f) ? [f, flag(f)!] : []));
79
+ const res = spawnSync(binPath("tsx"), [pkgFile("scripts/storyboard.ts"), jsonPath, ...passthru], { stdio: "inherit" });
80
+ process.exit(res.status ?? 0);
81
+ }
82
+
74
83
  const passthru = ["--format", "--theme", "--frame"].flatMap((f) => (flag(f) ? [f, flag(f)!] : []));
75
84
  const res = spawnSync(binPath("tsx"), [pkgFile("scripts/render.ts"), jsonPath, ...passthru], { stdio: "inherit" });
76
85
  process.exit(res.status ?? 0);
package/scripts/render.ts CHANGED
@@ -9,9 +9,9 @@
9
9
  import { spawnSync } from "node:child_process";
10
10
  import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
11
11
  import { basename, join, resolve } from "node:path";
12
- import { pathToFileURL } from "node:url";
13
- import { changelogSchema, type Format } from "../src/schema/beats";
12
+ import { type Format } from "../src/schema/beats";
14
13
  import { THEMES } from "../src/theme";
14
+ import { loadBeats } from "./_beats";
15
15
  import { binPath, pkgFile } from "./_pkg";
16
16
 
17
17
  const args = process.argv.slice(2);
@@ -29,10 +29,7 @@ if (!file) {
29
29
  }
30
30
 
31
31
  // Accept either a `.beats.ts` module or a plain `.json` beats file.
32
- const raw = file.endsWith(".json")
33
- ? JSON.parse(readFileSync(resolve(file), "utf8"))
34
- : (await import(pathToFileURL(resolve(file)).href)).default;
35
- const parsed = changelogSchema.parse(raw);
32
+ const parsed = await loadBeats(file);
36
33
 
37
34
  const fmt = getFlag("--format") as Format | undefined;
38
35
  if (fmt) parsed.format = fmt;
package/scripts/smoke.ts CHANGED
@@ -6,6 +6,7 @@
6
6
  * bun run check:render
7
7
  */
8
8
  import { spawnSync } from "node:child_process";
9
+ import { existsSync, rmSync } from "node:fs";
9
10
  import { resolve } from "node:path";
10
11
 
11
12
  type Case = { file: string; frame: number; theme?: string; format?: string; note: string };
@@ -36,8 +37,25 @@ for (const c of CASES) {
36
37
  }
37
38
  }
38
39
 
40
+ let total = CASES.length;
41
+
42
+ // Storyboard: the dry-run preview path (plan + one still per beat → a sheet).
43
+ // The only automated guard for the data-URI contact-sheet render.
44
+ total++;
45
+ process.stdout.write("· storyboard sheet (clarinet-3.18) … ");
46
+ const sbOut = resolve("out/clarinet-3.18.storyboard.png");
47
+ rmSync(sbOut, { force: true });
48
+ const sb = spawnSync(bin, ["scripts/storyboard.ts", "src/content/clarinet-3.18.beats.ts"], { stdio: ["ignore", "ignore", "pipe"], encoding: "utf8" });
49
+ if (sb.status === 0 && existsSync(sbOut)) {
50
+ console.log("ok");
51
+ } else {
52
+ failed++;
53
+ console.log("FAIL");
54
+ console.error((sb.stderr || "").split("\n").slice(-6).join("\n"));
55
+ }
56
+
39
57
  if (failed) {
40
- console.error(`\n✗ ${failed}/${CASES.length} render cases failed`);
58
+ console.error(`\n✗ ${failed}/${total} cases failed`);
41
59
  process.exit(1);
42
60
  }
43
- console.log(`\n✓ ${CASES.length} render cases ok`);
61
+ console.log(`\n✓ ${total} cases ok`);
@@ -0,0 +1,129 @@
1
+ /**
2
+ * `cadence storyboard <beats>` — a dry-run / mock: print the whole video plan
3
+ * (beat-by-beat outline + pacing) AND render a single contact-sheet PNG with one
4
+ * representative still per beat — no MP4. The cheap artifact a user + the skill
5
+ * iterate on (features, theme, background) before committing to a full render.
6
+ *
7
+ * Per-beat stills are rendered at the beat's mid frame (entrance motion settled),
8
+ * downscaled, encoded as data URIs, and laid into the `Storyboard` composition —
9
+ * data URIs (not staticFile/public) so it works from a global install too.
10
+ *
11
+ * cadence storyboard src/content/streams-launch.beats.ts [--theme slate] [--format 9x16]
12
+ */
13
+ import { spawnSync } from "node:child_process";
14
+ import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
15
+ import { basename, join } from "node:path";
16
+ import { type Beat, type Format } from "../src/schema/beats";
17
+ import type { StoryboardCell, StoryboardProps } from "../src/components/Storyboard";
18
+ import { THEMES } from "../src/theme";
19
+ import { auditBeats, ICON, rankFindings } from "./_audit";
20
+ import { beatTimings, FPS, loadBeats } from "./_beats";
21
+ import { binPath, pkgFile } from "./_pkg";
22
+
23
+ const args = process.argv.slice(2);
24
+ const getFlag = (name: string) => {
25
+ const eq = args.find((a) => a.startsWith(`${name}=`));
26
+ if (eq) return eq.split("=")[1];
27
+ const i = args.indexOf(name);
28
+ return i >= 0 ? args[i + 1] : undefined;
29
+ };
30
+
31
+ const file = args.find((a) => !a.startsWith("-"));
32
+ if (!file) {
33
+ console.error("usage: cadence storyboard <beats.ts|.json> [--theme <name>] [--theme-file <path>] [--format 16x9|1x1|9x16]");
34
+ process.exit(1);
35
+ }
36
+
37
+ const theme = getFlag("--theme");
38
+ const themeFile = getFlag("--theme-file");
39
+ if (theme && !themeFile && !THEMES[theme]) {
40
+ console.error(`unknown --theme "${theme}". have: ${Object.keys(THEMES).join(", ")}`);
41
+ process.exit(1);
42
+ }
43
+
44
+ /** Short label for a beat's backdrop (omitted background = the procedural default). */
45
+ const bgTag = (b: Beat): string => {
46
+ const bg = b.background;
47
+ if (!bg) return "shapes";
48
+ if (bg.src) return "image";
49
+ if (bg.gradient) return "gradient";
50
+ if (bg.solid) return "solid";
51
+ return "shapes";
52
+ };
53
+
54
+ const parsed = await loadBeats(file);
55
+ const fmt = (getFlag("--format") as Format | undefined) ?? parsed.format;
56
+ parsed.format = fmt;
57
+ const themeLabel = themeFile ? `custom (${basename(themeFile)})` : (theme ?? "default");
58
+ const { timings, totalFrames } = beatTimings(parsed.beats);
59
+ const name = basename(file).replace(/\.beats\.(ts|js|json)$/, "").replace(/\.(ts|js|json)$/, "");
60
+
61
+ // --- 1. The plan (text) ---
62
+ console.log(`\nstoryboard: ${file}`);
63
+ console.log(` ${parsed.beats.length} beats · ${(totalFrames / FPS).toFixed(1)}s · ${fmt} · theme ${themeLabel}\n`);
64
+
65
+ parsed.beats.forEach((b, i) => {
66
+ const t = timings[i];
67
+ const start = `${(t.start / FPS).toFixed(1)}s`.padStart(6);
68
+ const dur = `${(t.dur / FPS).toFixed(1)}s`.padStart(5);
69
+ const layout = b.layout.padEnd(6);
70
+ const panel = (b.panel?.kind ?? "—").padEnd(14);
71
+ const bg = bgTag(b).padEnd(8);
72
+ console.log(` ${String(i + 1).padStart(2)} @${start} ${dur} ${layout} ${panel} ${bg} ${b.headline}`);
73
+ });
74
+
75
+ const findings = rankFindings(auditBeats(parsed.beats));
76
+ if (findings.length) {
77
+ console.log("");
78
+ for (const f of findings) console.log(` ${ICON[f.level]} ${f.msg}`);
79
+ }
80
+
81
+ // --- 2. The sheet (one still per beat → a single PNG) ---
82
+ const env: NodeJS.ProcessEnv = { ...process.env };
83
+ if (theme) env.REMOTION_VIDEO_THEME = theme;
84
+ if (themeFile) env.REMOTION_VIDEO_THEME_JSON = readFileSync(themeFile, "utf8");
85
+
86
+ mkdirSync("out", { recursive: true });
87
+ const tmp = join("out", `.sb-${name}`);
88
+ mkdirSync(tmp, { recursive: true });
89
+ const propsPath = join(tmp, "props.json");
90
+ writeFileSync(propsPath, JSON.stringify(parsed));
91
+
92
+ const bin = binPath("remotion");
93
+ const entry = pkgFile("src/index.ts");
94
+ const TRANSPARENT_PX =
95
+ "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+P+/HgAFhAJ/wlseKgAAAABJRU5ErkJggg==";
96
+
97
+ console.log(`\nrendering ${parsed.beats.length} stills…`);
98
+ const cells: StoryboardCell[] = parsed.beats.map((b, i) => {
99
+ const out = join(tmp, `beat-${i}.png`);
100
+ const res = spawnSync(
101
+ bin,
102
+ ["still", entry, "Changelog", out, `--props=${propsPath}`, `--frame=${timings[i].mid}`, "--scale=0.33"],
103
+ { stdio: ["ignore", "ignore", "inherit"], env },
104
+ );
105
+ let img = TRANSPARENT_PX;
106
+ if (res.status === 0 && existsSync(out) && statSync(out).size > 0) {
107
+ img = `data:image/png;base64,${readFileSync(out).toString("base64")}`;
108
+ } else {
109
+ console.warn(` ! beat ${i + 1} still failed — placeholder used`);
110
+ }
111
+ return { img, headline: b.headline, panel: b.panel?.kind ?? "—", seconds: `${(b.durationInFrames / FPS).toFixed(1)}s` };
112
+ });
113
+
114
+ const sheetProps: StoryboardProps = { format: fmt, name, cells };
115
+ const sheetPropsPath = join(tmp, "sheet.json");
116
+ writeFileSync(sheetPropsPath, JSON.stringify(sheetProps));
117
+
118
+ const outPng = join("out", `${name}.storyboard.png`);
119
+ const sheet = spawnSync(bin, ["still", entry, "Storyboard", outPng, `--props=${sheetPropsPath}`], {
120
+ stdio: ["ignore", "ignore", "inherit"],
121
+ env,
122
+ });
123
+ rmSync(tmp, { recursive: true, force: true });
124
+
125
+ if (sheet.status !== 0) {
126
+ console.error("\n✗ storyboard sheet render failed");
127
+ process.exit(sheet.status ?? 1);
128
+ }
129
+ console.log(`\n→ ${outPng}\n`);
package/src/Root.tsx CHANGED
@@ -2,6 +2,7 @@ import { Composition } from "remotion";
2
2
  import { Changelog } from "./components/Changelog";
3
3
  import { MotionReel } from "./components/MotionReel";
4
4
  import { ContactSheet } from "./components/ContactSheet";
5
+ import { Storyboard, sheetLayout, type StoryboardProps } from "./components/Storyboard";
5
6
  import { changelogSchema } from "./schema/beats";
6
7
  import { prepareChangelog } from "./prepare";
7
8
  import { waitForFonts } from "./brand/fonts";
@@ -9,6 +10,18 @@ import streams from "./content/streams.beats";
9
10
 
10
11
  const defaultProps = changelogSchema.parse(streams);
11
12
 
13
+ // A 1×1 transparent PNG — keeps the Storyboard sample props tiny + self-contained.
14
+ const TRANSPARENT_PX =
15
+ "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+P+/HgAFhAJ/wlseKgAAAABJRU5ErkJggg==";
16
+ const storyboardSample: StoryboardProps = {
17
+ format: "16x9",
18
+ name: "storyboard preview",
19
+ cells: [
20
+ { img: TRANSPARENT_PX, headline: "First beat.", panel: "—", seconds: "5.0s" },
21
+ { img: TRANSPARENT_PX, headline: "Second beat.", panel: "data-table", seconds: "6.0s" },
22
+ ],
23
+ };
24
+
12
25
  export const RemotionRoot: React.FC = () => {
13
26
  return (
14
27
  <>
@@ -46,6 +59,20 @@ export const RemotionRoot: React.FC = () => {
46
59
  return c;
47
60
  }}
48
61
  />
62
+ <Composition
63
+ id="Storyboard"
64
+ component={Storyboard}
65
+ defaultProps={storyboardSample}
66
+ durationInFrames={1}
67
+ fps={30}
68
+ width={1600}
69
+ height={900}
70
+ calculateMetadata={async (c) => {
71
+ await waitForFonts();
72
+ const { width, height } = sheetLayout(c.props.format, c.props.cells.length);
73
+ return { ...c, width, height };
74
+ }}
75
+ />
49
76
  </>
50
77
  );
51
78
  };
@@ -1,17 +1,66 @@
1
- import { Series } from "remotion";
2
- import type { ChangelogVideo } from "../schema/beats";
1
+ import { AbsoluteFill, Sequence, Series } from "remotion";
2
+ import { COLORS } from "../brand/tokens";
3
+ import type { Beat, ChangelogVideo } from "../schema/beats";
4
+ import { Background } from "./Background";
3
5
  import { ChangelogScene } from "./ChangelogScene";
4
6
 
5
- /** Top-level composition: a Series of beats. Duration/dimensions/tokens are set
6
- * by `calculateMetadata` in Root before this renders. */
7
+ /** Frames of crossfade when the backdrop actually changes between beats. */
8
+ const XFADE = 18;
9
+
10
+ /** Identity key for a backdrop — beats that resolve to the same key share one
11
+ * continuous Background (no transition between them). Omitted background and an
12
+ * explicit `shapes` both resolve to the default procedural backdrop. */
13
+ function bgKey(bg: Beat["background"]): string {
14
+ if (!bg || bg.shapes) return "shapes";
15
+ if (bg.src) return `img:${bg.src}:${bg.treatment ?? "kenburns"}`;
16
+ if (bg.gradient) return `grad:${bg.gradient[0]},${bg.gradient[1]}:${bg.angle ?? 160}`;
17
+ if (bg.solid) return `solid:${bg.solid}`;
18
+ return "shapes";
19
+ }
20
+
21
+ type Segment = { key: string; bg: Beat["background"]; from: number; duration: number };
22
+
23
+ /** Collapse consecutive same-backdrop beats into segments laid end-to-end. */
24
+ function backgroundSegments(beats: Beat[]): Segment[] {
25
+ const segs: Segment[] = [];
26
+ let frame = 0;
27
+ for (const b of beats) {
28
+ const key = bgKey(b.background);
29
+ const last = segs[segs.length - 1];
30
+ if (last && last.key === key) last.duration += b.durationInFrames;
31
+ else segs.push({ key, bg: b.background, from: frame, duration: b.durationInFrames });
32
+ frame += b.durationInFrames;
33
+ }
34
+ return segs;
35
+ }
36
+
37
+ /**
38
+ * Top-level composition. The backdrop is a **continuous layer** underneath the
39
+ * content: consecutive beats with the same backdrop share one Background (so it
40
+ * doesn't fade out/in or reset its Ken Burns between them) — only the content
41
+ * (headline, code, panel) transitions per beat. A real backdrop change
42
+ * crossfades (the next segment starts XFADE frames early and fades in on top).
43
+ * Duration/dimensions/tokens are set by `calculateMetadata` in Root.
44
+ */
7
45
  export const Changelog: React.FC<ChangelogVideo> = ({ format, beats }) => {
46
+ const segments = backgroundSegments(beats);
8
47
  return (
9
- <Series>
10
- {beats.map((beat) => (
11
- <Series.Sequence key={beat.id} durationInFrames={beat.durationInFrames}>
12
- <ChangelogScene beat={beat} format={format} />
13
- </Series.Sequence>
14
- ))}
15
- </Series>
48
+ <AbsoluteFill style={{ backgroundColor: COLORS.ink }}>
49
+ {segments.map((seg, i) => {
50
+ const from = i === 0 ? 0 : Math.max(0, seg.from - XFADE);
51
+ return (
52
+ <Sequence key={`${seg.key}-${seg.from}`} from={from} durationInFrames={seg.from + seg.duration - from} layout="none">
53
+ <Background bg={seg.bg} />
54
+ </Sequence>
55
+ );
56
+ })}
57
+ <Series>
58
+ {beats.map((beat) => (
59
+ <Series.Sequence key={beat.id} durationInFrames={beat.durationInFrames}>
60
+ <ChangelogScene beat={beat} format={format} />
61
+ </Series.Sequence>
62
+ ))}
63
+ </Series>
64
+ </AbsoluteFill>
16
65
  );
17
66
  };
@@ -3,7 +3,6 @@ import { COLORS, EASE } from "../brand/tokens";
3
3
  import { FONTS } from "../brand/fonts";
4
4
  import { isLightBackdrop } from "../brand/tone";
5
5
  import type { Beat, Format } from "../schema/beats";
6
- import { Background } from "./Background";
7
6
  import { Headline } from "./Headline";
8
7
  import { CodeWindow, codeTypingDoneFrame } from "./CodeWindow";
9
8
  import { Panel } from "./panels";
@@ -32,9 +31,10 @@ export const ChangelogScene: React.FC<{ beat: Beat; format: Format }> = ({ beat,
32
31
  // Sequential: the output panel waits for the code to finish "running".
33
32
  const panelStart = beat.code ? codeTypingDoneFrame(beat.code.tokens ?? [], beat.code.motion) + OUTPUT_GAP : 0;
34
33
 
34
+ // Background is a continuous layer in Changelog (so same-bg beats don't
35
+ // re-fade); this scene renders only the content that transitions per beat.
35
36
  return (
36
- <AbsoluteFill style={{ backgroundColor: COLORS.ink }}>
37
- <Background bg={beat.background} />
37
+ <AbsoluteFill>
38
38
  <Headline eyebrow={beat.eyebrow} headline={beat.headline} motion={beat.headlineMotion} format={format} light={light} />
39
39
 
40
40
  <div
@@ -0,0 +1,68 @@
1
+ import { AbsoluteFill, Img } from "remotion";
2
+ import { type Format } from "../schema/beats";
3
+ import { COLORS, RADIUS } from "../brand/tokens";
4
+ import { FONTS } from "../brand/fonts";
5
+
6
+ /**
7
+ * A contact sheet for a beats file: one representative still per beat in a
8
+ * labeled grid. Pure and props-driven — images arrive as base64 data URIs in
9
+ * props (NOT staticFile/public, so it works from a global install), unlike
10
+ * ContactSheet which fetches a manifest. `scripts/storyboard.ts` renders the
11
+ * per-beat stills, encodes them, and renders this composition to a single PNG.
12
+ */
13
+ export type StoryboardCell = { img: string; headline: string; panel: string; seconds: string };
14
+ export type StoryboardProps = { format: Format; name: string; cells: StoryboardCell[] };
15
+
16
+ const PAD = 56;
17
+ const GAP = 24;
18
+ const HEADER = 110;
19
+ const CAPTION = 56;
20
+ const WIDTH = 1600;
21
+
22
+ /**
23
+ * Grid geometry + total canvas height for a given format and beat count. Used
24
+ * both by the component (columns + cell aspect) and by `calculateMetadata` in
25
+ * Root.tsx (canvas height) so the sheet fits its contents with no clipping.
26
+ */
27
+ export function sheetLayout(format: Format, count: number) {
28
+ const cols = format === "9x16" ? 4 : 3;
29
+ const rows = Math.max(1, Math.ceil(count / cols));
30
+ const cellW = (WIDTH - PAD * 2 - GAP * (cols - 1)) / cols;
31
+ const ar = format === "16x9" ? 9 / 16 : format === "9x16" ? 16 / 9 : 1; // height / width
32
+ const cellH = cellW * ar + CAPTION;
33
+ const height = Math.round(HEADER + PAD * 2 + rows * cellH + (rows - 1) * GAP);
34
+ const cellAspect = format === "16x9" ? "16 / 9" : format === "9x16" ? "9 / 16" : "1 / 1";
35
+ return { cols, width: WIDTH, height, cellAspect };
36
+ }
37
+
38
+ export const Storyboard: React.FC<StoryboardProps> = ({ format, name, cells }) => {
39
+ const { cols, cellAspect } = sheetLayout(format, cells.length);
40
+ return (
41
+ <AbsoluteFill style={{ background: COLORS.paper, padding: PAD, fontFamily: FONTS.body }}>
42
+ <div style={{ fontFamily: FONTS.display, fontSize: 40, fontWeight: 600, letterSpacing: "-0.02em", color: COLORS.ink }}>{name}</div>
43
+ <div style={{ fontFamily: FONTS.note, fontSize: 22, color: COLORS.signalBlue, marginBottom: 28 }}>
44
+ storyboard · {cells.length} beats · {format}
45
+ </div>
46
+ <div style={{ display: "grid", gridTemplateColumns: `repeat(${cols}, 1fr)`, gap: GAP }}>
47
+ {cells.map((c, i) => (
48
+ <div key={i} style={{ borderRadius: RADIUS.lg, overflow: "hidden", border: `1px solid ${COLORS.hairline}`, background: COLORS.paperElevated }}>
49
+ <Img
50
+ src={c.img}
51
+ onError={() => {}}
52
+ delayRenderTimeoutInMilliseconds={8000}
53
+ style={{ width: "100%", aspectRatio: cellAspect, objectFit: "cover", display: "block", background: COLORS.paper }}
54
+ />
55
+ <div style={{ display: "flex", justifyContent: "space-between", alignItems: "baseline", gap: 12, padding: "12px 16px" }}>
56
+ <span style={{ fontSize: 17, fontWeight: 600, color: COLORS.ink, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
57
+ {i + 1}. {c.headline}
58
+ </span>
59
+ <span style={{ fontFamily: FONTS.mono, fontSize: 12, textTransform: "uppercase", letterSpacing: "0.06em", color: COLORS.textMuted, whiteSpace: "nowrap" }}>
60
+ {c.panel} · {c.seconds}
61
+ </span>
62
+ </div>
63
+ </div>
64
+ ))}
65
+ </div>
66
+ </AbsoluteFill>
67
+ );
68
+ };