@waits/cadence 0.2.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.
Files changed (82) hide show
  1. package/LICENSE +21 -0
  2. package/MOTION.md +70 -0
  3. package/README.md +109 -0
  4. package/bin/cli.mjs +21 -0
  5. package/package.json +59 -0
  6. package/prompts/art/compose.ts +18 -0
  7. package/prompts/art/fantasy.ts +15 -0
  8. package/prompts/art/landmarks.ts +49 -0
  9. package/prompts/art/style.ts +20 -0
  10. package/public/backgrounds/barton-springs.png +0 -0
  11. package/public/backgrounds/capitol.png +0 -0
  12. package/public/backgrounds/congress.png +0 -0
  13. package/public/backgrounds/enchanted-rock.png +0 -0
  14. package/public/backgrounds/hamilton-pool.png +0 -0
  15. package/public/backgrounds/mount-bonnell.png +0 -0
  16. package/public/backgrounds/pennybacker.png +0 -0
  17. package/public/backgrounds/ut-tower.png +0 -0
  18. package/scripts/_pkg.ts +28 -0
  19. package/scripts/audit.ts +69 -0
  20. package/scripts/changes.ts +70 -0
  21. package/scripts/check-motion.ts +37 -0
  22. package/scripts/cli.ts +79 -0
  23. package/scripts/generate-art.ts +72 -0
  24. package/scripts/guide.ts +114 -0
  25. package/scripts/make.ts +76 -0
  26. package/scripts/redesign.ts +83 -0
  27. package/scripts/render.ts +62 -0
  28. package/scripts/smoke.ts +43 -0
  29. package/scripts/sync-skill.ts +43 -0
  30. package/scripts/theme.ts +97 -0
  31. package/src/Root.tsx +51 -0
  32. package/src/adapters/index.ts +72 -0
  33. package/src/adapters/parse.ts +65 -0
  34. package/src/adapters/types.ts +24 -0
  35. package/src/brand/fonts.ts +74 -0
  36. package/src/brand/tokens.ts +31 -0
  37. package/src/brand/tone.ts +25 -0
  38. package/src/code/highlight.ts +90 -0
  39. package/src/components/Background.tsx +75 -0
  40. package/src/components/Changelog.tsx +17 -0
  41. package/src/components/ChangelogScene.tsx +117 -0
  42. package/src/components/CodeWindow.tsx +106 -0
  43. package/src/components/ContactSheet.tsx +44 -0
  44. package/src/components/Headline.tsx +73 -0
  45. package/src/components/MotionReel.tsx +108 -0
  46. package/src/components/panels/DataTable.tsx +34 -0
  47. package/src/components/panels/Diagram.tsx +67 -0
  48. package/src/components/panels/Feed.tsx +46 -0
  49. package/src/components/panels/Fork.tsx +86 -0
  50. package/src/components/panels/PanelCard.tsx +44 -0
  51. package/src/components/panels/Proof.tsx +64 -0
  52. package/src/components/panels/Stat.tsx +28 -0
  53. package/src/components/panels/Status.tsx +41 -0
  54. package/src/components/panels/StreamResume.tsx +48 -0
  55. package/src/components/panels/UploadProgress.tsx +42 -0
  56. package/src/components/panels/index.tsx +34 -0
  57. package/src/content/clarinet-3.18.beats.ts +58 -0
  58. package/src/content/gradient-demo.beats.ts +29 -0
  59. package/src/content/index-decoded.beats.ts +93 -0
  60. package/src/content/mainnet-launch.beats.ts +70 -0
  61. package/src/content/panels.beats.ts +40 -0
  62. package/src/content/sdk-6.5-mempool.beats.ts +67 -0
  63. package/src/content/streams-launch.beats.ts +143 -0
  64. package/src/content/streams.beats.ts +46 -0
  65. package/src/index.ts +4 -0
  66. package/src/motion/names.ts +29 -0
  67. package/src/motion/presets.ts +67 -0
  68. package/src/motion/useMotion.ts +63 -0
  69. package/src/prepare.ts +33 -0
  70. package/src/schema/beats.ts +161 -0
  71. package/src/templates/changelog-reel.ts +28 -0
  72. package/src/templates/feature-launch.ts +26 -0
  73. package/src/templates/index.ts +13 -0
  74. package/src/templates/milestone.ts +25 -0
  75. package/src/templates/parts.ts +32 -0
  76. package/src/templates/types.ts +31 -0
  77. package/src/theme/default.ts +39 -0
  78. package/src/theme/derive.ts +119 -0
  79. package/src/theme/index.ts +33 -0
  80. package/src/theme/library.ts +60 -0
  81. package/src/theme/slate.ts +39 -0
  82. package/src/theme/types.ts +67 -0
@@ -0,0 +1,97 @@
1
+ /**
2
+ * `cadence study` — brand extraction → a theme. Give a brand accent (or a URL to
3
+ * guess it from), get a full ThemeConfig JSON (+ a portable design.md) you render
4
+ * with `--theme-file`.
5
+ *
6
+ * cadence study --accent "#10b981" --name emerald
7
+ * cadence study --from-url https://example.com --name acme
8
+ * cadence create <beats> --theme-file themes/emerald.json
9
+ *
10
+ * Screenshot → theme: this script extracts from a hex/URL only. To build a theme
11
+ * from a screenshot, hand the image to your agent (Claude/Cursor/Codex) — it reads
12
+ * the palette and writes a themes/<name>.json. See the cadence skill.
13
+ */
14
+ import { mkdirSync, writeFileSync } from "node:fs";
15
+ import { join } from "node:path";
16
+ import { deriveTheme } from "../src/theme/derive";
17
+
18
+ const args = process.argv.slice(2);
19
+ const flag = (n: string) => {
20
+ const eq = args.find((a) => a.startsWith(`${n}=`));
21
+ if (eq) return eq.split("=")[1];
22
+ const i = args.indexOf(n);
23
+ return i >= 0 && !args[i + 1]?.startsWith("--") ? args[i + 1] : undefined;
24
+ };
25
+
26
+ const parseHex = (hex: string) => {
27
+ let h = hex.replace("#", "");
28
+ if (h.length === 3) h = h.split("").map((c) => c + c).join("");
29
+ return { r: parseInt(h.slice(0, 2), 16), g: parseInt(h.slice(2, 4), 16), b: parseInt(h.slice(4, 6), 16) };
30
+ };
31
+ const saturated = (hex: string) => {
32
+ const { r, g, b } = parseHex(hex);
33
+ const mx = Math.max(r, g, b), mn = Math.min(r, g, b), l = (mx + mn) / 2;
34
+ return mx - mn > 55 && l > 40 && l < 220;
35
+ };
36
+
37
+ async function accentFromUrl(url: string): Promise<string | undefined> {
38
+ const html = await fetch(url).then((r) => r.text());
39
+ const meta = html.match(/<meta[^>]+name=["']theme-color["'][^>]+content=["'](#[0-9a-fA-F]{3,6})["']/i);
40
+ if (meta) return meta[1].toLowerCase();
41
+ const counts = new Map<string, number>();
42
+ for (const m of html.matchAll(/#([0-9a-fA-F]{6})\b/g)) {
43
+ const h = "#" + m[1].toLowerCase();
44
+ if (saturated(h)) counts.set(h, (counts.get(h) ?? 0) + 1);
45
+ }
46
+ return [...counts.entries()].sort((a, b) => b[1] - a[1])[0]?.[0];
47
+ }
48
+
49
+ let accent = flag("--accent");
50
+ const fromUrl = flag("--from-url");
51
+ if (!accent && fromUrl) {
52
+ accent = await accentFromUrl(fromUrl);
53
+ console.error(accent ? `· extracted accent ${accent} from ${fromUrl}` : `· couldn't find an accent at ${fromUrl}`);
54
+ }
55
+ if (!accent) {
56
+ console.error("usage: theme.ts --accent '#10b981' [--ink #0f172a] [--paper #f6f7f9] [--gold #c08a2e] --name <name>");
57
+ console.error(" or: theme.ts --from-url https://example.com --name <name>");
58
+ process.exit(1);
59
+ }
60
+
61
+ const name = flag("--name") ?? "brand";
62
+ const theme = deriveTheme({ name, accent, ink: flag("--ink"), paper: flag("--paper"), gold: flag("--gold") });
63
+
64
+ mkdirSync("themes", { recursive: true });
65
+ const out = flag("--out") ?? join("themes", `${name}.json`);
66
+ writeFileSync(out, JSON.stringify(theme, null, 2));
67
+
68
+ // Portable design summary — a human-readable companion to the JSON.
69
+ const c = theme.colors;
70
+ const designMd = `# ${name} — design
71
+
72
+ Extracted accent: \`${accent}\`
73
+
74
+ ## Palette
75
+ | role | color |
76
+ |------|-------|
77
+ | accent (signal) | \`${c.signalBlue}\` |
78
+ | ink (text) | \`${c.ink}\` |
79
+ | paper (bg) | \`${c.paper}\` |
80
+ | marker | \`${c.markerPink}\` |
81
+ | gold (version) | \`${c.gold}\` |
82
+ | success / warning / danger | \`${c.successGreen}\` / \`${c.warningYellow}\` / \`${c.dangerRed}\` |
83
+
84
+ ## Fonts
85
+ - display: ${theme.fonts.display}
86
+ - body: ${theme.fonts.body}
87
+ - mono: ${theme.fonts.mono}
88
+
89
+ ## Use
90
+ \`\`\`
91
+ cadence create <beats> --theme-file ${out}
92
+ \`\`\`
93
+ `;
94
+ const designOut = out.replace(/\.json$/, ".design.md");
95
+ writeFileSync(designOut, designMd);
96
+
97
+ console.log(`✓ ${name} theme (accent ${accent}) → ${out}\n design summary → ${designOut}\n render with: cadence create <beats> --theme-file ${out}`);
package/src/Root.tsx ADDED
@@ -0,0 +1,51 @@
1
+ import { Composition } from "remotion";
2
+ import { Changelog } from "./components/Changelog";
3
+ import { MotionReel } from "./components/MotionReel";
4
+ import { ContactSheet } from "./components/ContactSheet";
5
+ import { changelogSchema } from "./schema/beats";
6
+ import { prepareChangelog } from "./prepare";
7
+ import { waitForFonts } from "./brand/fonts";
8
+ import streams from "./content/streams.beats";
9
+
10
+ const defaultProps = changelogSchema.parse(streams);
11
+
12
+ export const RemotionRoot: React.FC = () => {
13
+ return (
14
+ <>
15
+ <Composition
16
+ id="Changelog"
17
+ component={Changelog}
18
+ defaultProps={defaultProps}
19
+ durationInFrames={210}
20
+ fps={30}
21
+ width={1920}
22
+ height={1080}
23
+ calculateMetadata={async ({ props }) => prepareChangelog(props)}
24
+ />
25
+ <Composition
26
+ id="MotionReel"
27
+ component={MotionReel}
28
+ durationInFrames={180}
29
+ fps={30}
30
+ width={1920}
31
+ height={1080}
32
+ calculateMetadata={async (c) => {
33
+ await waitForFonts();
34
+ return c;
35
+ }}
36
+ />
37
+ <Composition
38
+ id="ContactSheet"
39
+ component={ContactSheet}
40
+ durationInFrames={1}
41
+ fps={30}
42
+ width={1760}
43
+ height={1400}
44
+ calculateMetadata={async (c) => {
45
+ await waitForFonts();
46
+ return c;
47
+ }}
48
+ />
49
+ </>
50
+ );
51
+ };
@@ -0,0 +1,72 @@
1
+ import { extractFeatures } from "./parse";
2
+ import type { UpdateManifest } from "./types";
3
+
4
+ export { extractFeatures, cleanBullet } from "./parse";
5
+ export type { UpdateManifest, Feature } from "./types";
6
+
7
+ const stripV = (v: string) => v.replace(/^v/i, "").trim();
8
+
9
+ /**
10
+ * Drop changesets boilerplate that isn't a user-facing change: the
11
+ * "Updated dependencies […]" bullet plus its indented child dep-bumps
12
+ * (and any multi-line bracket continuation). Real entries are untouched.
13
+ */
14
+ function stripChangesetNoise(md: string): string {
15
+ const lines = md.split("\n");
16
+ const out: string[] = [];
17
+ let depIndent = -1; // ≥0 while inside an "Updated dependencies" block
18
+ for (const line of lines) {
19
+ const bullet = /^(\s*)[-*]\s+(.*)$/.exec(line);
20
+ if (depIndent >= 0) {
21
+ if (line.trim() === "") {
22
+ depIndent = -1; // blank line closes the block
23
+ } else if ((line.match(/^\s*/)?.[0].length ?? 0) > depIndent) {
24
+ continue; // child dep-bump or bracket continuation — drop it
25
+ } else {
26
+ depIndent = -1; // back to sibling depth — fall through to keep this line
27
+ }
28
+ }
29
+ if (bullet && /^Updated dependencies\b/i.test(bullet[2])) {
30
+ depIndent = bullet[1].length;
31
+ continue;
32
+ }
33
+ out.push(line);
34
+ }
35
+ return out.join("\n");
36
+ }
37
+
38
+ /** Build a manifest from a GitHub release (notes + tag). */
39
+ export function manifestFromReleaseBody(opts: {
40
+ product: string;
41
+ version: string;
42
+ body: string;
43
+ date?: string;
44
+ install?: string;
45
+ repoUrl?: string;
46
+ max?: number;
47
+ }): UpdateManifest {
48
+ const { features, dropped } = extractFeatures(opts.body ?? "", opts.max ?? 6);
49
+ return {
50
+ product: opts.product,
51
+ version: stripV(opts.version),
52
+ date: opts.date,
53
+ features,
54
+ install: opts.install,
55
+ repoUrl: opts.repoUrl,
56
+ dropped,
57
+ };
58
+ }
59
+
60
+ /** Build a manifest from a CHANGELOG (Keep-a-Changelog / common markdown). */
61
+ export function manifestFromChangelogText(
62
+ text: string,
63
+ opts: { product: string; install?: string; repoUrl?: string; max?: number }
64
+ ): UpdateManifest {
65
+ const blocks = text.split(/^##\s+/m).slice(1);
66
+ const first = blocks[0] ?? text;
67
+ const head = first.split("\n")[0] ?? "";
68
+ const version = stripV((head.match(/\[?([0-9][\w.\-]*)\]?/) || [])[1] ?? "");
69
+ const date = (head.match(/\d{4}-\d{2}-\d{2}/) || [])[0];
70
+ const { features, dropped } = extractFeatures(stripChangesetNoise(first), opts.max ?? 6);
71
+ return { product: opts.product, version, date, features, install: opts.install, repoUrl: opts.repoUrl, dropped };
72
+ }
@@ -0,0 +1,65 @@
1
+ import type { Feature } from "./types";
2
+
3
+ /** Strip list markers, PR/commit refs, emoji, markdown links, and bold to plain text. */
4
+ export function cleanBullet(line: string): string {
5
+ let s = line.replace(/^\s*[-*]\s+/, "");
6
+ // changesets prefixes entries with the originating commit hash: "1a3a80d: …"
7
+ s = s.replace(/^[0-9a-f]{7,40}:\s+/i, "");
8
+ s = s.replace(/\*\*/g, "");
9
+ s = s.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1"); // [text](url) → text
10
+ // strip trailing "(#123)" / "(abc1234)" refs, possibly repeated
11
+ for (let i = 0; i < 4; i++) s = s.replace(/\s*\((#\d+|[0-9a-f]{7,40})\)\s*$/i, "");
12
+ s = s.replace(/^[\p{Extended_Pictographic}\s]+/u, ""); // leading emoji
13
+ return s.trim().replace(/[.:]+$/, "").trim();
14
+ }
15
+
16
+ const FEATURE_HEADING = /feature|^\s*#+\s*(added|new|improv|enhanc|perf|✨|⚡)/i;
17
+ const SKIP_HEADING = /chore|build|^\s*#+\s*ci|continuous|test|refactor|depend|revert|🏗|🧹|⚙|➰/i;
18
+ const FIX_HEADING = /fix|bug|🐞|🐛|🪲|🐜/i;
19
+
20
+ const KIND = (s: string): string | undefined => {
21
+ const m = s.match(/^(feat|fix|perf|docs|refactor|build|chore)\b/i);
22
+ return m ? m[1].toLowerCase() : undefined;
23
+ };
24
+
25
+ /** Is this bullet just a bold group label like "static-cost:" with no content? */
26
+ const isGroupLabel = (s: string) => s.length <= 24 && /[:]$/.test(s.replace(/\*\*/g, "").trim());
27
+
28
+ /**
29
+ * Pull demo-worthy features from release-note / changelog markdown. Prefers
30
+ * feature/added/perf sections; falls back to bug-fix or unsectioned bullets so a
31
+ * fix-only release still yields beats. Reports how many bullets were dropped.
32
+ */
33
+ export function extractFeatures(md: string, max = 6): { features: Feature[]; dropped: number } {
34
+ const lines = md.split("\n");
35
+ let bucket: "feature" | "fix" | "skip" | "none" = "none";
36
+ const feature: Feature[] = [];
37
+ const fix: Feature[] = [];
38
+ const other: Feature[] = [];
39
+
40
+ for (const raw of lines) {
41
+ if (/^\s*#{1,6}\s/.test(raw)) {
42
+ bucket = SKIP_HEADING.test(raw) ? "skip" : FEATURE_HEADING.test(raw) ? "feature" : FIX_HEADING.test(raw) ? "fix" : "none";
43
+ continue;
44
+ }
45
+ if (!/^\s*[-*]\s+/.test(raw)) continue;
46
+ if (isGroupLabel(raw)) continue;
47
+ const title = cleanBullet(raw);
48
+ if (title.length < 4) continue;
49
+ const f: Feature = { title, kind: KIND(title) };
50
+ if (bucket === "skip") continue;
51
+ if (bucket === "feature") feature.push(f);
52
+ else if (bucket === "fix") fix.push(f);
53
+ else other.push(f);
54
+ }
55
+
56
+ let chosen = feature.length ? feature : other.length ? other : fix;
57
+ // top up features with a couple fixes if the release is thin
58
+ if (chosen === feature && feature.length < 2) chosen = [...feature, ...fix];
59
+
60
+ const seen = new Set<string>();
61
+ const deduped = chosen.filter((f) => (seen.has(f.title) ? false : (seen.add(f.title), true)));
62
+ const total = feature.length + fix.length + other.length;
63
+ const features = deduped.slice(0, max);
64
+ return { features, dropped: Math.max(0, total - features.length) };
65
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Normalized "what changed" for a repo/release — the input the brain (or a
3
+ * deterministic template) turns into beats. Adapters parse the messy real world
4
+ * (release notes, CHANGELOGs, git tags) into this shape.
5
+ */
6
+ export type Feature = {
7
+ title: string;
8
+ /** Conventional-commit-ish kind if detectable: feat | fix | perf | docs | … */
9
+ kind?: string;
10
+ };
11
+
12
+ export type UpdateManifest = {
13
+ /** Package or repo name. */
14
+ product: string;
15
+ /** Latest version / tag, without a leading "v" if present. */
16
+ version: string;
17
+ date?: string;
18
+ features: Feature[];
19
+ /** Real install line, e.g. "npm i pkg", "brew install tool". */
20
+ install?: string;
21
+ repoUrl?: string;
22
+ /** Anything the parser dropped (kept transparent — never silently truncate). */
23
+ dropped?: number;
24
+ };
@@ -0,0 +1,74 @@
1
+ import { loadFont as Sora } from "@remotion/google-fonts/Sora";
2
+ import { loadFont as Inter } from "@remotion/google-fonts/Inter";
3
+ import { loadFont as PublicSans } from "@remotion/google-fonts/PublicSans";
4
+ import { loadFont as Manrope } from "@remotion/google-fonts/Manrope";
5
+ import { loadFont as SpaceGrotesk } from "@remotion/google-fonts/SpaceGrotesk";
6
+ import { loadFont as PlusJakartaSans } from "@remotion/google-fonts/PlusJakartaSans";
7
+ import { loadFont as Outfit } from "@remotion/google-fonts/Outfit";
8
+ import { loadFont as DMSans } from "@remotion/google-fonts/DMSans";
9
+ import { loadFont as Figtree } from "@remotion/google-fonts/Figtree";
10
+ import { loadFont as Epilogue } from "@remotion/google-fonts/Epilogue";
11
+ import { loadFont as Archivo } from "@remotion/google-fonts/Archivo";
12
+ import { loadFont as WorkSans } from "@remotion/google-fonts/WorkSans";
13
+ import { loadFont as Geist } from "@remotion/google-fonts/Geist";
14
+ import { loadFont as PlayfairDisplay } from "@remotion/google-fonts/PlayfairDisplay";
15
+ import { loadFont as Spectral } from "@remotion/google-fonts/Spectral";
16
+ import { loadFont as Fraunces } from "@remotion/google-fonts/Fraunces";
17
+ import { loadFont as FiraCode } from "@remotion/google-fonts/FiraCode";
18
+ import { loadFont as JetBrainsMono } from "@remotion/google-fonts/JetBrainsMono";
19
+ import { loadFont as IBMPlexMono } from "@remotion/google-fonts/IBMPlexMono";
20
+ import { loadFont as SpaceMono } from "@remotion/google-fonts/SpaceMono";
21
+ import { loadFont as GeistMono } from "@remotion/google-fonts/GeistMono";
22
+ import { loadFont as Caveat } from "@remotion/google-fonts/Caveat";
23
+ import { activeTheme } from "../theme";
24
+
25
+ // A theme declares fonts as CSS stacks ("Sora, ui-sans-serif, …"); the first
26
+ // token is the family we load via @remotion/google-fonts. Importing a loader is
27
+ // cheap (just binds a function) — only the active theme's families are actually
28
+ // fetched, so a 22-family registry costs nothing per render beyond the ~4 in use.
29
+ // Loosely typed: each @remotion/google-fonts loader has a font-specific `style`
30
+ // union, so we erase the param types to register them in one map.
31
+ // biome-ignore lint/suspicious/noExplicitAny: heterogeneous loader signatures
32
+ type GoogleLoader = (...args: any[]) => { waitUntilDone: () => Promise<void> };
33
+
34
+ const LOADERS: Record<string, GoogleLoader> = {
35
+ Sora,
36
+ Inter,
37
+ "Public Sans": PublicSans,
38
+ Manrope,
39
+ "Space Grotesk": SpaceGrotesk,
40
+ "Plus Jakarta Sans": PlusJakartaSans,
41
+ Outfit,
42
+ "DM Sans": DMSans,
43
+ Figtree,
44
+ Epilogue,
45
+ Archivo,
46
+ "Work Sans": WorkSans,
47
+ Geist,
48
+ "Playfair Display": PlayfairDisplay,
49
+ Spectral,
50
+ Fraunces,
51
+ "Fira Code": FiraCode,
52
+ "JetBrains Mono": JetBrainsMono,
53
+ "IBM Plex Mono": IBMPlexMono,
54
+ "Space Mono": SpaceMono,
55
+ "Geist Mono": GeistMono,
56
+ Caveat,
57
+ };
58
+
59
+ /** First family of a CSS stack, e.g. "Sora, ui-sans-serif" → "Sora". */
60
+ const primary = (stack: string) => stack.split(",")[0].trim();
61
+
62
+ const families = new Set(
63
+ [activeTheme.fonts.display, activeTheme.fonts.body, activeTheme.fonts.mono, activeTheme.fonts.note].map(primary),
64
+ );
65
+ const loaded = [...families]
66
+ .map((f) => LOADERS[f])
67
+ .filter(Boolean)
68
+ .map((load) => load("normal", { subsets: ["latin"] }));
69
+
70
+ /** Active font stacks (from the theme). */
71
+ export const FONTS = activeTheme.fonts;
72
+
73
+ /** Await before a still / Lambda render — fonts load async; capture real faces. */
74
+ export const waitForFonts = () => Promise.all(loaded.map((f) => f.waitUntilDone()));
@@ -0,0 +1,31 @@
1
+ import { Easing } from "remotion";
2
+ import { activeTheme } from "../theme";
3
+
4
+ /**
5
+ * Engine tokens. Colors + shadow come from the active theme (swappable per render
6
+ * via `--theme`); the easing curves and scale are engine constants.
7
+ */
8
+ export const COLORS = activeTheme.colors;
9
+
10
+ export const CARET_BG = activeTheme.caretBg;
11
+
12
+ /** Light [from, to] gradient for the procedural default backdrop ("shapes"). */
13
+ export const BACKDROP: [string, string] = activeTheme.backdrop ?? ["#e6edff", "#f8faff"];
14
+
15
+ /**
16
+ * Two easings:
17
+ * - `smooth`: pure ease-out — the ENTRANCE curve (no overshoot).
18
+ * - `snappy`: a deliberate slight settle/overshoot — small UI state pops only.
19
+ */
20
+ export const EASE = {
21
+ smooth: Easing.bezier(0.19, 1, 0.22, 1),
22
+ snappy: Easing.bezier(0.175, 0.885, 0.32, 1.1),
23
+ } as const;
24
+
25
+ /** No-overshoot spring for entrances (damping high so it settles, never bounces). */
26
+ export const SPRING_ENTER = { damping: 200, mass: 1, stiffness: 100 } as const;
27
+
28
+ export const RADIUS = { sm: 3, md: 6, lg: 8, xl: 10, full: 999 } as const;
29
+
30
+ /** Shadow reserved for floating, dismissible chrome (windows/panels over the art). */
31
+ export const FLOAT_SHADOW = activeTheme.floatShadow;
@@ -0,0 +1,25 @@
1
+ import type { Beat } from "../schema/beats";
2
+
3
+ /** Relative luminance (0-1) of a hex color; non-hex → 0. */
4
+ const lum = (hex: string): number => {
5
+ let h = hex.replace("#", "").trim();
6
+ if (h.length === 3) h = h.split("").map((c) => c + c).join("");
7
+ if (h.length < 6) return 0;
8
+ const r = parseInt(h.slice(0, 2), 16) / 255;
9
+ const g = parseInt(h.slice(2, 4), 16) / 255;
10
+ const b = parseInt(h.slice(4, 6), 16) / 255;
11
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
12
+ };
13
+
14
+ /**
15
+ * Is the beat's backdrop light enough that overlaid text should be dark? The
16
+ * procedural default (`shapes` / no background) is always light; gradients/solids
17
+ * are judged by luminance; the image pack is assumed dark (keep the light-text +
18
+ * shadow treatment the paintings were designed for).
19
+ */
20
+ export function isLightBackdrop(bg: Beat["background"]): boolean {
21
+ if (!bg || bg.shapes) return true;
22
+ if (bg.solid) return lum(bg.solid) > 0.6;
23
+ if (bg.gradient) return (lum(bg.gradient[0]) + lum(bg.gradient[1])) / 2 > 0.55;
24
+ return false;
25
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Code → themed tokens, run once per beat inside `calculateMetadata` (async, off
3
+ * the render hot path). Primary path is shiki with a brand light theme; if shiki
4
+ * fails to load (e.g. WASM under the bundler), a regex fallback keeps renders
5
+ * working. Output is plain JSON (lines of {content,color}) so it serializes into
6
+ * Remotion input props.
7
+ */
8
+ import type { Highlighter } from "shiki";
9
+
10
+ export type CodeToken = { content: string; color: string };
11
+ export type CodeLine = CodeToken[];
12
+
13
+ export type Lang = "ts" | "tsx" | "bash" | "json";
14
+
15
+ import { activeTheme } from "../theme";
16
+
17
+ const C = activeTheme.codeTheme;
18
+
19
+ const FIELD_NOTEBOOK_LIGHT = {
20
+ name: "field-notebook-light",
21
+ type: "light",
22
+ colors: { "editor.background": activeTheme.codeBg, "editor.foreground": C.fg },
23
+ settings: [
24
+ { settings: { foreground: C.fg } },
25
+ { scope: ["keyword", "keyword.control", "storage", "storage.type", "storage.modifier"], settings: { foreground: C.kw } },
26
+ { scope: ["keyword.operator.new", "keyword.operator.expression.new"], settings: { foreground: C.nw } },
27
+ { scope: ["string", "string.quoted", "string.template", "constant.character"], settings: { foreground: C.str } },
28
+ { scope: ["constant.numeric", "constant.language"], settings: { foreground: C.num } },
29
+ { scope: ["entity.name.function", "support.function", "meta.function-call.identifier", "entity.name.type", "entity.name.class", "support.class"], settings: { foreground: C.fn } },
30
+ { scope: ["variable", "support.variable", "meta.object-literal.key", "variable.other.property"], settings: { foreground: C.punct } },
31
+ { scope: ["comment", "punctuation.definition.comment"], settings: { foreground: C.comment, fontStyle: "italic" } },
32
+ { scope: ["punctuation", "meta.brace", "keyword.operator"], settings: { foreground: C.punct } },
33
+ ],
34
+ } as const;
35
+
36
+ const SHIKI_LANG: Record<Lang, string> = { ts: "typescript", tsx: "tsx", bash: "bash", json: "json" };
37
+
38
+ let highlighterPromise: Promise<Highlighter> | null = null;
39
+ const getHighlighter = async () => {
40
+ if (!highlighterPromise) {
41
+ const { createHighlighter } = await import("shiki");
42
+ highlighterPromise = createHighlighter({
43
+ themes: [FIELD_NOTEBOOK_LIGHT as never],
44
+ langs: ["typescript", "tsx", "bash", "json"],
45
+ });
46
+ }
47
+ return highlighterPromise;
48
+ };
49
+
50
+ export async function tokenize(source: string, lang: Lang): Promise<CodeLine[]> {
51
+ try {
52
+ const hl = await getHighlighter();
53
+ const { tokens } = hl.codeToTokens(source, { lang: SHIKI_LANG[lang] as never, theme: "field-notebook-light" });
54
+ return tokens.map((line) => line.map((t) => ({ content: t.content, color: t.color ?? C.fg })));
55
+ } catch {
56
+ return fallbackTokenize(source, lang);
57
+ }
58
+ }
59
+
60
+ // ─── Regex fallback (resilience only; shiki is the real path) ─────────────────
61
+
62
+ const TS_KEYWORDS = /\b(import|from|export|const|let|var|new|await|async|return|function|type|interface|for|of|if|else|class|extends)\b/;
63
+ const fallbackTokenize = (source: string, lang: Lang): CodeLine[] =>
64
+ source.split("\n").map((line) => {
65
+ if (lang === "bash") {
66
+ const m = line.match(/^(\s*\$?\s*)(\S+)?(.*)$/);
67
+ if (!m) return [{ content: line, color: C.fg }];
68
+ return [
69
+ { content: m[1], color: C.punct },
70
+ { content: m[2] ?? "", color: C.fn },
71
+ { content: m[3] ?? "", color: C.fg },
72
+ ].filter((t) => t.content);
73
+ }
74
+ // crude TS: split on words/strings/numbers, color each chunk
75
+ const out: CodeToken[] = [];
76
+ const re = /("[^"]*"|'[^']*'|`[^`]*`|\b\d+(\.\d+)?\b|[A-Za-z_$][\w$]*|\s+|[^\w\s])/g;
77
+ for (const [tok] of line.matchAll(re)) {
78
+ let color: string = C.punct;
79
+ if (/^["'`]/.test(tok)) color = C.str;
80
+ else if (/^\d/.test(tok)) color = C.num;
81
+ else if (TS_KEYWORDS.test(tok)) color = C.kw;
82
+ else if (/^[A-Za-z_$]/.test(tok)) color = C.fg;
83
+ else if (/^\s+$/.test(tok)) color = C.fg;
84
+ out.push({ content: tok, color });
85
+ }
86
+ return out.length ? out : [{ content: "", color: C.fg }];
87
+ });
88
+
89
+ /** Code window background, from the active theme. */
90
+ export const CODE_BG = activeTheme.codeBg;
@@ -0,0 +1,75 @@
1
+ import { AbsoluteFill, Img, interpolate, staticFile, useCurrentFrame, useVideoConfig } from "remotion";
2
+ import { BACKDROP, COLORS } from "../brand/tokens";
3
+ import type { Beat } from "../schema/beats";
4
+
5
+ type BG = Beat["background"];
6
+
7
+ /** hex (#abc | #aabbcc) → rgba string with alpha. */
8
+ const hexA = (hex: string, a: number) => {
9
+ let h = hex.replace("#", "");
10
+ if (h.length === 3) h = h.split("").map((c) => c + c).join("");
11
+ return `rgba(${parseInt(h.slice(0, 2), 16)},${parseInt(h.slice(2, 4), 16)},${parseInt(h.slice(4, 6), 16)},${a})`;
12
+ };
13
+
14
+ /**
15
+ * Backdrop style packs. The default (no `background`, or `shapes`) is a procedural,
16
+ * theme-colored field of soft curved arcs — no asset, no API key. Other packs: an
17
+ * `image` (optional painterly pack, Ken Burns + haze), an AI-free `gradient`, or a
18
+ * `solid`. All add a soft vignette + slow drift so they're never flat.
19
+ */
20
+ export const Background: React.FC<{ bg?: BG }> = ({ bg }) => {
21
+ const frame = useCurrentFrame();
22
+ const { durationInFrames } = useVideoConfig();
23
+ const opacity = interpolate(frame, [0, 18], [0, 1], { extrapolateRight: "clamp" });
24
+
25
+ // Default backdrop: procedural soft arcs tinted from the active theme.
26
+ if (!bg || bg.shapes) {
27
+ const drift = interpolate(frame, [0, durationInFrames], [0, 1], { extrapolateRight: "clamp" });
28
+ const a = COLORS.signalBlue;
29
+ // A soft arc band centered off-canvas: transparent core → faint accent ring → fade.
30
+ const arc = (cx: number, cy: number, r: number, alpha: number) =>
31
+ `radial-gradient(circle at ${cx}% ${cy}%, transparent ${r}%, ${hexA(a, alpha)} ${r + 1}%, ${hexA(a, alpha * 0.35)} ${r + 9}%, transparent ${r + 18}%)`;
32
+ return (
33
+ <AbsoluteFill style={{ opacity }}>
34
+ <AbsoluteFill style={{ background: `linear-gradient(160deg, ${BACKDROP[0]} 0%, ${BACKDROP[1]} 100%)` }} />
35
+ <AbsoluteFill
36
+ style={{
37
+ background: [arc(114, 94, 40, 0.32), arc(90, 24, 32, 0.22), arc(-8, 110, 30, 0.18)].join(","),
38
+ transform: `translateY(${interpolate(drift, [0, 1], [0, -14])}px) scale(${interpolate(drift, [0, 1], [1, 1.05])})`,
39
+ }}
40
+ />
41
+ {/* gentle top wash so headlines stay legible */}
42
+ <AbsoluteFill style={{ background: "radial-gradient(120% 80% at 50% 14%, rgba(255,255,255,0.42) 0%, rgba(255,255,255,0) 55%)" }} />
43
+ </AbsoluteFill>
44
+ );
45
+ }
46
+
47
+ if (bg.solid || bg.gradient) {
48
+ const drift = interpolate(frame, [0, durationInFrames], [0, 1.04], { extrapolateRight: "clamp" });
49
+ const fill = bg.solid
50
+ ? bg.solid
51
+ : `linear-gradient(${bg.angle}deg, ${bg.gradient![0]} 0%, ${bg.gradient![1]} 100%)`;
52
+ return (
53
+ <AbsoluteFill style={{ opacity }}>
54
+ <AbsoluteFill style={{ background: fill, transform: `scale(${drift})` }} />
55
+ {/* soft vignette so panels read + the fill has depth */}
56
+ <AbsoluteFill style={{ background: "radial-gradient(120% 100% at 50% 30%, rgba(255,255,255,0.10), rgba(0,0,0,0.10) 100%)" }} />
57
+ </AbsoluteFill>
58
+ );
59
+ }
60
+
61
+ const kb = bg.treatment === "kenburns";
62
+ const scale = kb ? interpolate(frame, [0, durationInFrames], [1, 1.08], { extrapolateRight: "clamp" }) : 1;
63
+ const translateY = kb ? interpolate(frame, [0, durationInFrames], [-12, 0], { extrapolateRight: "clamp" }) : 0;
64
+
65
+ return (
66
+ <AbsoluteFill style={{ backgroundColor: COLORS.chrome, opacity }}>
67
+ <Img
68
+ src={staticFile(bg.src!)}
69
+ style={{ width: "100%", height: "100%", objectFit: "cover", transform: `scale(${scale}) translateY(${translateY}px)` }}
70
+ />
71
+ <AbsoluteFill style={{ background: "radial-gradient(120% 90% at 50% 16%, rgba(250,248,242,0.58) 0%, rgba(250,248,242,0.18) 40%, rgba(230,235,240,0) 72%)" }} />
72
+ <AbsoluteFill style={{ background: "linear-gradient(180deg, rgba(248,246,240,0.35) 0%, rgba(248,246,240,0) 22%, rgba(40,55,70,0.10) 100%)" }} />
73
+ </AbsoluteFill>
74
+ );
75
+ };
@@ -0,0 +1,17 @@
1
+ import { Series } from "remotion";
2
+ import type { ChangelogVideo } from "../schema/beats";
3
+ import { ChangelogScene } from "./ChangelogScene";
4
+
5
+ /** Top-level composition: a Series of beats. Duration/dimensions/tokens are set
6
+ * by `calculateMetadata` in Root before this renders. */
7
+ export const Changelog: React.FC<ChangelogVideo> = ({ format, beats }) => {
8
+ 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>
16
+ );
17
+ };