@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.
- package/LICENSE +21 -0
- package/MOTION.md +70 -0
- package/README.md +109 -0
- package/bin/cli.mjs +21 -0
- package/package.json +59 -0
- package/prompts/art/compose.ts +18 -0
- package/prompts/art/fantasy.ts +15 -0
- package/prompts/art/landmarks.ts +49 -0
- package/prompts/art/style.ts +20 -0
- package/public/backgrounds/barton-springs.png +0 -0
- package/public/backgrounds/capitol.png +0 -0
- package/public/backgrounds/congress.png +0 -0
- package/public/backgrounds/enchanted-rock.png +0 -0
- package/public/backgrounds/hamilton-pool.png +0 -0
- package/public/backgrounds/mount-bonnell.png +0 -0
- package/public/backgrounds/pennybacker.png +0 -0
- package/public/backgrounds/ut-tower.png +0 -0
- package/scripts/_pkg.ts +28 -0
- package/scripts/audit.ts +69 -0
- package/scripts/changes.ts +70 -0
- package/scripts/check-motion.ts +37 -0
- package/scripts/cli.ts +79 -0
- package/scripts/generate-art.ts +72 -0
- package/scripts/guide.ts +114 -0
- package/scripts/make.ts +76 -0
- package/scripts/redesign.ts +83 -0
- package/scripts/render.ts +62 -0
- package/scripts/smoke.ts +43 -0
- package/scripts/sync-skill.ts +43 -0
- package/scripts/theme.ts +97 -0
- package/src/Root.tsx +51 -0
- package/src/adapters/index.ts +72 -0
- package/src/adapters/parse.ts +65 -0
- package/src/adapters/types.ts +24 -0
- package/src/brand/fonts.ts +74 -0
- package/src/brand/tokens.ts +31 -0
- package/src/brand/tone.ts +25 -0
- package/src/code/highlight.ts +90 -0
- package/src/components/Background.tsx +75 -0
- package/src/components/Changelog.tsx +17 -0
- package/src/components/ChangelogScene.tsx +117 -0
- package/src/components/CodeWindow.tsx +106 -0
- package/src/components/ContactSheet.tsx +44 -0
- package/src/components/Headline.tsx +73 -0
- package/src/components/MotionReel.tsx +108 -0
- package/src/components/panels/DataTable.tsx +34 -0
- package/src/components/panels/Diagram.tsx +67 -0
- package/src/components/panels/Feed.tsx +46 -0
- package/src/components/panels/Fork.tsx +86 -0
- package/src/components/panels/PanelCard.tsx +44 -0
- package/src/components/panels/Proof.tsx +64 -0
- package/src/components/panels/Stat.tsx +28 -0
- package/src/components/panels/Status.tsx +41 -0
- package/src/components/panels/StreamResume.tsx +48 -0
- package/src/components/panels/UploadProgress.tsx +42 -0
- package/src/components/panels/index.tsx +34 -0
- package/src/content/clarinet-3.18.beats.ts +58 -0
- package/src/content/gradient-demo.beats.ts +29 -0
- package/src/content/index-decoded.beats.ts +93 -0
- package/src/content/mainnet-launch.beats.ts +70 -0
- package/src/content/panels.beats.ts +40 -0
- package/src/content/sdk-6.5-mempool.beats.ts +67 -0
- package/src/content/streams-launch.beats.ts +143 -0
- package/src/content/streams.beats.ts +46 -0
- package/src/index.ts +4 -0
- package/src/motion/names.ts +29 -0
- package/src/motion/presets.ts +67 -0
- package/src/motion/useMotion.ts +63 -0
- package/src/prepare.ts +33 -0
- package/src/schema/beats.ts +161 -0
- package/src/templates/changelog-reel.ts +28 -0
- package/src/templates/feature-launch.ts +26 -0
- package/src/templates/index.ts +13 -0
- package/src/templates/milestone.ts +25 -0
- package/src/templates/parts.ts +32 -0
- package/src/templates/types.ts +31 -0
- package/src/theme/default.ts +39 -0
- package/src/theme/derive.ts +119 -0
- package/src/theme/index.ts +33 -0
- package/src/theme/library.ts +60 -0
- package/src/theme/slate.ts +39 -0
- package/src/theme/types.ts +67 -0
package/scripts/theme.ts
ADDED
|
@@ -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
|
+
};
|