reelme 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 +10 -3
- package/assets/audio/PROVENANCE.md +18 -11
- package/assets/audio/generate-sfx.mjs +128 -0
- package/assets/audio/light-steps.mp3 +0 -0
- package/assets/audio/lofi-dusk.mp3 +0 -0
- package/assets/audio/manifest.json +56 -63
- package/assets/audio/neon-pulse.mp3 +0 -0
- package/assets/audio/night-drive.mp3 +0 -0
- package/assets/audio/sfx/SOURCES.md +16 -0
- package/assets/audio/sfx/pop.mp3 +0 -0
- package/assets/audio/sfx/rise.mp3 +0 -0
- package/assets/audio/sfx/whoosh.mp3 +0 -0
- package/assets/audio/sunny-bounce.mp3 +0 -0
- package/assets/audio/warm-memories.mp3 +0 -0
- package/package.json +8 -2
- package/src/cache.mjs +246 -16
- package/src/cli.mjs +20 -11
- package/src/render.mjs +98 -19
- package/template/package.json +6 -6
- package/template/pnpm-lock.yaml +177 -122
- package/template/pnpm-workspace.yaml +2 -0
- package/template/src/Root.tsx +101 -21
- package/template/src/brief.json +6 -6
- package/template/src/brief.ts +39 -6
- package/template/src/cinematic/Atmosphere.tsx +176 -30
- package/template/src/cinematic/Camera.tsx +22 -10
- package/template/src/cinematic/look.ts +51 -10
- package/template/src/cinematic/transitions.tsx +29 -9
- package/template/src/components/primitives/Arrow.tsx +8 -5
- package/template/src/components/primitives/Bar.tsx +6 -2
- package/template/src/components/primitives/Caption.tsx +2 -2
- package/template/src/components/primitives/CodeBlock.tsx +21 -7
- package/template/src/components/primitives/Icon.tsx +7 -0
- package/template/src/components/primitives/KeyPill.tsx +1 -1
- package/template/src/components/primitives/Label.tsx +4 -1
- package/template/src/components/primitives/Loop.tsx +65 -0
- package/template/src/components/primitives/RevealText.tsx +35 -7
- package/template/src/components/primitives/Stage.tsx +62 -0
- package/template/src/components/primitives/Terminal.tsx +24 -7
- package/template/src/components/scenes/BrowserFrame.tsx +13 -6
- package/template/src/components/scenes/CTA.tsx +47 -82
- package/template/src/components/scenes/Clip.tsx +31 -12
- package/template/src/components/scenes/CodeReveal.tsx +12 -13
- package/template/src/components/scenes/DataFlow.tsx +47 -19
- package/template/src/components/scenes/FeatureList.tsx +74 -77
- package/template/src/components/scenes/FileTree.tsx +23 -7
- package/template/src/components/scenes/Hook.tsx +4 -3
- package/template/src/components/scenes/HotkeyScene.tsx +4 -3
- package/template/src/components/scenes/MobileScreen.tsx +225 -118
- package/template/src/components/scenes/OSWindowScene.tsx +5 -5
- package/template/src/components/scenes/Problem.tsx +1 -1
- package/template/src/components/scenes/SplitComparison.tsx +41 -14
- package/template/src/components/scenes/StatCallout.tsx +123 -68
- package/template/src/components/scenes/TerminalScene.tsx +12 -11
- package/template/src/custom-scenes.ts +19 -0
- package/template/src/duration.ts +54 -5
- package/template/src/index.ts +16 -10
- package/template/src/theme.ts +10 -1
- package/template/src/timing.ts +49 -0
- package/assets/audio/bright-sparks.mp3 +0 -0
- package/assets/audio/calm-keys.mp3 +0 -0
- package/assets/audio/circuit-pulse.mp3 +0 -0
- package/assets/audio/clean-horizon.mp3 +0 -0
- package/assets/audio/generate-tracks.mjs +0 -213
- package/assets/audio/midnight-protocol.mp3 +0 -0
- package/assets/audio/pixel-bounce.mp3 +0 -0
- package/assets/audio/steady-launch.mp3 +0 -0
- package/assets/audio/sunny-loop.mp3 +0 -0
- package/assets/audio/vector-grid.mp3 +0 -0
package/src/cache.mjs
CHANGED
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
rmSync,
|
|
13
13
|
} from "node:fs";
|
|
14
14
|
import { homedir } from "node:os";
|
|
15
|
-
import { basename, dirname, join } from "node:path";
|
|
15
|
+
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
16
16
|
import { fileURLToPath } from "node:url";
|
|
17
17
|
import { spawnSync } from "node:child_process";
|
|
18
18
|
|
|
@@ -26,6 +26,19 @@ const CLI_VERSION = JSON.parse(
|
|
|
26
26
|
readFileSync(join(PKG_ROOT, "package.json"), "utf8")
|
|
27
27
|
).version;
|
|
28
28
|
|
|
29
|
+
// Cache-invalidation key. The per-render `src/` re-sync keeps scene code fresh,
|
|
30
|
+
// but dependencies and pnpm config only reach an existing cache on a full
|
|
31
|
+
// rebuild — so the marker hashes those files, not just the CLI version (F8).
|
|
32
|
+
// A bumped Remotion version or workspace change now forces a reinstall.
|
|
33
|
+
function templateFingerprint() {
|
|
34
|
+
const hash = createHash("sha256").update(CLI_VERSION);
|
|
35
|
+
for (const file of ["package.json", "pnpm-workspace.yaml"]) {
|
|
36
|
+
const path = join(TEMPLATE_DIR, file);
|
|
37
|
+
if (existsSync(path)) hash.update(readFileSync(path));
|
|
38
|
+
}
|
|
39
|
+
return hash.digest("hex").slice(0, 16);
|
|
40
|
+
}
|
|
41
|
+
|
|
29
42
|
// Not copied into the cache scaffold (mirrors the old SKILL.md rsync excludes).
|
|
30
43
|
const SCAFFOLD_EXCLUDES = new Set([
|
|
31
44
|
"node_modules",
|
|
@@ -48,6 +61,86 @@ export function loadPlatforms() {
|
|
|
48
61
|
);
|
|
49
62
|
}
|
|
50
63
|
|
|
64
|
+
// Per-scene contract, mirroring the TypeScript union in template/src/brief.ts.
|
|
65
|
+
// The CLI runs first and owns the error UX, so it validates exhaustively here
|
|
66
|
+
// (F4/F13/F24): a typo'd scene type or a missing required prop is caught with a
|
|
67
|
+
// named error instead of a cryptic NaN duration or crash minutes into Remotion.
|
|
68
|
+
const SCENE_REQUIRED = {
|
|
69
|
+
problem: ["headline"],
|
|
70
|
+
"code-reveal": ["language", "code"],
|
|
71
|
+
terminal: ["commands"],
|
|
72
|
+
"data-flow": ["nodes", "edges"],
|
|
73
|
+
cta: ["installCommand", "repoUrl"],
|
|
74
|
+
browser: ["url"],
|
|
75
|
+
split: ["before", "after"],
|
|
76
|
+
"feature-list": ["items"],
|
|
77
|
+
"stat-callout": ["stats"],
|
|
78
|
+
"file-tree": ["entries"],
|
|
79
|
+
mobile: [],
|
|
80
|
+
"os-window": ["items"],
|
|
81
|
+
hotkey: ["keys"],
|
|
82
|
+
hook: ["text"],
|
|
83
|
+
clip: ["src", "frame"],
|
|
84
|
+
benchmark: ["bars"],
|
|
85
|
+
custom: ["component", "durationInFrames"],
|
|
86
|
+
};
|
|
87
|
+
const ARRAY_FIELDS = new Set([
|
|
88
|
+
"commands", "nodes", "edges", "items", "stats", "entries", "keys", "bars",
|
|
89
|
+
]);
|
|
90
|
+
const CLIP_EXTS = ["mp4", "mov", "gif"];
|
|
91
|
+
const IMAGE_EXTS = ["png", "jpg", "jpeg", "webp"];
|
|
92
|
+
|
|
93
|
+
function extOf(p) {
|
|
94
|
+
const dot = p.lastIndexOf(".");
|
|
95
|
+
return dot === -1 ? "" : p.slice(dot + 1).toLowerCase();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function validateScene(scene, where, errors) {
|
|
99
|
+
if (!scene || typeof scene !== "object") {
|
|
100
|
+
errors.push(`${where}: scene must be an object.`);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
const spec = SCENE_REQUIRED[scene.type];
|
|
104
|
+
if (!spec) {
|
|
105
|
+
errors.push(
|
|
106
|
+
`${where}: unknown scene type "${scene.type ?? "(none)"}". Valid types: ${Object.keys(SCENE_REQUIRED).join(", ")}.`
|
|
107
|
+
);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
for (const field of spec) {
|
|
111
|
+
if (scene[field] === undefined || scene[field] === null) {
|
|
112
|
+
errors.push(`${where} (${scene.type}): missing required field "${field}".`);
|
|
113
|
+
} else if (ARRAY_FIELDS.has(field) && !Array.isArray(scene[field])) {
|
|
114
|
+
errors.push(`${where} (${scene.type}): "${field}" must be an array.`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
// Asset extension checks — the direct-CLI path has no skill to pre-validate.
|
|
118
|
+
const assetChecks = [
|
|
119
|
+
["clip", "src", CLIP_EXTS],
|
|
120
|
+
["mobile", "screenshot", IMAGE_EXTS],
|
|
121
|
+
["browser", "image", IMAGE_EXTS],
|
|
122
|
+
["custom", "component", ["tsx"]],
|
|
123
|
+
];
|
|
124
|
+
for (const [type, field, exts] of assetChecks) {
|
|
125
|
+
if (scene.type === type && typeof scene[field] === "string" && !exts.includes(extOf(scene[field]))) {
|
|
126
|
+
errors.push(
|
|
127
|
+
`${where} (${type}): "${field}" must be one of ${exts.join(", ")} (got "${scene[field]}").`
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Empty cut arrays are normalized to absent so a `"vertical": []` behaves like a
|
|
134
|
+
// missing vertical cut everywhere (F3): the CLI and the template agree on the
|
|
135
|
+
// main-cut fallback instead of building a zero-duration composition.
|
|
136
|
+
function normalizeCuts(brief) {
|
|
137
|
+
for (const key of ["vertical", "teaser"]) {
|
|
138
|
+
if (Array.isArray(brief.cuts?.[key]) && brief.cuts[key].length === 0) {
|
|
139
|
+
delete brief.cuts[key];
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
51
144
|
export function readBrief(repoRoot) {
|
|
52
145
|
const briefPath = join(repoRoot, "reelme.json");
|
|
53
146
|
if (!existsSync(briefPath)) {
|
|
@@ -83,6 +176,17 @@ export function readBrief(repoRoot) {
|
|
|
83
176
|
if (!Array.isArray(brief.cuts?.main) || brief.cuts.main.length === 0) {
|
|
84
177
|
fail(`cuts.main is missing or empty — the main cut is required.`);
|
|
85
178
|
}
|
|
179
|
+
normalizeCuts(brief);
|
|
180
|
+
|
|
181
|
+
const errors = [];
|
|
182
|
+
for (const key of ["main", "vertical", "teaser"]) {
|
|
183
|
+
const cut = brief.cuts[key];
|
|
184
|
+
if (!Array.isArray(cut)) continue;
|
|
185
|
+
cut.forEach((scene, i) => validateScene(scene, `cuts.${key}[${i}]`, errors));
|
|
186
|
+
}
|
|
187
|
+
if (errors.length > 0) {
|
|
188
|
+
fail(`reelme.json has ${errors.length} scene problem(s):\n${errors.map((e) => ` ${e}`).join("\n")}`);
|
|
189
|
+
}
|
|
86
190
|
return brief;
|
|
87
191
|
}
|
|
88
192
|
|
|
@@ -91,8 +195,16 @@ export function projectCacheDir(repoRoot) {
|
|
|
91
195
|
return join(CACHE_ROOT, hash);
|
|
92
196
|
}
|
|
93
197
|
|
|
198
|
+
// spawnSync with the pnpm/npx shims resolved on every OS. On Windows those
|
|
199
|
+
// shims are `.cmd` files, which Node's spawnSync can only launch via a shell
|
|
200
|
+
// (and since CVE-2024-27980 that is the only sanctioned route). Elsewhere we
|
|
201
|
+
// avoid the shell so arguments never go through word-splitting.
|
|
202
|
+
export function runShell(command, args, cwd) {
|
|
203
|
+
return spawnSync(command, args, { cwd, stdio: "inherit", shell: process.platform === "win32" });
|
|
204
|
+
}
|
|
205
|
+
|
|
94
206
|
function run(command, args, cwd) {
|
|
95
|
-
const result =
|
|
207
|
+
const result = runShell(command, args, cwd);
|
|
96
208
|
if (result.error) fail(`failed to run ${command}: ${result.error.message}`);
|
|
97
209
|
if (result.status !== 0) {
|
|
98
210
|
fail(`${command} ${args.join(" ")} exited with status ${result.status}`);
|
|
@@ -105,13 +217,18 @@ function run(command, args, cwd) {
|
|
|
105
217
|
export function ensureScaffold(repoRoot) {
|
|
106
218
|
const cacheDir = projectCacheDir(repoRoot);
|
|
107
219
|
const versionMarker = join(cacheDir, ".reelme-template-version");
|
|
220
|
+
const fingerprint = templateFingerprint();
|
|
108
221
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
222
|
+
// A cache with a package.json but a missing or mismatched marker is stale — a
|
|
223
|
+
// missing marker is treated as stale, not fresh (F8), so half-built or
|
|
224
|
+
// pre-marker caches always rebuild.
|
|
225
|
+
const cacheExists = existsSync(join(cacheDir, "package.json"));
|
|
226
|
+
const markerValue = existsSync(versionMarker)
|
|
227
|
+
? readFileSync(versionMarker, "utf8").trim()
|
|
228
|
+
: null;
|
|
229
|
+
if (cacheExists && markerValue !== fingerprint) {
|
|
113
230
|
console.log(
|
|
114
|
-
`reelme: template
|
|
231
|
+
`reelme: template changed (CLI ${CLI_VERSION}) — rebuilding the cache scaffold.`
|
|
115
232
|
);
|
|
116
233
|
rmSync(cacheDir, { recursive: true, force: true });
|
|
117
234
|
}
|
|
@@ -123,27 +240,140 @@ export function ensureScaffold(repoRoot) {
|
|
|
123
240
|
recursive: true,
|
|
124
241
|
filter: (src) => !SCAFFOLD_EXCLUDES.has(basename(src)),
|
|
125
242
|
});
|
|
126
|
-
writeFileSync(versionMarker, `${
|
|
243
|
+
writeFileSync(versionMarker, `${fingerprint}\n`);
|
|
127
244
|
}
|
|
128
245
|
|
|
129
|
-
//
|
|
246
|
+
// Re-sync the template source on every render. The version marker only fires
|
|
247
|
+
// on a CLI bump, so without this a render reuses whatever scene code the cache
|
|
248
|
+
// was scaffolded with — local template edits (and the gallery render eval)
|
|
249
|
+
// would silently test stale code. This is a cheap dir copy; node_modules stays
|
|
250
|
+
// gated below so we don't reinstall. (Deleted template files aren't pruned from
|
|
251
|
+
// the cache — a CLI bump or `reelme clean` does a full rebuild.)
|
|
252
|
+
cpSync(join(TEMPLATE_DIR, "src"), join(cacheDir, "src"), {
|
|
253
|
+
recursive: true,
|
|
254
|
+
filter: (src) => !SCAFFOLD_EXCLUDES.has(basename(src)),
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
// The user's brief always wins over the template sample. On the way in, the
|
|
258
|
+
// chosen track's BPM is injected from the audio manifest so the template can
|
|
259
|
+
// quantize scene cuts to the beat — the manifest stays the single source of
|
|
260
|
+
// truth and briefs never hand-write bpm.
|
|
130
261
|
cpSync(join(repoRoot, "reelme.json"), join(cacheDir, "src", "brief.json"));
|
|
262
|
+
try {
|
|
263
|
+
const briefPath = join(cacheDir, "src", "brief.json");
|
|
264
|
+
const staged = JSON.parse(readFileSync(briefPath, "utf8"));
|
|
265
|
+
const track = staged?.project?.audio?.track;
|
|
266
|
+
if (track) {
|
|
267
|
+
const manifest = JSON.parse(readFileSync(join(AUDIO_DIR, "manifest.json"), "utf8"));
|
|
268
|
+
const entry = Array.isArray(manifest) ? manifest.find((e) => e.file === track) : undefined;
|
|
269
|
+
if (entry && typeof entry.bpm === "number") {
|
|
270
|
+
staged.project.audio.bpm = entry.bpm;
|
|
271
|
+
writeFileSync(briefPath, JSON.stringify(staged, null, 2) + "\n");
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
} catch {
|
|
275
|
+
// Malformed briefs/manifests are reported by validation, not here.
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
stageCustomScenes(repoRoot, cacheDir);
|
|
131
279
|
|
|
132
280
|
if (!existsSync(join(cacheDir, "node_modules"))) {
|
|
133
281
|
console.log("reelme: installing render dependencies (first run only)…");
|
|
282
|
+
// esbuild's build script is pre-approved in template/pnpm-workspace.yaml
|
|
283
|
+
// (allowBuilds), so `pnpm install` runs it without a blanket approval of
|
|
284
|
+
// every dependency's scripts (F15) — the supply-chain gate stays closed.
|
|
134
285
|
run("pnpm", ["install"], cacheDir);
|
|
135
|
-
// esbuild needs its post-install script; pnpm-workspace.yaml persists the approval.
|
|
136
|
-
run("pnpm", ["approve-builds", "--all"], cacheDir);
|
|
137
286
|
}
|
|
138
287
|
|
|
139
288
|
return cacheDir;
|
|
140
289
|
}
|
|
141
290
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
291
|
+
/**
|
|
292
|
+
* Stage skill-authored bespoke scenes and regenerate the import registry the
|
|
293
|
+
* template's SceneRenderer resolves against. The template's default
|
|
294
|
+
* custom-scenes.ts (synced a moment earlier) exports an empty registry, so
|
|
295
|
+
* briefs without custom scenes keep exactly the stock behavior. Remotion
|
|
296
|
+
* bundles statically, so a generated barrel of real imports is the only way a
|
|
297
|
+
* brief-referenced path can become a renderable component.
|
|
298
|
+
*/
|
|
299
|
+
function stageCustomScenes(repoRoot, cacheDir) {
|
|
300
|
+
let components = [];
|
|
301
|
+
try {
|
|
302
|
+
const staged = JSON.parse(readFileSync(join(cacheDir, "src", "brief.json"), "utf8"));
|
|
303
|
+
for (const cut of Object.values(staged?.cuts ?? {})) {
|
|
304
|
+
if (!Array.isArray(cut)) continue;
|
|
305
|
+
for (const scene of cut) {
|
|
306
|
+
if (scene?.type === "custom" && typeof scene.component === "string") {
|
|
307
|
+
components.push(scene.component);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
} catch {
|
|
312
|
+
return; // malformed briefs are reported by validation
|
|
313
|
+
}
|
|
314
|
+
components = [...new Set(components)];
|
|
315
|
+
if (components.length === 0) return;
|
|
316
|
+
|
|
317
|
+
const customDir = join(cacheDir, "src", "custom");
|
|
318
|
+
mkdirSync(customDir, { recursive: true });
|
|
319
|
+
|
|
320
|
+
const imports = [];
|
|
321
|
+
const entries = [];
|
|
322
|
+
components.forEach((rel, i) => {
|
|
323
|
+
// Containment: the component must live inside the repo.
|
|
324
|
+
const source = resolve(repoRoot, rel);
|
|
325
|
+
const contained = relative(repoRoot, source);
|
|
326
|
+
if (isAbsolute(rel) || contained.startsWith("..") || isAbsolute(contained)) {
|
|
327
|
+
fail(`custom scene component must be a repo-relative path (got "${rel}").`);
|
|
328
|
+
}
|
|
329
|
+
if (!existsSync(source)) {
|
|
330
|
+
fail(`custom scene component not found: ${rel}`);
|
|
331
|
+
}
|
|
332
|
+
cpSync(source, join(customDir, `scene-${i}.tsx`));
|
|
333
|
+
imports.push(`import Custom${i} from "./custom/scene-${i}";`);
|
|
334
|
+
entries.push(` ${JSON.stringify(rel)}: Custom${i},`);
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
const generated = `// GENERATED by the reelme CLI at stage time — do not edit.
|
|
338
|
+
import type { ComponentType } from "react";
|
|
339
|
+
import { Theme } from "./theme";
|
|
340
|
+
import { ProjectMeta } from "./brief";
|
|
341
|
+
import { PlatformPreset } from "./platforms";
|
|
342
|
+
${imports.join("\n")}
|
|
343
|
+
|
|
344
|
+
export interface CustomSceneProps {
|
|
345
|
+
theme: Theme;
|
|
346
|
+
project: ProjectMeta;
|
|
347
|
+
platform: PlatformPreset;
|
|
348
|
+
bottomInset: number;
|
|
349
|
+
caption?: string;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
export const CUSTOM_SCENES: Record<string, ComponentType<CustomSceneProps>> = {
|
|
353
|
+
${entries.join("\n")}
|
|
354
|
+
};
|
|
355
|
+
`;
|
|
356
|
+
writeFileSync(join(cacheDir, "src", "custom-scenes.ts"), generated);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Removes the current project's cache by default; pass `all` to wipe every
|
|
360
|
+
// project's cache (F16). A moved/renamed repo hashes to a new dir, so `--all`
|
|
361
|
+
// is the way to reclaim orphaned caches.
|
|
362
|
+
export function cleanCache(repoRoot, all = false) {
|
|
363
|
+
if (all) {
|
|
364
|
+
if (!existsSync(CACHE_ROOT)) {
|
|
365
|
+
console.log("reelme: cache is already empty.");
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
rmSync(CACHE_ROOT, { recursive: true, force: true });
|
|
369
|
+
console.log(`reelme: removed all project caches (${CACHE_ROOT})`);
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
const cacheDir = projectCacheDir(repoRoot);
|
|
373
|
+
if (!existsSync(cacheDir)) {
|
|
374
|
+
console.log("reelme: no cache for this project. Use `reelme clean --all` to wipe every project's cache.");
|
|
145
375
|
return;
|
|
146
376
|
}
|
|
147
|
-
rmSync(
|
|
148
|
-
console.log(`reelme: removed ${
|
|
377
|
+
rmSync(cacheDir, { recursive: true, force: true });
|
|
378
|
+
console.log(`reelme: removed this project's cache (${cacheDir})`);
|
|
149
379
|
}
|
package/src/cli.mjs
CHANGED
|
@@ -4,30 +4,33 @@
|
|
|
4
4
|
// skill owns the intelligence and writes reelme.json; this CLI never asks
|
|
5
5
|
// questions.
|
|
6
6
|
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import { render } from "./render.mjs";
|
|
7
|
+
import { ensureScaffold, cleanCache, readBrief, runShell, fail } from "./cache.mjs";
|
|
8
|
+
import { render, stageInputs } from "./render.mjs";
|
|
10
9
|
|
|
11
10
|
const USAGE = `reelme — launch videos for your repo, rendered locally
|
|
12
11
|
|
|
13
12
|
Usage: npx reelme <command>
|
|
14
13
|
|
|
15
14
|
Commands:
|
|
16
|
-
render
|
|
17
|
-
studio
|
|
18
|
-
|
|
15
|
+
render Render every platform in reelme.json to ./reelme-out/
|
|
16
|
+
studio Open Remotion Studio against the cached project (preview/tweak)
|
|
17
|
+
validate Check reelme.json (schema, platforms, scenes) without rendering
|
|
18
|
+
clean Remove this project's reelme cache
|
|
19
|
+
clean --all Remove every project's cache (~/.reelme/cache)
|
|
19
20
|
|
|
20
21
|
The brief (reelme.json) lives at your repo root — create it with the
|
|
21
22
|
/reelme agent skill: npx skills add RubenGlez/reelme
|
|
22
23
|
`;
|
|
23
24
|
|
|
25
|
+
// Studio runs the same staging pipeline as render (minus the render loop) so a
|
|
26
|
+
// preview shows exactly what render() would produce — assets, audio, and logo
|
|
27
|
+
// all staged, and a missing brief reported cleanly (F1).
|
|
24
28
|
function studio(repoRoot) {
|
|
29
|
+
const brief = readBrief(repoRoot);
|
|
25
30
|
const cacheDir = ensureScaffold(repoRoot);
|
|
31
|
+
stageInputs(repoRoot, cacheDir, brief);
|
|
26
32
|
console.log("reelme: opening Remotion Studio (Ctrl+C to stop)…");
|
|
27
|
-
const result =
|
|
28
|
-
cwd: cacheDir,
|
|
29
|
-
stdio: "inherit",
|
|
30
|
-
});
|
|
33
|
+
const result = runShell("pnpm", ["exec", "remotion", "studio"], cacheDir);
|
|
31
34
|
if (result.error) fail(`failed to open Studio: ${result.error.message}`);
|
|
32
35
|
}
|
|
33
36
|
|
|
@@ -41,8 +44,14 @@ switch (command) {
|
|
|
41
44
|
case "studio":
|
|
42
45
|
studio(repoRoot);
|
|
43
46
|
break;
|
|
47
|
+
case "validate":
|
|
48
|
+
// readBrief validates schema, platforms, and every scene, exiting non-zero
|
|
49
|
+
// with named errors on failure. A cheap pre-render check for the skill.
|
|
50
|
+
readBrief(repoRoot);
|
|
51
|
+
console.log("reelme: reelme.json is valid.");
|
|
52
|
+
break;
|
|
44
53
|
case "clean":
|
|
45
|
-
cleanCache();
|
|
54
|
+
cleanCache(repoRoot, process.argv.includes("--all"));
|
|
46
55
|
break;
|
|
47
56
|
case "--help":
|
|
48
57
|
case "-h":
|
package/src/render.mjs
CHANGED
|
@@ -2,22 +2,74 @@
|
|
|
2
2
|
// variant per social platform when the brief has a teaser cut. Outputs land
|
|
3
3
|
// in <repo>/reelme-out/; the heavy Remotion project stays in the cache.
|
|
4
4
|
|
|
5
|
-
import { cpSync, existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
6
|
-
import { basename, dirname, join } from "node:path";
|
|
5
|
+
import { cpSync, existsSync, mkdirSync, readFileSync, rmSync } from "node:fs";
|
|
6
|
+
import { basename, dirname, join, resolve, relative, isAbsolute } from "node:path";
|
|
7
7
|
import { spawnSync } from "node:child_process";
|
|
8
|
-
import {
|
|
8
|
+
import { createRequire } from "node:module";
|
|
9
|
+
import { AUDIO_DIR, ensureScaffold, loadPlatforms, readBrief, runShell, fail } from "./cache.mjs";
|
|
10
|
+
|
|
11
|
+
const require = createRequire(import.meta.url);
|
|
12
|
+
|
|
13
|
+
// Reject asset paths that escape their base dir. A reelme.json is authored by an
|
|
14
|
+
// agent and gets committed/shared, so a `../../…` src/logo would otherwise copy
|
|
15
|
+
// (or read) files outside the cache at render time (F11). Guards both the repo
|
|
16
|
+
// source and the cache destination.
|
|
17
|
+
function safeJoin(baseDir, relPath, label) {
|
|
18
|
+
if (typeof relPath !== "string" || relPath.length === 0 || isAbsolute(relPath)) {
|
|
19
|
+
fail(`${label} "${relPath}" must be a repo-relative path.`);
|
|
20
|
+
}
|
|
21
|
+
const dest = resolve(baseDir, relPath);
|
|
22
|
+
const rel = relative(baseDir, dest);
|
|
23
|
+
if (rel.startsWith("..") || isAbsolute(rel)) {
|
|
24
|
+
fail(`${label} "${relPath}" escapes the project directory — refusing to copy.`);
|
|
25
|
+
}
|
|
26
|
+
return dest;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Resolve the gifsicle binary: prefer the optionalDependency's bundled build,
|
|
30
|
+
// fall back to a system install on PATH. Returns null when neither is present.
|
|
31
|
+
function gifsicleBin() {
|
|
32
|
+
try {
|
|
33
|
+
const mod = require("gifsicle");
|
|
34
|
+
// The package exports the binary path; under Node's require-ESM it arrives
|
|
35
|
+
// as a namespace ({ default: path }) rather than a bare string.
|
|
36
|
+
const p = typeof mod === "string" ? mod : mod?.default;
|
|
37
|
+
if (typeof p === "string" && existsSync(p)) return p;
|
|
38
|
+
} catch {
|
|
39
|
+
// optionalDependency not installed — fall through to a PATH lookup.
|
|
40
|
+
}
|
|
41
|
+
return "gifsicle";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Lossy gif pass. Remotion's native gif is already well quantized, but a
|
|
45
|
+
// gifsicle --lossy run roughly halves it again with text left crisp. Our
|
|
46
|
+
// atmosphere gradients band under palette/colour reduction, so we keep the full
|
|
47
|
+
// palette and only apply --lossy. Best-effort: a missing gifsicle is non-fatal.
|
|
48
|
+
function optimizeGif(file) {
|
|
49
|
+
const res = spawnSync(gifsicleBin(), ["-b", "-O3", "--lossy=60", file], { stdio: "ignore" });
|
|
50
|
+
if (res.error || res.status !== 0) {
|
|
51
|
+
console.warn("reelme: note — gif left unoptimized (install gifsicle for ~50% smaller files).");
|
|
52
|
+
}
|
|
53
|
+
}
|
|
9
54
|
|
|
10
55
|
function renderComposition(cacheDir, compositionId, outFile, codec) {
|
|
11
56
|
const args = ["exec", "remotion", "render", compositionId, join("out", outFile)];
|
|
12
57
|
// Render gifs at 0.6 scale (e.g. 1920x1080 -> 1152x648): a README gif needs no
|
|
13
58
|
// more, and fewer pixels roughly halves the file size. Layout and timing are
|
|
14
59
|
// unchanged — --scale downscales the output, not the composition coordinates.
|
|
15
|
-
if (codec === "gif")
|
|
60
|
+
if (codec === "gif") {
|
|
61
|
+
args.push("--codec=gif", "--scale=0.6");
|
|
62
|
+
} else {
|
|
63
|
+
// Remotion's default h264 bitrate is far higher than a social upload needs;
|
|
64
|
+
// crf 20 is visually lossless and cuts the file to roughly a third.
|
|
65
|
+
args.push("--crf=20");
|
|
66
|
+
}
|
|
16
67
|
console.log(`reelme: rendering ${compositionId} → reelme-out/${outFile}`);
|
|
17
|
-
const result =
|
|
68
|
+
const result = runShell("pnpm", args, cacheDir);
|
|
18
69
|
if (result.error || result.status !== 0) {
|
|
19
70
|
fail(`render failed for platform composition "${compositionId}".`);
|
|
20
71
|
}
|
|
72
|
+
if (codec === "gif") optimizeGif(join(cacheDir, "out", outFile));
|
|
21
73
|
}
|
|
22
74
|
|
|
23
75
|
function collectAssets(brief) {
|
|
@@ -38,14 +90,19 @@ function copyAssets(repoRoot, cacheDir, brief) {
|
|
|
38
90
|
if (assets.length === 0) return;
|
|
39
91
|
const publicDir = join(cacheDir, "public");
|
|
40
92
|
mkdirSync(publicDir, { recursive: true });
|
|
41
|
-
|
|
93
|
+
// Resolve every path through the traversal guard before touching the disk.
|
|
94
|
+
const resolved = assets.map((asset) => ({
|
|
95
|
+
asset,
|
|
96
|
+
source: safeJoin(repoRoot, asset, "asset"),
|
|
97
|
+
dest: safeJoin(publicDir, asset, "asset"),
|
|
98
|
+
}));
|
|
99
|
+
const missing = resolved.filter(({ source }) => !existsSync(source));
|
|
42
100
|
if (missing.length > 0) {
|
|
43
|
-
fail(`missing asset file(s):\n${missing.map((
|
|
101
|
+
fail(`missing asset file(s):\n${missing.map(({ source }) => ` ${source}`).join("\n")}`);
|
|
44
102
|
}
|
|
45
|
-
for (const
|
|
46
|
-
const dest = join(publicDir, asset);
|
|
103
|
+
for (const { source, dest } of resolved) {
|
|
47
104
|
mkdirSync(dirname(dest), { recursive: true });
|
|
48
|
-
cpSync(
|
|
105
|
+
cpSync(source, dest);
|
|
49
106
|
}
|
|
50
107
|
}
|
|
51
108
|
|
|
@@ -89,21 +146,37 @@ function copyAudioTrack(cacheDir, brief) {
|
|
|
89
146
|
const audioDir = join(cacheDir, "public", "audio");
|
|
90
147
|
mkdirSync(audioDir, { recursive: true });
|
|
91
148
|
cpSync(source, join(audioDir, track));
|
|
149
|
+
|
|
150
|
+
// Cut SFX ride along whenever music is enabled (Root.tsx plays them under
|
|
151
|
+
// scene transitions; audio: false disables both together).
|
|
152
|
+
const sfxDir = join(AUDIO_DIR, "sfx");
|
|
153
|
+
if (existsSync(sfxDir)) {
|
|
154
|
+
cpSync(sfxDir, join(audioDir, "sfx"), { recursive: true });
|
|
155
|
+
}
|
|
92
156
|
}
|
|
93
157
|
|
|
94
158
|
function copyLogo(repoRoot, cacheDir, brief) {
|
|
95
159
|
const logo = brief.project?.logo;
|
|
96
160
|
if (!logo) return;
|
|
97
|
-
const source =
|
|
161
|
+
const source = safeJoin(repoRoot, logo, "project.logo");
|
|
98
162
|
if (!existsSync(source)) {
|
|
99
163
|
fail(`project.logo file not found: ${source}`);
|
|
100
164
|
}
|
|
101
165
|
const publicDir = join(cacheDir, "public");
|
|
102
|
-
const dest =
|
|
166
|
+
const dest = safeJoin(publicDir, logo, "project.logo");
|
|
103
167
|
mkdirSync(dirname(dest), { recursive: true });
|
|
104
168
|
cpSync(source, dest);
|
|
105
169
|
}
|
|
106
170
|
|
|
171
|
+
// Stage every render input into the cache. Shared by render() and studio() so
|
|
172
|
+
// Studio previews exactly what render() produces (F1) instead of showing 404s
|
|
173
|
+
// for missing assets/audio/logo.
|
|
174
|
+
export function stageInputs(repoRoot, cacheDir, brief) {
|
|
175
|
+
copyAssets(repoRoot, cacheDir, brief);
|
|
176
|
+
copyAudioTrack(cacheDir, brief);
|
|
177
|
+
copyLogo(repoRoot, cacheDir, brief);
|
|
178
|
+
}
|
|
179
|
+
|
|
107
180
|
export function render(repoRoot) {
|
|
108
181
|
const brief = readBrief(repoRoot);
|
|
109
182
|
const platforms = loadPlatforms();
|
|
@@ -111,17 +184,15 @@ export function render(repoRoot) {
|
|
|
111
184
|
const outDir = join(repoRoot, "reelme-out");
|
|
112
185
|
mkdirSync(outDir, { recursive: true });
|
|
113
186
|
|
|
114
|
-
|
|
115
|
-
copyAudioTrack(cacheDir, brief);
|
|
116
|
-
copyLogo(repoRoot, cacheDir, brief);
|
|
187
|
+
stageInputs(repoRoot, cacheDir, brief);
|
|
117
188
|
|
|
118
189
|
const verticalFallback = brief.project.platforms.filter(
|
|
119
190
|
(id) => platforms[id].cut === "vertical" && !brief.cuts.vertical?.length
|
|
120
191
|
);
|
|
121
192
|
if (verticalFallback.length > 0) {
|
|
122
193
|
console.warn(
|
|
123
|
-
`reelme: warning — no cuts.vertical in reelme.json; ${verticalFallback.join(", ")} will render ` +
|
|
124
|
-
`the main cut
|
|
194
|
+
`reelme: warning — no cuts.vertical in reelme.json; ${verticalFallback.join(", ")} will re-render ` +
|
|
195
|
+
`the main cut at 9:16. Dense wide scenes may cramp — author a vertical cut for better results.`
|
|
125
196
|
);
|
|
126
197
|
}
|
|
127
198
|
|
|
@@ -129,7 +200,7 @@ export function render(repoRoot) {
|
|
|
129
200
|
const preset = platforms[id];
|
|
130
201
|
const outFile = basename(preset.output.file);
|
|
131
202
|
renderComposition(cacheDir, `Reel-${id}`, outFile, preset.output.codec);
|
|
132
|
-
|
|
203
|
+
copyOut(cacheDir, outFile, outDir);
|
|
133
204
|
}
|
|
134
205
|
|
|
135
206
|
const hasTeaser = Array.isArray(brief.cuts.teaser) && brief.cuts.teaser.length > 0;
|
|
@@ -140,9 +211,17 @@ export function render(repoRoot) {
|
|
|
140
211
|
for (const id of socialIds) {
|
|
141
212
|
const outFile = `${id}-teaser.mp4`;
|
|
142
213
|
renderComposition(cacheDir, `Reel-${id}-teaser`, outFile, "h264");
|
|
143
|
-
|
|
214
|
+
copyOut(cacheDir, outFile, outDir);
|
|
144
215
|
}
|
|
145
216
|
}
|
|
146
217
|
|
|
147
218
|
console.log(`reelme: done — outputs in ${outDir}`);
|
|
148
219
|
}
|
|
220
|
+
|
|
221
|
+
// Copy a finished render to reelme-out/, then drop the cache copy so out/ doesn't
|
|
222
|
+
// accumulate every render forever (F16). The deliverable already lives in the repo.
|
|
223
|
+
function copyOut(cacheDir, outFile, outDir) {
|
|
224
|
+
const cached = join(cacheDir, "out", outFile);
|
|
225
|
+
cpSync(cached, join(outDir, outFile));
|
|
226
|
+
rmSync(cached, { force: true });
|
|
227
|
+
}
|
package/template/package.json
CHANGED
|
@@ -9,12 +9,14 @@
|
|
|
9
9
|
"test": "vitest run"
|
|
10
10
|
},
|
|
11
11
|
"dependencies": {
|
|
12
|
-
"@remotion/cli": "4.0.
|
|
13
|
-
"@remotion/gif": "4.0.
|
|
14
|
-
"@remotion/google-fonts": "4.0.
|
|
12
|
+
"@remotion/cli": "4.0.410",
|
|
13
|
+
"@remotion/gif": "4.0.410",
|
|
14
|
+
"@remotion/google-fonts": "4.0.410",
|
|
15
15
|
"chroma-js": "^3.1.2",
|
|
16
16
|
"lucide-react": "^0.511.0",
|
|
17
|
-
"
|
|
17
|
+
"react": "^18.3.0",
|
|
18
|
+
"react-dom": "^18.3.0",
|
|
19
|
+
"remotion": "4.0.410"
|
|
18
20
|
},
|
|
19
21
|
"devDependencies": {
|
|
20
22
|
"@types/chroma-js": "^2.4.4",
|
|
@@ -22,8 +24,6 @@
|
|
|
22
24
|
"@typescript-eslint/eslint-plugin": "^8.0.0",
|
|
23
25
|
"@typescript-eslint/parser": "^8.0.0",
|
|
24
26
|
"eslint": "^9.0.0",
|
|
25
|
-
"react": "^18.3.0",
|
|
26
|
-
"react-dom": "^18.3.0",
|
|
27
27
|
"typescript": "^5.4.0",
|
|
28
28
|
"vite": "^7.3.5",
|
|
29
29
|
"vitest": "^4.1.8"
|