@waits/cadence 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -104,13 +104,19 @@ Layout: `src/schema` (the beats contract), `src/components` (scene + panels),
104
104
  `src/motion` (the lexicon), `src/theme` (themes + derivation), `src/templates`
105
105
  (the no-LLM repo→beats path), `prompts/art` (the optional painterly pack).
106
106
 
107
- ## Optional: painterly backgrounds
107
+ ## Optional: generated backgrounds
108
108
 
109
- The default backdrop is procedural and needs no key. For the optional
110
- "Hill Country Sublime" landscape pack (see **[ART-DIRECTION.md](ART-DIRECTION.md)**):
109
+ The default backdrop is procedural and needs no key. For custom painted
110
+ backdrops, `cadence art` generates them from a freeform prompt — landing in your
111
+ project's `.cadence/backgrounds/`, ready to reference as `image:<file>`:
111
112
 
112
113
  ```bash
113
- cadence art --landmark pennybacker --level heightened # needs OPENAI_API_KEY
114
+ cadence art --prompt "misty redwood coastline at dawn" --name redwood # needs OPENAI_API_KEY
115
+ cadence art --promote redwood # candidate → backgrounds/
114
116
  ```
115
117
 
116
- This is the only part of the toolchain that calls an external API.
118
+ Reusable subject sets go in a `--pack <file.json>`; the bundled "Hill Country
119
+ Sublime" landscape pack is the default (`cadence art --all`). This is the only
120
+ part of the toolchain that calls an external API. See
121
+ **[docs/guides/custom-backgrounds.md](docs/guides/custom-backgrounds.md)** and
122
+ **[ART-DIRECTION.md](ART-DIRECTION.md)**.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@waits/cadence",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -3,14 +3,24 @@ import { COMPOSITION, NEGATIVES, STYLE } from "./style";
3
3
  import { LANDMARKS, type LandmarkKey } from "./landmarks";
4
4
  import { FANTASY, type FantasyLevel } from "./fantasy";
5
5
 
6
- /** Build the full image-gen prompt for a landmark × fantasy level × format. */
7
- export function composePrompt(landmark: LandmarkKey, level: FantasyLevel, format: Format): string {
6
+ /**
7
+ * Build the full image-gen prompt for a freeform subject × fantasy level × format.
8
+ * `style`/`negatives` default to the built-in luminist look; a pack or `--style-file`
9
+ * can override them. `COMPOSITION` (UI-safe framing) is always engine-supplied.
10
+ */
11
+ export function composePrompt(opts: {
12
+ subject: string;
13
+ level: FantasyLevel;
14
+ format: Format;
15
+ style?: string;
16
+ negatives?: string;
17
+ }): string {
8
18
  return [
9
- STYLE,
10
- `Subject: ${LANDMARKS[landmark].subject}.`,
11
- FANTASY[level],
12
- COMPOSITION[format],
13
- NEGATIVES,
19
+ opts.style ?? STYLE,
20
+ `Subject: ${opts.subject}.`,
21
+ FANTASY[opts.level],
22
+ COMPOSITION[opts.format],
23
+ opts.negatives ?? NEGATIVES,
14
24
  ].join(" ");
15
25
  }
16
26
 
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Stage a merged public dir for a project render. Remotion's `--public-dir` is a
3
+ * single replacement dir (not an overlay), and the engine's own `public/` holds
4
+ * assets shipped beats reference (`mountains.jpg`, built-in `backgrounds/*.png`).
5
+ * So when a project supplies its own art in `<project>/.cadence/backgrounds/`, we
6
+ * merge the engine's `public/*` with the project's `backgrounds/` into a temp dir
7
+ * and point `--public-dir` there — both built-in and project images resolve.
8
+ *
9
+ * `_candidates/` is excluded (the unpromoted generation pile — not for rendering).
10
+ */
11
+ import { cpSync, existsSync, mkdirSync, rmSync } from "node:fs";
12
+ import { dirname, join, resolve } from "node:path";
13
+ import { pkgFile } from "./_pkg";
14
+ import { findCadenceDir } from "./_theme";
15
+
16
+ const noCandidates = (src: string) => !src.includes(`${join("backgrounds", "_candidates")}`);
17
+
18
+ /** Returns an absolute staged public dir, or undefined when there's no project art. */
19
+ export function stagePublicDir(opts: { beatsFile?: string; outDir: string }): string | undefined {
20
+ const start = opts.beatsFile ? dirname(resolve(opts.beatsFile)) : process.cwd();
21
+ const cad = findCadenceDir(start);
22
+ const projBg = cad ? join(cad, "backgrounds") : undefined;
23
+ if (!projBg || !existsSync(projBg)) return undefined;
24
+
25
+ const staged = resolve(opts.outDir, ".pub");
26
+ rmSync(staged, { recursive: true, force: true });
27
+ mkdirSync(staged, { recursive: true });
28
+ cpSync(pkgFile("public"), staged, { recursive: true, filter: noCandidates });
29
+ cpSync(projBg, join(staged, "backgrounds"), { recursive: true, filter: noCandidates });
30
+ return staged;
31
+ }
@@ -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/cli.ts CHANGED
@@ -47,7 +47,8 @@ utilities:
47
47
  templates list available templates
48
48
  themes list available themes
49
49
 
50
- 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)
51
52
  preview before rendering: cadence storyboard <beats> · cadence create … --dry-run (plan + one still per beat, no MP4)`;
52
53
 
53
54
  if (!sub || sub === "help" || sub === "--help") {
@@ -1,21 +1,26 @@
1
1
  /**
2
- * "Hill Country Sublime" painting generator. Builds a prompt from the art system
3
- * (prompts/art/*) and calls OpenAI gpt-image-1 (base64 response → PNG). Writes to
4
- * public/backgrounds/_candidates/ and updates a manifest the ContactSheet reads.
2
+ * Background painting generator. Builds a prompt from the art system (prompts/art/*)
3
+ * and calls OpenAI gpt-image-1 (base64 → PNG), then updates a manifest the
4
+ * ContactSheet reads. Freeform `--prompt` generates anything; with no prompt it
5
+ * pulls subjects from the built-in pack (the "Hill Country Sublime" landmarks).
5
6
  *
6
- * bun run art --landmark pennybacker --level heightened [--format 16x9] [--quality high]
7
- * bun run art --all --level heightened --format 16x9 # every landmark
7
+ * Output is project-local: run against a repo with a `.cadence/` dir and art lands
8
+ * in `<project>/.cadence/backgrounds/` (candidates under `_candidates/`); otherwise
9
+ * the cadence engine's own `public/backgrounds/`.
8
10
  *
9
- * Requires OPENAI_API_KEY. Chosen winners get moved to public/backgrounds/ (committed).
11
+ * bun run art --prompt "misty redwood coastline at dawn" --name redwood
12
+ * bun run art --all --level heightened --format 16x9 # every built-in subject
13
+ * bun run art --promote redwood # candidate(s) → backgrounds/
14
+ *
15
+ * Requires OPENAI_API_KEY (except --promote, which only moves files).
10
16
  */
11
- import { mkdirSync, readFileSync, writeFileSync, existsSync } from "node:fs";
17
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
12
18
  import { join } from "node:path";
13
- import { composePrompt, LANDMARKS, type FantasyLevel, type LandmarkKey } from "../prompts/art/compose";
19
+ import { composePrompt, LANDMARKS, type FantasyLevel } from "../prompts/art/compose";
14
20
  import type { Format } from "../src/schema/beats";
21
+ import { findCadenceDir } from "./_theme";
15
22
 
16
23
  const SIZE: Record<Format, string> = { "16x9": "1536x1024", "1x1": "1024x1024", "9x16": "1024x1536" };
17
- const CANDIDATES = "public/backgrounds/_candidates";
18
- const MANIFEST = join(CANDIDATES, "manifest.json");
19
24
 
20
25
  const args = process.argv.slice(2);
21
26
  const flag = (name: string, def?: string) => {
@@ -25,26 +30,95 @@ const flag = (name: string, def?: string) => {
25
30
  return i >= 0 && !args[i + 1]?.startsWith("--") ? args[i + 1] : def;
26
31
  };
27
32
  const has = (name: string) => args.includes(name);
33
+ const slugify = (s: string) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
34
+
35
+ // T4: project-local art base. A project's `.cadence/backgrounds/` if present
36
+ // (discovered from cwd), else the engine's committed `public/backgrounds/`.
37
+ const cad = findCadenceDir(process.cwd());
38
+ const ART_BASE = cad ? join(cad, "backgrounds") : "public/backgrounds";
39
+ const CANDIDATES = join(ART_BASE, "_candidates");
40
+ const MANIFEST = join(CANDIDATES, "manifest.json");
41
+
42
+ const level = flag("--level", "heightened") as FantasyLevel;
43
+ const format = flag("--format", "16x9") as Format;
44
+ const quality = flag("--quality", "medium")!;
45
+
46
+ type Entry = { file: string; subject: string; level: string; format: string; name: string };
47
+ mkdirSync(CANDIDATES, { recursive: true });
48
+ const manifest: Entry[] = existsSync(MANIFEST) ? JSON.parse(readFileSync(MANIFEST, "utf8")) : [];
49
+
50
+ // T6: promote — move a chosen candidate (by slug or filename) to backgrounds/,
51
+ // and drop it from the candidates manifest (its path would otherwise go stale).
52
+ const promote = flag("--promote");
53
+ if (promote) {
54
+ const key = slugify(promote);
55
+ const matches = manifest.filter((m) => m.name === promote || m.name.startsWith(`${key}-`));
56
+ if (!matches.length) {
57
+ console.error(`✗ no candidate matching "${promote}" in ${CANDIDATES}`);
58
+ process.exit(1);
59
+ }
60
+ mkdirSync(ART_BASE, { recursive: true });
61
+ for (const m of matches) {
62
+ const from = join(CANDIDATES, m.name);
63
+ if (existsSync(from)) {
64
+ renameSync(from, join(ART_BASE, m.name));
65
+ console.log(`✓ promoted ${m.name} → ${ART_BASE}/ (reference as image:${m.name})`);
66
+ }
67
+ }
68
+ const promoted = new Set(matches.map((m) => m.name));
69
+ writeFileSync(MANIFEST, JSON.stringify(manifest.filter((m) => !promoted.has(m.name)), null, 2));
70
+ process.exit(0);
71
+ }
72
+
73
+ // Subject source: a user-supplied pack (`--pack file.json`) or the built-in
74
+ // landmark pack. A pack may also override the style/negatives (else engine defaults).
75
+ type Pack = { style?: string; negatives?: string; subjects: Record<string, { name: string; subject: string }> };
76
+ const packFile = flag("--pack");
77
+ const pack: Pack = packFile ? JSON.parse(readFileSync(packFile, "utf8")) : { subjects: LANDMARKS };
78
+ const styleFile = flag("--style-file");
79
+ const style = styleFile ? readFileSync(styleFile, "utf8") : pack.style;
80
+ const negatives = pack.negatives;
28
81
 
29
- const level = (flag("--level", "heightened") as FantasyLevel);
30
- const format = (flag("--format", "16x9") as Format);
31
- const quality = flag("--quality", "high")!;
32
- const landmarks: LandmarkKey[] = has("--all")
33
- ? (Object.keys(LANDMARKS) as LandmarkKey[])
34
- : [(flag("--landmark", "pennybacker") as LandmarkKey)];
82
+ // A job = one image to generate. Freeform `--prompt` (needs `--name`) wins;
83
+ // otherwise pull subjects from the pack via --landmark/--all.
84
+ type Job = { subject: string; slug: string; label: string };
85
+ const promptText = flag("--prompt");
86
+ let jobs: Job[];
87
+ if (promptText) {
88
+ const name = flag("--name");
89
+ if (!name) {
90
+ console.error("✗ --prompt requires --name <slug> (used for the filename + manifest label)");
91
+ process.exit(1);
92
+ }
93
+ jobs = [{ subject: promptText, slug: slugify(name), label: name }];
94
+ } else {
95
+ const keys = has("--all") ? Object.keys(pack.subjects) : [flag("--landmark", Object.keys(pack.subjects)[0])!];
96
+ jobs = keys.map((k) => {
97
+ const s = pack.subjects[k];
98
+ if (!s) {
99
+ console.error(`✗ "${k}" not in pack (have: ${Object.keys(pack.subjects).join(", ")})`);
100
+ process.exit(1);
101
+ }
102
+ return { subject: s.subject, slug: k, label: s.name };
103
+ });
104
+ }
105
+
106
+ // Cost guard: batch runs cost real money — require explicit --yes after an estimate.
107
+ const COST: Record<string, number> = { low: 0.02, medium: 0.06, high: 0.19 }; // ~USD/image, approx
108
+ if (jobs.length > 1 && !has("--yes")) {
109
+ const est = (COST[quality] ?? 0.06) * jobs.length;
110
+ console.error(`⚠ ${jobs.length} images at quality "${quality}" ≈ $${est.toFixed(2)} (approx). Re-run with --yes to proceed.`);
111
+ process.exit(1);
112
+ }
35
113
 
36
114
  if (!process.env.OPENAI_API_KEY) {
37
115
  console.error("✗ OPENAI_API_KEY not set");
38
116
  process.exit(1);
39
117
  }
40
118
 
41
- mkdirSync(CANDIDATES, { recursive: true });
42
- type Entry = { file: string; landmark: string; level: string; format: string; name: string };
43
- const manifest: Entry[] = existsSync(MANIFEST) ? JSON.parse(readFileSync(MANIFEST, "utf8")) : [];
44
-
45
- async function generate(landmark: LandmarkKey) {
46
- const prompt = composePrompt(landmark, level, format);
47
- const fname = `${landmark}-${level}-${format}.png`;
119
+ async function generate(job: Job) {
120
+ const prompt = composePrompt({ subject: job.subject, level, format, style, negatives });
121
+ const fname = `${job.slug}-${level}-${format}.png`;
48
122
  process.stdout.write(`· ${fname} … `);
49
123
  const res = await fetch("https://api.openai.com/v1/images/generations", {
50
124
  method: "POST",
@@ -59,14 +133,15 @@ async function generate(landmark: LandmarkKey) {
59
133
  const json = await res.json();
60
134
  const b64 = json.data[0].b64_json as string;
61
135
  writeFileSync(join(CANDIDATES, fname), Buffer.from(b64, "base64"));
62
- const entry: Entry = { file: `backgrounds/_candidates/${fname}`, landmark: LANDMARKS[landmark].name, level, format, name: fname };
136
+ const entry: Entry = { file: `backgrounds/_candidates/${fname}`, subject: job.label, level, format, name: fname };
63
137
  const idx = manifest.findIndex((m) => m.name === fname);
64
- if (idx >= 0) manifest[idx] = entry; else manifest.push(entry);
138
+ if (idx >= 0) manifest[idx] = entry;
139
+ else manifest.push(entry);
65
140
  console.log("ok");
66
141
  }
67
142
 
68
- for (const lm of landmarks) {
69
- await generate(lm);
143
+ for (const job of jobs) {
144
+ await generate(job);
70
145
  }
71
146
  writeFileSync(MANIFEST, JSON.stringify(manifest, null, 2));
72
- console.log(`\n✓ ${landmarks.length} image(s) → ${CANDIDATES}/ (manifest: ${manifest.length} total)`);
147
+ console.log(`\n✓ ${jobs.length} image(s) → ${CANDIDATES}/ (manifest: ${manifest.length} total)`);
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) => {
@@ -68,18 +69,21 @@ const beats = template(manifest, {
68
69
  console.error(`· ${manifest.product} ${manifest.version}: ${manifest.features.length} features${manifest.dropped ? ` (+${manifest.dropped} dropped)` : ""} → ${templateName}`);
69
70
 
70
71
  // 3. Write beats JSON, then render — or, with --dry-run, storyboard it (preview
71
- // sheet + plan, no MP4).
72
- mkdirSync("out", { recursive: true });
73
- const jsonPath = join("out", `make-${manifest.product}.beats.json`);
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`);
74
76
  writeFileSync(jsonPath, JSON.stringify(beats));
77
+ // Pin the child to the same out dir so beats + renders colocate.
78
+ const outArgs = ["--out", outDir];
75
79
 
76
80
  if (args.includes("--dry-run")) {
77
81
  // repo → storyboard. --frame is meaningless here; --theme-file is supported.
78
82
  const passthru = ["--format", "--theme", "--theme-file"].flatMap((f) => (flag(f) ? [f, flag(f)!] : []));
79
- const res = spawnSync(binPath("tsx"), [pkgFile("scripts/storyboard.ts"), jsonPath, ...passthru], { stdio: "inherit" });
83
+ const res = spawnSync(binPath("tsx"), [pkgFile("scripts/storyboard.ts"), jsonPath, ...passthru, ...outArgs], { stdio: "inherit" });
80
84
  process.exit(res.status ?? 0);
81
85
  }
82
86
 
83
87
  const passthru = ["--format", "--theme", "--frame"].flatMap((f) => (flag(f) ? [f, flag(f)!] : []));
84
- 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" });
85
89
  process.exit(res.status ?? 0);
package/scripts/render.ts CHANGED
@@ -7,12 +7,13 @@
7
7
  * bun run render src/content/streams.beats.ts [--format 16x9|1x1|9x16] [--frame N]
8
8
  */
9
9
  import { spawnSync } from "node:child_process";
10
- import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
10
+ import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
11
11
  import { basename, join, resolve } from "node:path";
12
12
  import { type Format } from "../src/schema/beats";
13
- import { THEMES } from "../src/theme";
13
+ import { stagePublicDir } from "./_assets";
14
14
  import { loadBeats } from "./_beats";
15
15
  import { binPath, pkgFile } from "./_pkg";
16
+ import { resolveOutDir, resolveTheme } from "./_theme";
16
17
 
17
18
  const args = process.argv.slice(2);
18
19
  const getFlag = (name: string) => {
@@ -34,26 +35,26 @@ const parsed = await loadBeats(file);
34
35
  const fmt = getFlag("--format") as Format | undefined;
35
36
  if (fmt) parsed.format = fmt;
36
37
  const frame = getFlag("--frame");
37
- const theme = getFlag("--theme");
38
- const themeFile = getFlag("--theme-file");
39
- if (theme && !themeFile && !THEMES[theme]) {
40
- console.error(`unknown --theme "${theme}". have: ${Object.keys(THEMES).join(", ")}`);
41
- process.exit(1);
42
- }
38
+ const { theme, themeFile } = resolveTheme({ theme: getFlag("--theme"), themeFile: getFlag("--theme-file"), beatsFile: file });
43
39
  const env: NodeJS.ProcessEnv = { ...process.env };
44
40
  if (theme) env.REMOTION_VIDEO_THEME = theme;
45
41
  if (themeFile) env.REMOTION_VIDEO_THEME_JSON = readFileSync(resolve(themeFile), "utf8");
46
42
 
47
- mkdirSync("out", { recursive: true });
43
+ const outDir = resolveOutDir({ outFlag: getFlag("--out"), beatsFile: file });
44
+ mkdirSync(outDir, { recursive: true });
48
45
  const name = basename(file).replace(/\.beats\.(ts|js|json)$/, "").replace(/\.(ts|js|json)$/, "");
49
- const propsPath = join("out", `.props-${name}-${parsed.format}.json`);
46
+ const propsPath = join(outDir, `.props-${name}-${parsed.format}.json`);
50
47
  writeFileSync(propsPath, JSON.stringify(parsed));
51
48
 
52
49
  const bin = binPath("remotion");
53
50
  const common = [pkgFile("src/index.ts"), "Changelog"];
54
51
  const suffix = theme && theme !== "default" ? `-${theme}` : "";
52
+ // Stage a merged public dir when the project supplies its own backgrounds.
53
+ const staged = stagePublicDir({ beatsFile: file, outDir });
54
+ const pub = staged ? [`--public-dir=${staged}`] : [];
55
55
  const res = frame
56
- ? spawnSync(bin, ["still", ...common, join("out", `${name}-${parsed.format}${suffix}-f${frame}.png`), `--props=${propsPath}`, `--frame=${frame}`], { stdio: "inherit", env })
57
- : spawnSync(bin, ["render", ...common, join("out", `${name}-${parsed.format}${suffix}.mp4`), `--props=${propsPath}`, "--image-format=jpeg"], { stdio: "inherit", env });
56
+ ? spawnSync(bin, ["still", ...common, join(outDir, `${name}-${parsed.format}${suffix}-f${frame}.png`), `--props=${propsPath}`, `--frame=${frame}`, ...pub], { stdio: "inherit", env })
57
+ : spawnSync(bin, ["render", ...common, join(outDir, `${name}-${parsed.format}${suffix}.mp4`), `--props=${propsPath}`, "--image-format=jpeg", ...pub], { stdio: "inherit", env });
58
+ if (staged) rmSync(staged, { recursive: true, force: true });
58
59
 
59
60
  process.exit(res.status ?? 0);
@@ -15,10 +15,11 @@ import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync }
15
15
  import { basename, join } from "node:path";
16
16
  import { type Beat, type Format } from "../src/schema/beats";
17
17
  import type { StoryboardCell, StoryboardProps } from "../src/components/Storyboard";
18
- import { THEMES } from "../src/theme";
18
+ import { stagePublicDir } from "./_assets";
19
19
  import { auditBeats, ICON, rankFindings } from "./_audit";
20
20
  import { beatTimings, FPS, loadBeats } from "./_beats";
21
21
  import { binPath, pkgFile } from "./_pkg";
22
+ import { resolveOutDir, resolveTheme } from "./_theme";
22
23
 
23
24
  const args = process.argv.slice(2);
24
25
  const getFlag = (name: string) => {
@@ -34,12 +35,7 @@ if (!file) {
34
35
  process.exit(1);
35
36
  }
36
37
 
37
- const theme = getFlag("--theme");
38
- const themeFile = getFlag("--theme-file");
39
- if (theme && !themeFile && !THEMES[theme]) {
40
- console.error(`unknown --theme "${theme}". have: ${Object.keys(THEMES).join(", ")}`);
41
- process.exit(1);
42
- }
38
+ const { theme, themeFile } = resolveTheme({ theme: getFlag("--theme"), themeFile: getFlag("--theme-file"), beatsFile: file });
43
39
 
44
40
  /** Short label for a beat's backdrop (omitted background = the procedural default). */
45
41
  const bgTag = (b: Beat): string => {
@@ -83,14 +79,18 @@ const env: NodeJS.ProcessEnv = { ...process.env };
83
79
  if (theme) env.REMOTION_VIDEO_THEME = theme;
84
80
  if (themeFile) env.REMOTION_VIDEO_THEME_JSON = readFileSync(themeFile, "utf8");
85
81
 
86
- mkdirSync("out", { recursive: true });
87
- const tmp = join("out", `.sb-${name}`);
82
+ const outDir = resolveOutDir({ outFlag: getFlag("--out"), beatsFile: file });
83
+ mkdirSync(outDir, { recursive: true });
84
+ const tmp = join(outDir, `.sb-${name}`);
88
85
  mkdirSync(tmp, { recursive: true });
89
86
  const propsPath = join(tmp, "props.json");
90
87
  writeFileSync(propsPath, JSON.stringify(parsed));
91
88
 
92
89
  const bin = binPath("remotion");
93
90
  const entry = pkgFile("src/index.ts");
91
+ // Stage a merged public dir so project backgrounds resolve in the per-beat stills.
92
+ const staged = stagePublicDir({ beatsFile: file, outDir });
93
+ const pub = staged ? [`--public-dir=${staged}`] : [];
94
94
  const TRANSPARENT_PX =
95
95
  "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+P+/HgAFhAJ/wlseKgAAAABJRU5ErkJggg==";
96
96
 
@@ -99,7 +99,7 @@ const cells: StoryboardCell[] = parsed.beats.map((b, i) => {
99
99
  const out = join(tmp, `beat-${i}.png`);
100
100
  const res = spawnSync(
101
101
  bin,
102
- ["still", entry, "Changelog", out, `--props=${propsPath}`, `--frame=${timings[i].mid}`, "--scale=0.33"],
102
+ ["still", entry, "Changelog", out, `--props=${propsPath}`, `--frame=${timings[i].mid}`, "--scale=0.33", ...pub],
103
103
  { stdio: ["ignore", "ignore", "inherit"], env },
104
104
  );
105
105
  let img = TRANSPARENT_PX;
@@ -115,12 +115,13 @@ const sheetProps: StoryboardProps = { format: fmt, name, cells };
115
115
  const sheetPropsPath = join(tmp, "sheet.json");
116
116
  writeFileSync(sheetPropsPath, JSON.stringify(sheetProps));
117
117
 
118
- const outPng = join("out", `${name}.storyboard.png`);
118
+ const outPng = join(outDir, `${name}.storyboard.png`);
119
119
  const sheet = spawnSync(bin, ["still", entry, "Storyboard", outPng, `--props=${sheetPropsPath}`], {
120
120
  stdio: ["ignore", "ignore", "inherit"],
121
121
  env,
122
122
  });
123
123
  rmSync(tmp, { recursive: true, force: true });
124
+ if (staged) rmSync(staged, { recursive: true, force: true });
124
125
 
125
126
  if (sheet.status !== 0) {
126
127
  console.error("\n✗ storyboard sheet render failed");
@@ -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,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
- <div style={{ height: 54, display: "flex", alignItems: "center", padding: "0 22px", gap: 9 }}>
94
- <Dot color="#f6645f" /><Dot color="#f7bd45" /><Dot color="#2fc94e" />
95
- <span style={{ flex: 1, textAlign: "center", color: "rgba(0,0,0,0.34)", fontSize: 15, fontFamily: FONTS.mono, marginRight: 60 }}>{filename}</span>
96
- </div>
97
- <div style={{ padding: "26px 40px 42px", fontFamily: FONTS.mono, fontSize, lineHeight: `${lineHeight}px`, color: COLORS.ink }}>
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>
@@ -3,13 +3,13 @@ import { AbsoluteFill, Img, continueRender, delayRender, staticFile } from "remo
3
3
  import { COLORS, RADIUS } from "../brand/tokens";
4
4
  import { FONTS } from "../brand/fonts";
5
5
 
6
- type Entry = { file: string; landmark: string; level: string; format: string; name: string };
6
+ type Entry = { file: string; subject: string; level: string; format: string; name: string };
7
7
 
8
8
  /**
9
- * Review board for generated paintings. Reads the candidates manifest written by
10
- * `bun run art` and lays them out in a labeled grid. Render with
11
- * `remotion still ContactSheet out/contact.png` to pick winners, then move the
12
- * chosen files from _candidates/ to committed public/backgrounds/.
9
+ * Review board for generated backgrounds. Reads the candidates manifest written
10
+ * by `cadence art` and lays them out in a labeled grid. Render with
11
+ * `remotion still ContactSheet out/contact.png` to pick winners, then promote the
12
+ * chosen files from _candidates/ to backgrounds/ (`cadence art --promote`).
13
13
  */
14
14
  export const ContactSheet: React.FC = () => {
15
15
  const [handle] = useState(() => delayRender("manifest"));
@@ -24,16 +24,16 @@ export const ContactSheet: React.FC = () => {
24
24
 
25
25
  return (
26
26
  <AbsoluteFill style={{ background: COLORS.paper, padding: 64, fontFamily: FONTS.body }}>
27
- <div style={{ fontFamily: FONTS.display, fontSize: 44, fontWeight: 600, letterSpacing: "-0.02em", color: COLORS.ink }}>Hill Country Sublime</div>
27
+ <div style={{ fontFamily: FONTS.display, fontSize: 44, fontWeight: 600, letterSpacing: "-0.02em", color: COLORS.ink }}>Background candidates</div>
28
28
  <div style={{ fontFamily: FONTS.note, fontSize: 26, color: COLORS.signalBlue, marginBottom: 32 }}>
29
- {items.length ? `${items.length} candidates — pick winners → public/backgrounds/` : "no candidates yet — run `bun run art --all`"}
29
+ {items.length ? `${items.length} candidates — pick winners → backgrounds/` : "no candidates yet — run `cadence art --prompt … --name …`"}
30
30
  </div>
31
31
  <div style={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 24 }}>
32
32
  {items.map((it) => (
33
33
  <div key={it.name} style={{ borderRadius: RADIUS.lg, overflow: "hidden", border: `1px solid ${COLORS.hairline}`, background: COLORS.paperElevated }}>
34
34
  <Img src={staticFile(it.file)} style={{ width: "100%", aspectRatio: "16 / 9", objectFit: "cover", display: "block" }} />
35
35
  <div style={{ display: "flex", justifyContent: "space-between", alignItems: "baseline", padding: "12px 16px" }}>
36
- <span style={{ fontSize: 18, fontWeight: 600, color: COLORS.ink }}>{it.landmark}</span>
36
+ <span style={{ fontSize: 18, fontWeight: 600, color: COLORS.ink }}>{it.subject}</span>
37
37
  <span style={{ fontFamily: FONTS.mono, fontSize: 12, textTransform: "uppercase", letterSpacing: "0.06em", color: COLORS.textMuted }}>{it.level} · {it.format}</span>
38
38
  </div>
39
39
  </div>
@@ -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
  };