@waits/cadence 0.4.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/generate-art.ts +103 -28
- package/scripts/render.ts +8 -3
- package/scripts/storyboard.ts +6 -1
- package/src/components/ContactSheet.tsx +8 -8
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
|
+
}
|
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/render.ts
CHANGED
|
@@ -7,9 +7,10 @@
|
|
|
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 { stagePublicDir } from "./_assets";
|
|
13
14
|
import { loadBeats } from "./_beats";
|
|
14
15
|
import { binPath, pkgFile } from "./_pkg";
|
|
15
16
|
import { resolveOutDir, resolveTheme } from "./_theme";
|
|
@@ -48,8 +49,12 @@ writeFileSync(propsPath, JSON.stringify(parsed));
|
|
|
48
49
|
const bin = binPath("remotion");
|
|
49
50
|
const common = [pkgFile("src/index.ts"), "Changelog"];
|
|
50
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}`] : [];
|
|
51
55
|
const res = frame
|
|
52
|
-
? spawnSync(bin, ["still", ...common, join(outDir, `${name}-${parsed.format}${suffix}-f${frame}.png`), `--props=${propsPath}`, `--frame=${frame}
|
|
53
|
-
: spawnSync(bin, ["render", ...common, join(outDir, `${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 });
|
|
54
59
|
|
|
55
60
|
process.exit(res.status ?? 0);
|
package/scripts/storyboard.ts
CHANGED
|
@@ -15,6 +15,7 @@ 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 { stagePublicDir } from "./_assets";
|
|
18
19
|
import { auditBeats, ICON, rankFindings } from "./_audit";
|
|
19
20
|
import { beatTimings, FPS, loadBeats } from "./_beats";
|
|
20
21
|
import { binPath, pkgFile } from "./_pkg";
|
|
@@ -87,6 +88,9 @@ writeFileSync(propsPath, JSON.stringify(parsed));
|
|
|
87
88
|
|
|
88
89
|
const bin = binPath("remotion");
|
|
89
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}`] : [];
|
|
90
94
|
const TRANSPARENT_PX =
|
|
91
95
|
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+P+/HgAFhAJ/wlseKgAAAABJRU5ErkJggg==";
|
|
92
96
|
|
|
@@ -95,7 +99,7 @@ const cells: StoryboardCell[] = parsed.beats.map((b, i) => {
|
|
|
95
99
|
const out = join(tmp, `beat-${i}.png`);
|
|
96
100
|
const res = spawnSync(
|
|
97
101
|
bin,
|
|
98
|
-
["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],
|
|
99
103
|
{ stdio: ["ignore", "ignore", "inherit"], env },
|
|
100
104
|
);
|
|
101
105
|
let img = TRANSPARENT_PX;
|
|
@@ -117,6 +121,7 @@ const sheet = spawnSync(bin, ["still", entry, "Storyboard", outPng, `--props=${s
|
|
|
117
121
|
env,
|
|
118
122
|
});
|
|
119
123
|
rmSync(tmp, { recursive: true, force: true });
|
|
124
|
+
if (staged) rmSync(staged, { recursive: true, force: true });
|
|
120
125
|
|
|
121
126
|
if (sheet.status !== 0) {
|
|
122
127
|
console.error("\n✗ storyboard sheet render failed");
|
|
@@ -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>
|