@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 +11 -5
- package/package.json +1 -1
- package/prompts/art/compose.ts +17 -7
- package/scripts/_assets.ts +31 -0
- package/scripts/_theme.ts +74 -0
- package/scripts/cli.ts +2 -1
- package/scripts/generate-art.ts +103 -28
- package/scripts/make.ts +9 -5
- package/scripts/render.ts +13 -12
- package/scripts/storyboard.ts +12 -11
- package/src/brand/tokens.ts +3 -0
- package/src/components/CodeWindow.tsx +10 -7
- package/src/components/ContactSheet.tsx +8 -8
- package/src/theme/types.ts +6 -0
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:
|
|
107
|
+
## Optional: generated backgrounds
|
|
108
108
|
|
|
109
|
-
The default backdrop is procedural and needs no key. For
|
|
110
|
-
|
|
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 --
|
|
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
|
-
|
|
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
package/prompts/art/compose.ts
CHANGED
|
@@ -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
|
-
/**
|
|
7
|
-
|
|
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: ${
|
|
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") {
|
package/scripts/generate-art.ts
CHANGED
|
@@ -1,21 +1,26 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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
|
-
*
|
|
7
|
-
*
|
|
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
|
-
*
|
|
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,
|
|
17
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
12
18
|
import { join } from "node:path";
|
|
13
|
-
import { composePrompt, LANDMARKS, type FantasyLevel
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
const
|
|
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}`,
|
|
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;
|
|
138
|
+
if (idx >= 0) manifest[idx] = entry;
|
|
139
|
+
else manifest.push(entry);
|
|
65
140
|
console.log("ok");
|
|
66
141
|
}
|
|
67
142
|
|
|
68
|
-
for (const
|
|
69
|
-
await generate(
|
|
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✓ ${
|
|
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
|
-
|
|
73
|
-
|
|
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 {
|
|
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
|
-
|
|
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(
|
|
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(
|
|
57
|
-
: spawnSync(bin, ["render", ...common, join(
|
|
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);
|
package/scripts/storyboard.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
87
|
-
|
|
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(
|
|
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");
|
package/src/brand/tokens.ts
CHANGED
|
@@ -9,6 +9,9 @@ export const COLORS = activeTheme.colors;
|
|
|
9
9
|
|
|
10
10
|
export const CARET_BG = activeTheme.caretBg;
|
|
11
11
|
|
|
12
|
+
/** Code window chrome — `"window"` (floating editor) or `"minimal"` (docs-style). */
|
|
13
|
+
export const CODE_CHROME = activeTheme.codeChrome ?? "window";
|
|
14
|
+
|
|
12
15
|
/** Light [from, to] gradient for the procedural default backdrop ("shapes"). */
|
|
13
16
|
export const BACKDROP: [string, string] = activeTheme.backdrop ?? ["#e6edff", "#f8faff"];
|
|
14
17
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useCurrentFrame } from "remotion";
|
|
2
|
-
import { CARET_BG, COLORS, FLOAT_SHADOW } from "../brand/tokens";
|
|
2
|
+
import { CARET_BG, CODE_CHROME, COLORS, FLOAT_SHADOW } from "../brand/tokens";
|
|
3
3
|
import { FONTS } from "../brand/fonts";
|
|
4
4
|
import { useMotion, type MotionSpec } from "../motion/useMotion";
|
|
5
5
|
import { CODE_BG, type CodeLine } from "../code/highlight";
|
|
@@ -24,6 +24,7 @@ export const codeTypingDoneFrame = (tokens: CodeLine[], motion?: MotionSpec) =>
|
|
|
24
24
|
export const CodeWindow: React.FC<Props> = ({ filename, tokens, motion = { enter: "settle", delay: 12 }, fontSize = 22 }) => {
|
|
25
25
|
const frame = useCurrentFrame();
|
|
26
26
|
const style = useMotion(motion);
|
|
27
|
+
const minimal = CODE_CHROME === "minimal";
|
|
27
28
|
|
|
28
29
|
const typeStart = typeStartFor(motion);
|
|
29
30
|
const total = totalChars(tokens);
|
|
@@ -81,7 +82,7 @@ export const CodeWindow: React.FC<Props> = ({ filename, tokens, motion = { enter
|
|
|
81
82
|
<div
|
|
82
83
|
style={{
|
|
83
84
|
width: "100%",
|
|
84
|
-
borderRadius: 20,
|
|
85
|
+
borderRadius: minimal ? 12 : 20,
|
|
85
86
|
background: `${CODE_BG}f7`,
|
|
86
87
|
boxShadow: FLOAT_SHADOW,
|
|
87
88
|
backdropFilter: "blur(6px)",
|
|
@@ -90,11 +91,13 @@ export const CodeWindow: React.FC<Props> = ({ filename, tokens, motion = { enter
|
|
|
90
91
|
...style,
|
|
91
92
|
}}
|
|
92
93
|
>
|
|
93
|
-
|
|
94
|
-
<
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
94
|
+
{!minimal && (
|
|
95
|
+
<div style={{ height: 54, display: "flex", alignItems: "center", padding: "0 22px", gap: 9 }}>
|
|
96
|
+
<Dot color="#f6645f" /><Dot color="#f7bd45" /><Dot color="#2fc94e" />
|
|
97
|
+
<span style={{ flex: 1, textAlign: "center", color: "rgba(0,0,0,0.34)", fontSize: 15, fontFamily: FONTS.mono, marginRight: 60 }}>{filename}</span>
|
|
98
|
+
</div>
|
|
99
|
+
)}
|
|
100
|
+
<div style={{ padding: minimal ? "30px 34px" : "26px 40px 42px", fontFamily: FONTS.mono, fontSize, lineHeight: `${lineHeight}px`, color: COLORS.ink }}>
|
|
98
101
|
{lines}
|
|
99
102
|
</div>
|
|
100
103
|
</div>
|
|
@@ -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;
|
|
6
|
+
type Entry = { file: string; subject: string; level: string; format: string; name: string };
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
|
-
* Review board for generated
|
|
10
|
-
* `
|
|
11
|
-
* `remotion still ContactSheet out/contact.png` to pick winners, then
|
|
12
|
-
* chosen files from _candidates/ to
|
|
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 }}>
|
|
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 →
|
|
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.
|
|
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>
|
package/src/theme/types.ts
CHANGED
|
@@ -62,6 +62,12 @@ export type ThemeConfig = {
|
|
|
62
62
|
caretBg: string;
|
|
63
63
|
/** Syntax colors. */
|
|
64
64
|
codeTheme: CodeTheme;
|
|
65
|
+
/**
|
|
66
|
+
* Code window chrome. `"window"` (default) is a floating editor with traffic-
|
|
67
|
+
* light dots + a filename tab; `"minimal"` is chromeless (just the code surface)
|
|
68
|
+
* to match a docs-style snippet component.
|
|
69
|
+
*/
|
|
70
|
+
codeChrome?: "window" | "minimal";
|
|
65
71
|
/** Light [from, to] gradient for the procedural default backdrop (Background "shapes"). */
|
|
66
72
|
backdrop?: [string, string];
|
|
67
73
|
};
|