@waits/cadence 0.2.0 → 0.4.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 +11 -4
- package/package.json +2 -1
- package/scripts/_audit.ts +53 -0
- package/scripts/_beats.ts +38 -0
- package/scripts/_theme.ts +74 -0
- package/scripts/audit.ts +6 -47
- package/scripts/cli.ts +10 -2
- package/scripts/make.ts +17 -4
- package/scripts/render.ts +10 -17
- package/scripts/smoke.ts +20 -2
- package/scripts/storyboard.ts +125 -0
- package/src/Root.tsx +27 -0
- package/src/brand/tokens.ts +3 -0
- package/src/components/Changelog.tsx +60 -11
- package/src/components/ChangelogScene.tsx +3 -3
- package/src/components/CodeWindow.tsx +10 -7
- package/src/components/Storyboard.tsx +68 -0
- package/src/theme/types.ts +6 -0
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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.
|
|
3
|
+
"version": "0.4.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
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Project-local `.cadence/` resolution shared by render / storyboard / make.
|
|
3
|
+
*
|
|
4
|
+
* A repo captures its own cadence setup under `<project>/.cadence/`:
|
|
5
|
+
* - `theme.json` — brand (colors, mono font, code syntax theme, code chrome)
|
|
6
|
+
* cadence discovers it automatically when run against the project, and writes
|
|
7
|
+
* **outputs alongside it** (`<project>/.cadence/out/`) so renders are anchored to
|
|
8
|
+
* the project, not the current directory. No project-specific files live in the
|
|
9
|
+
* cadence engine itself.
|
|
10
|
+
*
|
|
11
|
+
* Theme precedence: --theme-file > --theme <name> > .cadence/theme.json > default.
|
|
12
|
+
* Output dir: --out <dir> > <project>/.cadence/out > ./out.
|
|
13
|
+
*/
|
|
14
|
+
import { existsSync } from "node:fs";
|
|
15
|
+
import { dirname, join, resolve } from "node:path";
|
|
16
|
+
import { THEMES } from "../src/theme";
|
|
17
|
+
|
|
18
|
+
/** Walk up from `start` (max 6 levels) for a `.cadence` directory. */
|
|
19
|
+
export function findCadenceDir(start: string): string | undefined {
|
|
20
|
+
let dir = resolve(start);
|
|
21
|
+
for (let i = 0; i < 6; i++) {
|
|
22
|
+
const p = join(dir, ".cadence");
|
|
23
|
+
if (existsSync(p)) return p;
|
|
24
|
+
const parent = dirname(dir);
|
|
25
|
+
if (parent === dir) break;
|
|
26
|
+
dir = parent;
|
|
27
|
+
}
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** A project's `.cadence/theme.json`, if one exists near `start`. */
|
|
32
|
+
export function discoverProjectTheme(start: string): string | undefined {
|
|
33
|
+
const cad = findCadenceDir(start);
|
|
34
|
+
if (!cad) return undefined;
|
|
35
|
+
const p = join(cad, "theme.json");
|
|
36
|
+
return existsSync(p) ? p : undefined;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Where renders are written. Precedence: an explicit `--out <dir>` > a project's
|
|
41
|
+
* `<project>/.cadence/out` (so outputs are anchored to the project regardless of
|
|
42
|
+
* cwd) > `./out`. `beatsFile` (when present) anchors discovery near the beats
|
|
43
|
+
* file; otherwise discovery starts from the current directory.
|
|
44
|
+
*/
|
|
45
|
+
export function resolveOutDir(opts: { outFlag?: string; beatsFile?: string }): string {
|
|
46
|
+
if (opts.outFlag) return resolve(opts.outFlag);
|
|
47
|
+
const start = opts.beatsFile ? dirname(resolve(opts.beatsFile)) : process.cwd();
|
|
48
|
+
const cad = findCadenceDir(start);
|
|
49
|
+
return cad ? join(cad, "out") : "out";
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Resolve which theme to use. Returns the (possibly auto-discovered) `themeFile`
|
|
54
|
+
* and/or named `theme`. Exits on an unknown named theme. Logs when a project
|
|
55
|
+
* theme is auto-discovered so it's never a silent surprise.
|
|
56
|
+
*/
|
|
57
|
+
export function resolveTheme(opts: { theme?: string; themeFile?: string; beatsFile?: string }): {
|
|
58
|
+
theme?: string;
|
|
59
|
+
themeFile?: string;
|
|
60
|
+
} {
|
|
61
|
+
let { theme, themeFile } = opts;
|
|
62
|
+
if (!themeFile && !theme && opts.beatsFile) {
|
|
63
|
+
const found = discoverProjectTheme(dirname(resolve(opts.beatsFile)));
|
|
64
|
+
if (found) {
|
|
65
|
+
themeFile = found;
|
|
66
|
+
console.error(`· using project theme ${found}`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (theme && !themeFile && !THEMES[theme]) {
|
|
70
|
+
console.error(`unknown --theme "${theme}". have: ${Object.keys(THEMES).join(", ")}`);
|
|
71
|
+
process.exit(1);
|
|
72
|
+
}
|
|
73
|
+
return { theme, themeFile };
|
|
74
|
+
}
|
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).
|
|
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 {
|
|
9
|
-
import {
|
|
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
|
|
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
|
|
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(` ${
|
|
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,9 @@ 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>, --out <dir>
|
|
51
|
+
outputs: written to <project>/.cadence/out when run against a repo with a .cadence/ dir, else ./out (override with --out)
|
|
52
|
+
preview before rendering: cadence storyboard <beats> · cadence create … --dry-run (plan + one still per beat, no MP4)`;
|
|
49
53
|
|
|
50
54
|
if (!sub || sub === "help" || sub === "--help") {
|
|
51
55
|
console.log(HELP);
|
|
@@ -65,7 +69,11 @@ if (sub === "themes") {
|
|
|
65
69
|
function resolveScript(verb: string, args: string[]): string | undefined {
|
|
66
70
|
if (verb === "create") {
|
|
67
71
|
const beatsFile = args.find((a) => !a.startsWith("-") && /\.(beats\.)?(ts|js|json)$/.test(a));
|
|
68
|
-
|
|
72
|
+
const dryRun = args.includes("--dry-run");
|
|
73
|
+
// beats-file flow: --dry-run → storyboard (preview sheet), else render (MP4).
|
|
74
|
+
if (beatsFile) return dryRun ? "scripts/storyboard.ts" : "scripts/render.ts";
|
|
75
|
+
// repo flow: make.ts handles --dry-run itself (generates beats → storyboard).
|
|
76
|
+
return "scripts/make.ts";
|
|
69
77
|
}
|
|
70
78
|
return SCRIPTS[verb];
|
|
71
79
|
}
|
package/scripts/make.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { manifestFromChangelogText, manifestFromReleaseBody } from "../src/adapt
|
|
|
13
13
|
import type { BackgroundSpec } from "../src/templates";
|
|
14
14
|
import { TEMPLATES } from "../src/templates";
|
|
15
15
|
import { binPath, pkgFile } from "./_pkg";
|
|
16
|
+
import { resolveOutDir } from "./_theme";
|
|
16
17
|
|
|
17
18
|
const args = process.argv.slice(2);
|
|
18
19
|
const flag = (n: string) => {
|
|
@@ -67,10 +68,22 @@ const beats = template(manifest, {
|
|
|
67
68
|
|
|
68
69
|
console.error(`· ${manifest.product} ${manifest.version}: ${manifest.features.length} features${manifest.dropped ? ` (+${manifest.dropped} dropped)` : ""} → ${templateName}`);
|
|
69
70
|
|
|
70
|
-
// 3. Write beats JSON
|
|
71
|
-
|
|
72
|
-
const
|
|
71
|
+
// 3. Write beats JSON, then render — or, with --dry-run, storyboard it (preview
|
|
72
|
+
// sheet + plan, no MP4). Outputs go to the project's .cadence/out (or --out).
|
|
73
|
+
const outDir = resolveOutDir({ outFlag: flag("--out") });
|
|
74
|
+
mkdirSync(outDir, { recursive: true });
|
|
75
|
+
const jsonPath = join(outDir, `make-${manifest.product}.beats.json`);
|
|
73
76
|
writeFileSync(jsonPath, JSON.stringify(beats));
|
|
77
|
+
// Pin the child to the same out dir so beats + renders colocate.
|
|
78
|
+
const outArgs = ["--out", outDir];
|
|
79
|
+
|
|
80
|
+
if (args.includes("--dry-run")) {
|
|
81
|
+
// repo → storyboard. --frame is meaningless here; --theme-file is supported.
|
|
82
|
+
const passthru = ["--format", "--theme", "--theme-file"].flatMap((f) => (flag(f) ? [f, flag(f)!] : []));
|
|
83
|
+
const res = spawnSync(binPath("tsx"), [pkgFile("scripts/storyboard.ts"), jsonPath, ...passthru, ...outArgs], { stdio: "inherit" });
|
|
84
|
+
process.exit(res.status ?? 0);
|
|
85
|
+
}
|
|
86
|
+
|
|
74
87
|
const passthru = ["--format", "--theme", "--frame"].flatMap((f) => (flag(f) ? [f, flag(f)!] : []));
|
|
75
|
-
const res = spawnSync(binPath("tsx"), [pkgFile("scripts/render.ts"), jsonPath, ...passthru], { stdio: "inherit" });
|
|
88
|
+
const res = spawnSync(binPath("tsx"), [pkgFile("scripts/render.ts"), jsonPath, ...passthru, ...outArgs], { stdio: "inherit" });
|
|
76
89
|
process.exit(res.status ?? 0);
|
package/scripts/render.ts
CHANGED
|
@@ -9,10 +9,10 @@
|
|
|
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 {
|
|
13
|
-
import {
|
|
14
|
-
import { THEMES } from "../src/theme";
|
|
12
|
+
import { type Format } from "../src/schema/beats";
|
|
13
|
+
import { loadBeats } from "./_beats";
|
|
15
14
|
import { binPath, pkgFile } from "./_pkg";
|
|
15
|
+
import { resolveOutDir, resolveTheme } from "./_theme";
|
|
16
16
|
|
|
17
17
|
const args = process.argv.slice(2);
|
|
18
18
|
const getFlag = (name: string) => {
|
|
@@ -29,34 +29,27 @@ if (!file) {
|
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
// Accept either a `.beats.ts` module or a plain `.json` beats file.
|
|
32
|
-
const
|
|
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;
|
|
39
36
|
const frame = getFlag("--frame");
|
|
40
|
-
const theme = getFlag("--theme");
|
|
41
|
-
const themeFile = getFlag("--theme-file");
|
|
42
|
-
if (theme && !themeFile && !THEMES[theme]) {
|
|
43
|
-
console.error(`unknown --theme "${theme}". have: ${Object.keys(THEMES).join(", ")}`);
|
|
44
|
-
process.exit(1);
|
|
45
|
-
}
|
|
37
|
+
const { theme, themeFile } = resolveTheme({ theme: getFlag("--theme"), themeFile: getFlag("--theme-file"), beatsFile: file });
|
|
46
38
|
const env: NodeJS.ProcessEnv = { ...process.env };
|
|
47
39
|
if (theme) env.REMOTION_VIDEO_THEME = theme;
|
|
48
40
|
if (themeFile) env.REMOTION_VIDEO_THEME_JSON = readFileSync(resolve(themeFile), "utf8");
|
|
49
41
|
|
|
50
|
-
|
|
42
|
+
const outDir = resolveOutDir({ outFlag: getFlag("--out"), beatsFile: file });
|
|
43
|
+
mkdirSync(outDir, { recursive: true });
|
|
51
44
|
const name = basename(file).replace(/\.beats\.(ts|js|json)$/, "").replace(/\.(ts|js|json)$/, "");
|
|
52
|
-
const propsPath = join(
|
|
45
|
+
const propsPath = join(outDir, `.props-${name}-${parsed.format}.json`);
|
|
53
46
|
writeFileSync(propsPath, JSON.stringify(parsed));
|
|
54
47
|
|
|
55
48
|
const bin = binPath("remotion");
|
|
56
49
|
const common = [pkgFile("src/index.ts"), "Changelog"];
|
|
57
50
|
const suffix = theme && theme !== "default" ? `-${theme}` : "";
|
|
58
51
|
const res = frame
|
|
59
|
-
? spawnSync(bin, ["still", ...common, join(
|
|
60
|
-
: spawnSync(bin, ["render", ...common, join(
|
|
52
|
+
? spawnSync(bin, ["still", ...common, join(outDir, `${name}-${parsed.format}${suffix}-f${frame}.png`), `--props=${propsPath}`, `--frame=${frame}`], { stdio: "inherit", env })
|
|
53
|
+
: spawnSync(bin, ["render", ...common, join(outDir, `${name}-${parsed.format}${suffix}.mp4`), `--props=${propsPath}`, "--image-format=jpeg"], { stdio: "inherit", env });
|
|
61
54
|
|
|
62
55
|
process.exit(res.status ?? 0);
|
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}/${
|
|
58
|
+
console.error(`\n✗ ${failed}/${total} cases failed`);
|
|
41
59
|
process.exit(1);
|
|
42
60
|
}
|
|
43
|
-
console.log(`\n✓ ${
|
|
61
|
+
console.log(`\n✓ ${total} cases ok`);
|
|
@@ -0,0 +1,125 @@
|
|
|
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 { auditBeats, ICON, rankFindings } from "./_audit";
|
|
19
|
+
import { beatTimings, FPS, loadBeats } from "./_beats";
|
|
20
|
+
import { binPath, pkgFile } from "./_pkg";
|
|
21
|
+
import { resolveOutDir, resolveTheme } from "./_theme";
|
|
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, themeFile } = resolveTheme({ theme: getFlag("--theme"), themeFile: getFlag("--theme-file"), beatsFile: file });
|
|
38
|
+
|
|
39
|
+
/** Short label for a beat's backdrop (omitted background = the procedural default). */
|
|
40
|
+
const bgTag = (b: Beat): string => {
|
|
41
|
+
const bg = b.background;
|
|
42
|
+
if (!bg) return "shapes";
|
|
43
|
+
if (bg.src) return "image";
|
|
44
|
+
if (bg.gradient) return "gradient";
|
|
45
|
+
if (bg.solid) return "solid";
|
|
46
|
+
return "shapes";
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const parsed = await loadBeats(file);
|
|
50
|
+
const fmt = (getFlag("--format") as Format | undefined) ?? parsed.format;
|
|
51
|
+
parsed.format = fmt;
|
|
52
|
+
const themeLabel = themeFile ? `custom (${basename(themeFile)})` : (theme ?? "default");
|
|
53
|
+
const { timings, totalFrames } = beatTimings(parsed.beats);
|
|
54
|
+
const name = basename(file).replace(/\.beats\.(ts|js|json)$/, "").replace(/\.(ts|js|json)$/, "");
|
|
55
|
+
|
|
56
|
+
// --- 1. The plan (text) ---
|
|
57
|
+
console.log(`\nstoryboard: ${file}`);
|
|
58
|
+
console.log(` ${parsed.beats.length} beats · ${(totalFrames / FPS).toFixed(1)}s · ${fmt} · theme ${themeLabel}\n`);
|
|
59
|
+
|
|
60
|
+
parsed.beats.forEach((b, i) => {
|
|
61
|
+
const t = timings[i];
|
|
62
|
+
const start = `${(t.start / FPS).toFixed(1)}s`.padStart(6);
|
|
63
|
+
const dur = `${(t.dur / FPS).toFixed(1)}s`.padStart(5);
|
|
64
|
+
const layout = b.layout.padEnd(6);
|
|
65
|
+
const panel = (b.panel?.kind ?? "—").padEnd(14);
|
|
66
|
+
const bg = bgTag(b).padEnd(8);
|
|
67
|
+
console.log(` ${String(i + 1).padStart(2)} @${start} ${dur} ${layout} ${panel} ${bg} ${b.headline}`);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const findings = rankFindings(auditBeats(parsed.beats));
|
|
71
|
+
if (findings.length) {
|
|
72
|
+
console.log("");
|
|
73
|
+
for (const f of findings) console.log(` ${ICON[f.level]} ${f.msg}`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// --- 2. The sheet (one still per beat → a single PNG) ---
|
|
77
|
+
const env: NodeJS.ProcessEnv = { ...process.env };
|
|
78
|
+
if (theme) env.REMOTION_VIDEO_THEME = theme;
|
|
79
|
+
if (themeFile) env.REMOTION_VIDEO_THEME_JSON = readFileSync(themeFile, "utf8");
|
|
80
|
+
|
|
81
|
+
const outDir = resolveOutDir({ outFlag: getFlag("--out"), beatsFile: file });
|
|
82
|
+
mkdirSync(outDir, { recursive: true });
|
|
83
|
+
const tmp = join(outDir, `.sb-${name}`);
|
|
84
|
+
mkdirSync(tmp, { recursive: true });
|
|
85
|
+
const propsPath = join(tmp, "props.json");
|
|
86
|
+
writeFileSync(propsPath, JSON.stringify(parsed));
|
|
87
|
+
|
|
88
|
+
const bin = binPath("remotion");
|
|
89
|
+
const entry = pkgFile("src/index.ts");
|
|
90
|
+
const TRANSPARENT_PX =
|
|
91
|
+
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+P+/HgAFhAJ/wlseKgAAAABJRU5ErkJggg==";
|
|
92
|
+
|
|
93
|
+
console.log(`\nrendering ${parsed.beats.length} stills…`);
|
|
94
|
+
const cells: StoryboardCell[] = parsed.beats.map((b, i) => {
|
|
95
|
+
const out = join(tmp, `beat-${i}.png`);
|
|
96
|
+
const res = spawnSync(
|
|
97
|
+
bin,
|
|
98
|
+
["still", entry, "Changelog", out, `--props=${propsPath}`, `--frame=${timings[i].mid}`, "--scale=0.33"],
|
|
99
|
+
{ stdio: ["ignore", "ignore", "inherit"], env },
|
|
100
|
+
);
|
|
101
|
+
let img = TRANSPARENT_PX;
|
|
102
|
+
if (res.status === 0 && existsSync(out) && statSync(out).size > 0) {
|
|
103
|
+
img = `data:image/png;base64,${readFileSync(out).toString("base64")}`;
|
|
104
|
+
} else {
|
|
105
|
+
console.warn(` ! beat ${i + 1} still failed — placeholder used`);
|
|
106
|
+
}
|
|
107
|
+
return { img, headline: b.headline, panel: b.panel?.kind ?? "—", seconds: `${(b.durationInFrames / FPS).toFixed(1)}s` };
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
const sheetProps: StoryboardProps = { format: fmt, name, cells };
|
|
111
|
+
const sheetPropsPath = join(tmp, "sheet.json");
|
|
112
|
+
writeFileSync(sheetPropsPath, JSON.stringify(sheetProps));
|
|
113
|
+
|
|
114
|
+
const outPng = join(outDir, `${name}.storyboard.png`);
|
|
115
|
+
const sheet = spawnSync(bin, ["still", entry, "Storyboard", outPng, `--props=${sheetPropsPath}`], {
|
|
116
|
+
stdio: ["ignore", "ignore", "inherit"],
|
|
117
|
+
env,
|
|
118
|
+
});
|
|
119
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
120
|
+
|
|
121
|
+
if (sheet.status !== 0) {
|
|
122
|
+
console.error("\n✗ storyboard sheet render failed");
|
|
123
|
+
process.exit(sheet.status ?? 1);
|
|
124
|
+
}
|
|
125
|
+
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
|
};
|
package/src/brand/tokens.ts
CHANGED
|
@@ -9,6 +9,9 @@ export const COLORS = activeTheme.colors;
|
|
|
9
9
|
|
|
10
10
|
export const CARET_BG = activeTheme.caretBg;
|
|
11
11
|
|
|
12
|
+
/** Code window chrome — `"window"` (floating editor) or `"minimal"` (docs-style). */
|
|
13
|
+
export const CODE_CHROME = activeTheme.codeChrome ?? "window";
|
|
14
|
+
|
|
12
15
|
/** Light [from, to] gradient for the procedural default backdrop ("shapes"). */
|
|
13
16
|
export const BACKDROP: [string, string] = activeTheme.backdrop ?? ["#e6edff", "#f8faff"];
|
|
14
17
|
|
|
@@ -1,17 +1,66 @@
|
|
|
1
|
-
import { Series } from "remotion";
|
|
2
|
-
import
|
|
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
|
-
/**
|
|
6
|
-
|
|
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
|
-
<
|
|
10
|
-
{
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
|
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
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useCurrentFrame } from "remotion";
|
|
2
|
-
import { CARET_BG, COLORS, FLOAT_SHADOW } from "../brand/tokens";
|
|
2
|
+
import { CARET_BG, CODE_CHROME, COLORS, FLOAT_SHADOW } from "../brand/tokens";
|
|
3
3
|
import { FONTS } from "../brand/fonts";
|
|
4
4
|
import { useMotion, type MotionSpec } from "../motion/useMotion";
|
|
5
5
|
import { CODE_BG, type CodeLine } from "../code/highlight";
|
|
@@ -24,6 +24,7 @@ export const codeTypingDoneFrame = (tokens: CodeLine[], motion?: MotionSpec) =>
|
|
|
24
24
|
export const CodeWindow: React.FC<Props> = ({ filename, tokens, motion = { enter: "settle", delay: 12 }, fontSize = 22 }) => {
|
|
25
25
|
const frame = useCurrentFrame();
|
|
26
26
|
const style = useMotion(motion);
|
|
27
|
+
const minimal = CODE_CHROME === "minimal";
|
|
27
28
|
|
|
28
29
|
const typeStart = typeStartFor(motion);
|
|
29
30
|
const total = totalChars(tokens);
|
|
@@ -81,7 +82,7 @@ export const CodeWindow: React.FC<Props> = ({ filename, tokens, motion = { enter
|
|
|
81
82
|
<div
|
|
82
83
|
style={{
|
|
83
84
|
width: "100%",
|
|
84
|
-
borderRadius: 20,
|
|
85
|
+
borderRadius: minimal ? 12 : 20,
|
|
85
86
|
background: `${CODE_BG}f7`,
|
|
86
87
|
boxShadow: FLOAT_SHADOW,
|
|
87
88
|
backdropFilter: "blur(6px)",
|
|
@@ -90,11 +91,13 @@ export const CodeWindow: React.FC<Props> = ({ filename, tokens, motion = { enter
|
|
|
90
91
|
...style,
|
|
91
92
|
}}
|
|
92
93
|
>
|
|
93
|
-
|
|
94
|
-
<
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
94
|
+
{!minimal && (
|
|
95
|
+
<div style={{ height: 54, display: "flex", alignItems: "center", padding: "0 22px", gap: 9 }}>
|
|
96
|
+
<Dot color="#f6645f" /><Dot color="#f7bd45" /><Dot color="#2fc94e" />
|
|
97
|
+
<span style={{ flex: 1, textAlign: "center", color: "rgba(0,0,0,0.34)", fontSize: 15, fontFamily: FONTS.mono, marginRight: 60 }}>{filename}</span>
|
|
98
|
+
</div>
|
|
99
|
+
)}
|
|
100
|
+
<div style={{ padding: minimal ? "30px 34px" : "26px 40px 42px", fontFamily: FONTS.mono, fontSize, lineHeight: `${lineHeight}px`, color: COLORS.ink }}>
|
|
98
101
|
{lines}
|
|
99
102
|
</div>
|
|
100
103
|
</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
|
+
};
|
package/src/theme/types.ts
CHANGED
|
@@ -62,6 +62,12 @@ export type ThemeConfig = {
|
|
|
62
62
|
caretBg: string;
|
|
63
63
|
/** Syntax colors. */
|
|
64
64
|
codeTheme: CodeTheme;
|
|
65
|
+
/**
|
|
66
|
+
* Code window chrome. `"window"` (default) is a floating editor with traffic-
|
|
67
|
+
* light dots + a filename tab; `"minimal"` is chromeless (just the code surface)
|
|
68
|
+
* to match a docs-style snippet component.
|
|
69
|
+
*/
|
|
70
|
+
codeChrome?: "window" | "minimal";
|
|
65
71
|
/** Light [from, to] gradient for the procedural default backdrop (Background "shapes"). */
|
|
66
72
|
backdrop?: [string, string];
|
|
67
73
|
};
|