reelme 0.3.0 → 0.4.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.
Files changed (40) hide show
  1. package/README.md +10 -3
  2. package/assets/audio/generate-tracks.mjs +6 -1
  3. package/package.json +8 -2
  4. package/src/cache.mjs +154 -14
  5. package/src/cli.mjs +20 -11
  6. package/src/render.mjs +91 -19
  7. package/template/package.json +6 -6
  8. package/template/pnpm-lock.yaml +177 -122
  9. package/template/pnpm-workspace.yaml +2 -0
  10. package/template/src/Root.tsx +27 -15
  11. package/template/src/brief.json +6 -6
  12. package/template/src/brief.ts +13 -3
  13. package/template/src/cinematic/Camera.tsx +1 -1
  14. package/template/src/cinematic/look.ts +25 -8
  15. package/template/src/cinematic/transitions.tsx +27 -9
  16. package/template/src/components/primitives/Bar.tsx +6 -2
  17. package/template/src/components/primitives/Caption.tsx +2 -2
  18. package/template/src/components/primitives/Icon.tsx +7 -0
  19. package/template/src/components/primitives/KeyPill.tsx +1 -1
  20. package/template/src/components/primitives/Label.tsx +4 -1
  21. package/template/src/components/primitives/Loop.tsx +65 -0
  22. package/template/src/components/primitives/RevealText.tsx +27 -5
  23. package/template/src/components/primitives/Stage.tsx +62 -0
  24. package/template/src/components/scenes/BrowserFrame.tsx +1 -1
  25. package/template/src/components/scenes/CTA.tsx +45 -82
  26. package/template/src/components/scenes/Clip.tsx +9 -3
  27. package/template/src/components/scenes/CodeReveal.tsx +6 -5
  28. package/template/src/components/scenes/DataFlow.tsx +1 -1
  29. package/template/src/components/scenes/FeatureList.tsx +74 -77
  30. package/template/src/components/scenes/FileTree.tsx +1 -1
  31. package/template/src/components/scenes/HotkeyScene.tsx +4 -3
  32. package/template/src/components/scenes/MobileScreen.tsx +222 -107
  33. package/template/src/components/scenes/OSWindowScene.tsx +4 -4
  34. package/template/src/components/scenes/Problem.tsx +1 -1
  35. package/template/src/components/scenes/SplitComparison.tsx +1 -1
  36. package/template/src/components/scenes/StatCallout.tsx +123 -68
  37. package/template/src/components/scenes/TerminalScene.tsx +12 -11
  38. package/template/src/duration.ts +12 -2
  39. package/template/src/index.ts +7 -5
  40. package/template/src/timing.ts +49 -0
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # reelme
2
2
 
3
- Turn your repo into launch videos for social platforms — rendered locally with [Remotion](https://www.remotion.dev), zero cloud, zero API keys.
3
+ Turn your repo into launch videos for social platforms — rendered on your machine with [Remotion](https://www.remotion.dev). No accounts, no API keys, nothing uploaded.
4
4
 
5
5
  `reelme` is the rendering engine behind the [reelme agent skill](https://github.com/RubenGlez/reelme). The skill does the thinking (it reads your repo, interviews you, and writes a `reelme.json` brief at your repo root); this CLI does the work: it scaffolds a Remotion project in a global cache and renders one video variant per publishing platform.
6
6
 
@@ -24,12 +24,13 @@ npx skills add RubenGlez/reelme
24
24
  |---|---|
25
25
  | `npx reelme render` | Renders every platform selected in `reelme.json` to `./reelme-out/` |
26
26
  | `npx reelme studio` | Opens Remotion Studio against the cached project, for previewing and tweaking |
27
- | `npx reelme clean` | Removes the reelme cache (`~/.reelme/cache`) |
27
+ | `npx reelme validate` | Checks `reelme.json` (schema, platforms, scene types and fields) without rendering |
28
+ | `npx reelme clean` | Removes this project's cache (add `--all` to wipe every project's cache in `~/.reelme/cache`) |
28
29
 
29
30
  ## How it works
30
31
 
31
32
  - Your repo keeps only two things: `reelme.json` (the brief — commit it, it's your video's source of truth across releases) and `reelme-out/` (rendered videos; add it to `.gitignore`).
32
- - The heavy Remotion project lives in `~/.reelme/cache/<project-hash>/`, scaffolded on first render and reused afterwards. Re-renders don't reinstall anything; upgrading the CLI rebuilds the scaffold automatically.
33
+ - The heavy Remotion project lives in `~/.reelme/cache/<project-hash>/`, scaffolded on first render and reused afterwards. Re-renders don't reinstall anything; upgrading the CLI (or changing the template's dependencies) rebuilds the scaffold automatically.
33
34
  - Each platform you select (X, LinkedIn, YouTube, TikTok, Instagram Reel/Story/Feed, GitHub README) maps to a preset that resolves dimensions, duration ceilings, and safe areas internally — you never deal with aspect ratios. GitHub README outputs a GIF; everything else is MP4. Briefs with a `teaser` cut also get a short `<platform>-teaser.mp4` per social platform.
34
35
 
35
36
  ## Requirements
@@ -37,6 +38,12 @@ npx skills add RubenGlez/reelme
37
38
  - Node.js >= 18
38
39
  - [pnpm](https://pnpm.io) (used to install render dependencies inside the cache)
39
40
 
41
+ The first render needs network access: it installs the Remotion project into the
42
+ cache (npm registry), downloads a headless Chrome build (hundreds of MB, once),
43
+ and fetches the on-screen fonts from Google Fonts. After the first successful
44
+ render most of this is cached, but font loading still runs per render — nothing
45
+ you author is ever uploaded.
46
+
40
47
  ## License
41
48
 
42
49
  MIT
@@ -204,10 +204,15 @@ for (const track of TRACKS) {
204
204
  ],
205
205
  { stdio: "inherit" }
206
206
  );
207
- rmSync(wavPath, { force: true });
207
+ // Check the encode status before cleaning up. On failure, remove both the
208
+ // intermediate WAV and any partial/stale MP3 so a broken encode never leaves a
209
+ // misleading file from this or a previous run on disk.
208
210
  if (result.error || result.status !== 0) {
211
+ rmSync(wavPath, { force: true });
212
+ rmSync(mp3Path, { force: true });
209
213
  process.exitCode = result.status || 1;
210
214
  break;
211
215
  }
216
+ rmSync(wavPath, { force: true });
212
217
  console.log(`wrote ${mp3Path}`);
213
218
  }
package/package.json CHANGED
@@ -1,11 +1,14 @@
1
1
  {
2
2
  "name": "reelme",
3
- "version": "0.3.0",
4
- "description": "Turn your repo into launch videos for social platforms. Local Remotion rendering, zero cloud.",
3
+ "version": "0.4.0",
4
+ "description": "Turn your repo into launch videos for social platforms. Rendered on your machine with Remotion — no accounts, no uploads.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "reelme": "src/cli.mjs"
8
8
  },
9
+ "scripts": {
10
+ "test": "node test/smoke.mjs"
11
+ },
9
12
  "files": [
10
13
  "src",
11
14
  "assets",
@@ -19,6 +22,9 @@
19
22
  "engines": {
20
23
  "node": ">=18"
21
24
  },
25
+ "optionalDependencies": {
26
+ "gifsicle": "^7.0.1"
27
+ },
22
28
  "keywords": [
23
29
  "launch-video",
24
30
  "remotion",
package/src/cache.mjs CHANGED
@@ -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,84 @@ 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
+ };
86
+ const ARRAY_FIELDS = new Set([
87
+ "commands", "nodes", "edges", "items", "stats", "entries", "keys", "bars",
88
+ ]);
89
+ const CLIP_EXTS = ["mp4", "mov", "gif"];
90
+ const IMAGE_EXTS = ["png", "jpg", "jpeg", "webp"];
91
+
92
+ function extOf(p) {
93
+ const dot = p.lastIndexOf(".");
94
+ return dot === -1 ? "" : p.slice(dot + 1).toLowerCase();
95
+ }
96
+
97
+ function validateScene(scene, where, errors) {
98
+ if (!scene || typeof scene !== "object") {
99
+ errors.push(`${where}: scene must be an object.`);
100
+ return;
101
+ }
102
+ const spec = SCENE_REQUIRED[scene.type];
103
+ if (!spec) {
104
+ errors.push(
105
+ `${where}: unknown scene type "${scene.type ?? "(none)"}". Valid types: ${Object.keys(SCENE_REQUIRED).join(", ")}.`
106
+ );
107
+ return;
108
+ }
109
+ for (const field of spec) {
110
+ if (scene[field] === undefined || scene[field] === null) {
111
+ errors.push(`${where} (${scene.type}): missing required field "${field}".`);
112
+ } else if (ARRAY_FIELDS.has(field) && !Array.isArray(scene[field])) {
113
+ errors.push(`${where} (${scene.type}): "${field}" must be an array.`);
114
+ }
115
+ }
116
+ // Asset extension checks — the direct-CLI path has no skill to pre-validate.
117
+ const assetChecks = [
118
+ ["clip", "src", CLIP_EXTS],
119
+ ["mobile", "screenshot", IMAGE_EXTS],
120
+ ["browser", "image", IMAGE_EXTS],
121
+ ];
122
+ for (const [type, field, exts] of assetChecks) {
123
+ if (scene.type === type && typeof scene[field] === "string" && !exts.includes(extOf(scene[field]))) {
124
+ errors.push(
125
+ `${where} (${type}): "${field}" must be one of ${exts.join(", ")} (got "${scene[field]}").`
126
+ );
127
+ }
128
+ }
129
+ }
130
+
131
+ // Empty cut arrays are normalized to absent so a `"vertical": []` behaves like a
132
+ // missing vertical cut everywhere (F3): the CLI and the template agree on the
133
+ // main-cut fallback instead of building a zero-duration composition.
134
+ function normalizeCuts(brief) {
135
+ for (const key of ["vertical", "teaser"]) {
136
+ if (Array.isArray(brief.cuts?.[key]) && brief.cuts[key].length === 0) {
137
+ delete brief.cuts[key];
138
+ }
139
+ }
140
+ }
141
+
51
142
  export function readBrief(repoRoot) {
52
143
  const briefPath = join(repoRoot, "reelme.json");
53
144
  if (!existsSync(briefPath)) {
@@ -83,6 +174,17 @@ export function readBrief(repoRoot) {
83
174
  if (!Array.isArray(brief.cuts?.main) || brief.cuts.main.length === 0) {
84
175
  fail(`cuts.main is missing or empty — the main cut is required.`);
85
176
  }
177
+ normalizeCuts(brief);
178
+
179
+ const errors = [];
180
+ for (const key of ["main", "vertical", "teaser"]) {
181
+ const cut = brief.cuts[key];
182
+ if (!Array.isArray(cut)) continue;
183
+ cut.forEach((scene, i) => validateScene(scene, `cuts.${key}[${i}]`, errors));
184
+ }
185
+ if (errors.length > 0) {
186
+ fail(`reelme.json has ${errors.length} scene problem(s):\n${errors.map((e) => ` ${e}`).join("\n")}`);
187
+ }
86
188
  return brief;
87
189
  }
88
190
 
@@ -91,8 +193,16 @@ export function projectCacheDir(repoRoot) {
91
193
  return join(CACHE_ROOT, hash);
92
194
  }
93
195
 
196
+ // spawnSync with the pnpm/npx shims resolved on every OS. On Windows those
197
+ // shims are `.cmd` files, which Node's spawnSync can only launch via a shell
198
+ // (and since CVE-2024-27980 that is the only sanctioned route). Elsewhere we
199
+ // avoid the shell so arguments never go through word-splitting.
200
+ export function runShell(command, args, cwd) {
201
+ return spawnSync(command, args, { cwd, stdio: "inherit", shell: process.platform === "win32" });
202
+ }
203
+
94
204
  function run(command, args, cwd) {
95
- const result = spawnSync(command, args, { cwd, stdio: "inherit" });
205
+ const result = runShell(command, args, cwd);
96
206
  if (result.error) fail(`failed to run ${command}: ${result.error.message}`);
97
207
  if (result.status !== 0) {
98
208
  fail(`${command} ${args.join(" ")} exited with status ${result.status}`);
@@ -105,13 +215,18 @@ function run(command, args, cwd) {
105
215
  export function ensureScaffold(repoRoot) {
106
216
  const cacheDir = projectCacheDir(repoRoot);
107
217
  const versionMarker = join(cacheDir, ".reelme-template-version");
218
+ const fingerprint = templateFingerprint();
108
219
 
109
- const stale =
110
- existsSync(versionMarker) &&
111
- readFileSync(versionMarker, "utf8").trim() !== CLI_VERSION;
112
- if (stale) {
220
+ // A cache with a package.json but a missing or mismatched marker is stale — a
221
+ // missing marker is treated as stale, not fresh (F8), so half-built or
222
+ // pre-marker caches always rebuild.
223
+ const cacheExists = existsSync(join(cacheDir, "package.json"));
224
+ const markerValue = existsSync(versionMarker)
225
+ ? readFileSync(versionMarker, "utf8").trim()
226
+ : null;
227
+ if (cacheExists && markerValue !== fingerprint) {
113
228
  console.log(
114
- `reelme: template updated (CLI ${CLI_VERSION}) — rebuilding the cache scaffold.`
229
+ `reelme: template changed (CLI ${CLI_VERSION}) — rebuilding the cache scaffold.`
115
230
  );
116
231
  rmSync(cacheDir, { recursive: true, force: true });
117
232
  }
@@ -123,27 +238,52 @@ export function ensureScaffold(repoRoot) {
123
238
  recursive: true,
124
239
  filter: (src) => !SCAFFOLD_EXCLUDES.has(basename(src)),
125
240
  });
126
- writeFileSync(versionMarker, `${CLI_VERSION}\n`);
241
+ writeFileSync(versionMarker, `${fingerprint}\n`);
127
242
  }
128
243
 
244
+ // Re-sync the template source on every render. The version marker only fires
245
+ // on a CLI bump, so without this a render reuses whatever scene code the cache
246
+ // was scaffolded with — local template edits (and the gallery render eval)
247
+ // would silently test stale code. This is a cheap dir copy; node_modules stays
248
+ // gated below so we don't reinstall. (Deleted template files aren't pruned from
249
+ // the cache — a CLI bump or `reelme clean` does a full rebuild.)
250
+ cpSync(join(TEMPLATE_DIR, "src"), join(cacheDir, "src"), {
251
+ recursive: true,
252
+ filter: (src) => !SCAFFOLD_EXCLUDES.has(basename(src)),
253
+ });
254
+
129
255
  // The user's brief always wins over the template sample.
130
256
  cpSync(join(repoRoot, "reelme.json"), join(cacheDir, "src", "brief.json"));
131
257
 
132
258
  if (!existsSync(join(cacheDir, "node_modules"))) {
133
259
  console.log("reelme: installing render dependencies (first run only)…");
260
+ // esbuild's build script is pre-approved in template/pnpm-workspace.yaml
261
+ // (allowBuilds), so `pnpm install` runs it without a blanket approval of
262
+ // every dependency's scripts (F15) — the supply-chain gate stays closed.
134
263
  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
264
  }
138
265
 
139
266
  return cacheDir;
140
267
  }
141
268
 
142
- export function cleanCache() {
143
- if (!existsSync(CACHE_ROOT)) {
144
- console.log("reelme: cache is already empty.");
269
+ // Removes the current project's cache by default; pass `all` to wipe every
270
+ // project's cache (F16). A moved/renamed repo hashes to a new dir, so `--all`
271
+ // is the way to reclaim orphaned caches.
272
+ export function cleanCache(repoRoot, all = false) {
273
+ if (all) {
274
+ if (!existsSync(CACHE_ROOT)) {
275
+ console.log("reelme: cache is already empty.");
276
+ return;
277
+ }
278
+ rmSync(CACHE_ROOT, { recursive: true, force: true });
279
+ console.log(`reelme: removed all project caches (${CACHE_ROOT})`);
280
+ return;
281
+ }
282
+ const cacheDir = projectCacheDir(repoRoot);
283
+ if (!existsSync(cacheDir)) {
284
+ console.log("reelme: no cache for this project. Use `reelme clean --all` to wipe every project's cache.");
145
285
  return;
146
286
  }
147
- rmSync(CACHE_ROOT, { recursive: true, force: true });
148
- console.log(`reelme: removed ${CACHE_ROOT}`);
287
+ rmSync(cacheDir, { recursive: true, force: true });
288
+ console.log(`reelme: removed this project's cache (${cacheDir})`);
149
289
  }
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 { spawnSync } from "node:child_process";
8
- import { ensureScaffold, cleanCache, fail } from "./cache.mjs";
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 Render every platform in reelme.json to ./reelme-out/
17
- studio Open Remotion Studio against the cached project (preview/tweak)
18
- clean Remove the reelme cache (~/.reelme/cache)
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 = spawnSync("pnpm", ["exec", "remotion", "studio"], {
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 { AUDIO_DIR, ensureScaffold, loadPlatforms, readBrief, fail } from "./cache.mjs";
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") args.push("--codec=gif", "--scale=0.6");
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 = spawnSync("pnpm", args, { cwd: cacheDir, stdio: "inherit" });
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
- const missing = assets.filter((a) => !existsSync(join(repoRoot, a)));
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((a) => ` ${join(repoRoot, a)}`).join("\n")}`);
101
+ fail(`missing asset file(s):\n${missing.map(({ source }) => ` ${source}`).join("\n")}`);
44
102
  }
45
- for (const asset of assets) {
46
- const dest = join(publicDir, asset);
103
+ for (const { source, dest } of resolved) {
47
104
  mkdirSync(dirname(dest), { recursive: true });
48
- cpSync(join(repoRoot, asset), dest);
105
+ cpSync(source, dest);
49
106
  }
50
107
  }
51
108
 
@@ -94,16 +151,25 @@ function copyAudioTrack(cacheDir, brief) {
94
151
  function copyLogo(repoRoot, cacheDir, brief) {
95
152
  const logo = brief.project?.logo;
96
153
  if (!logo) return;
97
- const source = join(repoRoot, logo);
154
+ const source = safeJoin(repoRoot, logo, "project.logo");
98
155
  if (!existsSync(source)) {
99
156
  fail(`project.logo file not found: ${source}`);
100
157
  }
101
158
  const publicDir = join(cacheDir, "public");
102
- const dest = join(publicDir, logo);
159
+ const dest = safeJoin(publicDir, logo, "project.logo");
103
160
  mkdirSync(dirname(dest), { recursive: true });
104
161
  cpSync(source, dest);
105
162
  }
106
163
 
164
+ // Stage every render input into the cache. Shared by render() and studio() so
165
+ // Studio previews exactly what render() produces (F1) instead of showing 404s
166
+ // for missing assets/audio/logo.
167
+ export function stageInputs(repoRoot, cacheDir, brief) {
168
+ copyAssets(repoRoot, cacheDir, brief);
169
+ copyAudioTrack(cacheDir, brief);
170
+ copyLogo(repoRoot, cacheDir, brief);
171
+ }
172
+
107
173
  export function render(repoRoot) {
108
174
  const brief = readBrief(repoRoot);
109
175
  const platforms = loadPlatforms();
@@ -111,17 +177,15 @@ export function render(repoRoot) {
111
177
  const outDir = join(repoRoot, "reelme-out");
112
178
  mkdirSync(outDir, { recursive: true });
113
179
 
114
- copyAssets(repoRoot, cacheDir, brief);
115
- copyAudioTrack(cacheDir, brief);
116
- copyLogo(repoRoot, cacheDir, brief);
180
+ stageInputs(repoRoot, cacheDir, brief);
117
181
 
118
182
  const verticalFallback = brief.project.platforms.filter(
119
183
  (id) => platforms[id].cut === "vertical" && !brief.cuts.vertical?.length
120
184
  );
121
185
  if (verticalFallback.length > 0) {
122
186
  console.warn(
123
- `reelme: warning — no cuts.vertical in reelme.json; ${verticalFallback.join(", ")} will render ` +
124
- `the main cut letterboxed into 9:16. Author a vertical cut for better results.`
187
+ `reelme: warning — no cuts.vertical in reelme.json; ${verticalFallback.join(", ")} will re-render ` +
188
+ `the main cut at 9:16. Dense wide scenes may cramp — author a vertical cut for better results.`
125
189
  );
126
190
  }
127
191
 
@@ -129,7 +193,7 @@ export function render(repoRoot) {
129
193
  const preset = platforms[id];
130
194
  const outFile = basename(preset.output.file);
131
195
  renderComposition(cacheDir, `Reel-${id}`, outFile, preset.output.codec);
132
- cpSync(join(cacheDir, "out", outFile), join(outDir, outFile));
196
+ copyOut(cacheDir, outFile, outDir);
133
197
  }
134
198
 
135
199
  const hasTeaser = Array.isArray(brief.cuts.teaser) && brief.cuts.teaser.length > 0;
@@ -140,9 +204,17 @@ export function render(repoRoot) {
140
204
  for (const id of socialIds) {
141
205
  const outFile = `${id}-teaser.mp4`;
142
206
  renderComposition(cacheDir, `Reel-${id}-teaser`, outFile, "h264");
143
- cpSync(join(cacheDir, "out", outFile), join(outDir, outFile));
207
+ copyOut(cacheDir, outFile, outDir);
144
208
  }
145
209
  }
146
210
 
147
211
  console.log(`reelme: done — outputs in ${outDir}`);
148
212
  }
213
+
214
+ // Copy a finished render to reelme-out/, then drop the cache copy so out/ doesn't
215
+ // accumulate every render forever (F16). The deliverable already lives in the repo.
216
+ function copyOut(cacheDir, outFile, outDir) {
217
+ const cached = join(cacheDir, "out", outFile);
218
+ cpSync(cached, join(outDir, outFile));
219
+ rmSync(cached, { force: true });
220
+ }
@@ -9,12 +9,14 @@
9
9
  "test": "vitest run"
10
10
  },
11
11
  "dependencies": {
12
- "@remotion/cli": "4.0.290",
13
- "@remotion/gif": "4.0.290",
14
- "@remotion/google-fonts": "4.0.290",
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
- "remotion": "4.0.290"
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"