reelme 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.
Files changed (50) hide show
  1. package/assets/audio/PROVENANCE.md +18 -11
  2. package/assets/audio/generate-sfx.mjs +128 -0
  3. package/assets/audio/light-steps.mp3 +0 -0
  4. package/assets/audio/lofi-dusk.mp3 +0 -0
  5. package/assets/audio/manifest.json +56 -63
  6. package/assets/audio/neon-pulse.mp3 +0 -0
  7. package/assets/audio/night-drive.mp3 +0 -0
  8. package/assets/audio/sfx/SOURCES.md +16 -0
  9. package/assets/audio/sfx/pop.mp3 +0 -0
  10. package/assets/audio/sfx/rise.mp3 +0 -0
  11. package/assets/audio/sfx/whoosh.mp3 +0 -0
  12. package/assets/audio/sunny-bounce.mp3 +0 -0
  13. package/assets/audio/warm-memories.mp3 +0 -0
  14. package/package.json +1 -1
  15. package/src/cache.mjs +92 -2
  16. package/src/render.mjs +7 -0
  17. package/template/src/Root.tsx +75 -7
  18. package/template/src/brief.ts +26 -3
  19. package/template/src/cinematic/Atmosphere.tsx +176 -30
  20. package/template/src/cinematic/Camera.tsx +21 -9
  21. package/template/src/cinematic/look.ts +28 -4
  22. package/template/src/cinematic/transitions.tsx +4 -2
  23. package/template/src/components/primitives/Arrow.tsx +8 -5
  24. package/template/src/components/primitives/CodeBlock.tsx +21 -7
  25. package/template/src/components/primitives/RevealText.tsx +8 -2
  26. package/template/src/components/primitives/Terminal.tsx +24 -7
  27. package/template/src/components/scenes/BrowserFrame.tsx +12 -5
  28. package/template/src/components/scenes/CTA.tsx +4 -2
  29. package/template/src/components/scenes/Clip.tsx +27 -14
  30. package/template/src/components/scenes/CodeReveal.tsx +8 -10
  31. package/template/src/components/scenes/DataFlow.tsx +46 -18
  32. package/template/src/components/scenes/FileTree.tsx +22 -6
  33. package/template/src/components/scenes/Hook.tsx +4 -3
  34. package/template/src/components/scenes/MobileScreen.tsx +10 -18
  35. package/template/src/components/scenes/OSWindowScene.tsx +1 -1
  36. package/template/src/components/scenes/SplitComparison.tsx +41 -14
  37. package/template/src/custom-scenes.ts +19 -0
  38. package/template/src/duration.ts +43 -4
  39. package/template/src/index.ts +9 -5
  40. package/template/src/theme.ts +10 -1
  41. package/assets/audio/bright-sparks.mp3 +0 -0
  42. package/assets/audio/calm-keys.mp3 +0 -0
  43. package/assets/audio/circuit-pulse.mp3 +0 -0
  44. package/assets/audio/clean-horizon.mp3 +0 -0
  45. package/assets/audio/generate-tracks.mjs +0 -218
  46. package/assets/audio/midnight-protocol.mp3 +0 -0
  47. package/assets/audio/pixel-bounce.mp3 +0 -0
  48. package/assets/audio/steady-launch.mp3 +0 -0
  49. package/assets/audio/sunny-loop.mp3 +0 -0
  50. package/assets/audio/vector-grid.mp3 +0 -0
@@ -1,16 +1,23 @@
1
1
  # ReelMe Bundled Audio Provenance
2
2
 
3
- The bundled tracks in this directory are instrumental loops generated by
4
- `generate-tracks.mjs` for ReelMe. They do not sample third-party recordings,
5
- loops, MIDI files, or stems.
3
+ The bundled music beds in this directory are curated **CC0 (public domain)**
4
+ loops from [Freesound](https://freesound.org). Each `manifest.json` entry lists
5
+ the uploader (`artist`) and the sound's Freesound page (`source`); CC0 requires
6
+ no attribution, but the credits are kept as a courtesy. The files were loudness-
7
+ normalized (`loudnorm I=-16:TP=-1.5`) to sit under the mix and re-encoded as MP3;
8
+ they are otherwise unedited.
6
9
 
7
- The generated tracks are dedicated to the public domain under CC0 1.0. The
8
- manifest source URLs point to the generator definition used to create each
9
- track. Regenerate the MP3 files with:
10
+ | file | Freesound | uploader |
11
+ |---|---|---|
12
+ | `lofi-dusk.mp3` | https://freesound.org/s/852231/ | holizna |
13
+ | `warm-memories.mp3` | https://freesound.org/s/826622/ | xkeril |
14
+ | `neon-pulse.mp3` | https://freesound.org/s/415922/ | newagesoup |
15
+ | `night-drive.mp3` | https://freesound.org/s/211555/ | waveplaySFX |
16
+ | `sunny-bounce.mp3` | https://freesound.org/s/512340/ | mistermender |
17
+ | `light-steps.mp3` | https://freesound.org/s/410520/ | Jamessi |
10
18
 
11
- ```bash
12
- node cli/assets/audio/generate-tracks.mjs
13
- ```
19
+ **Beat sync.** Each bed is an exact whole-beat loop at the `bpm` recorded in
20
+ `manifest.json`, so the CLI can quantize scene cuts to the beat and the loop
21
+ stays locked when Remotion repeats it (`sunny-bounce` is within ~0.1 beat).
14
22
 
15
- Each track is 32 seconds long, encoded as MP3, and kept below 2 MB for package
16
- size.
23
+ Each file is kept below 2 MB for package size.
@@ -0,0 +1,128 @@
1
+ // Procedural SFX generator — FALLBACK ONLY. The shipped cues in sfx/ are
2
+ // curated ElevenLabs sound effects (see sfx/SOURCES.md); this script exists so
3
+ // the cues can be regenerated from nothing if those files are ever removed.
4
+ // It REFUSES to overwrite existing files unless run with --force.
5
+ //
6
+ // whoosh.mp3 — air sweep for fast lateral cuts (whip/wipe/flip)
7
+ // pop.mp3 — soft low knock for settle cuts (zoom/punch/rise/fade)
8
+ // rise.mp3 — swell into the end card (CTA), peak at 0.6s
9
+ //
10
+ // node cli/assets/audio/generate-sfx.mjs --force (requires ffmpeg on PATH)
11
+ import { writeFileSync, rmSync, existsSync } from "node:fs";
12
+ import { spawnSync } from "node:child_process";
13
+ import { dirname, join } from "node:path";
14
+ import { fileURLToPath } from "node:url";
15
+
16
+ const SAMPLE_RATE = 44100;
17
+ const TWO_PI = Math.PI * 2;
18
+
19
+ // Deterministic noise so regeneration is reproducible.
20
+ function makeNoise() {
21
+ let seed = 0x9e3779b9;
22
+ return () => {
23
+ seed ^= seed << 13;
24
+ seed ^= seed >>> 17;
25
+ seed ^= seed << 5;
26
+ seed >>>= 0;
27
+ return (seed / 0xffffffff) * 2 - 1;
28
+ };
29
+ }
30
+
31
+ // One-pole band-ish filter state per render call.
32
+ function renderWhoosh(t, dur, noise, state) {
33
+ // Noise through a swept low-pass: dark → bright → gone. Reads as air, not tape hiss.
34
+ const p = t / dur;
35
+ const cutoff = 400 + 5200 * Math.sin(Math.PI * p) ** 2;
36
+ const alpha = Math.min(1, cutoff / (SAMPLE_RATE / 2 / Math.PI));
37
+ state.lp += alpha * (noise() - state.lp);
38
+ const body = state.lp - state.lp2;
39
+ state.lp2 += 0.02 * (state.lp - state.lp2); // high-pass against rumble
40
+ const envelope = Math.sin(Math.PI * p) ** 1.6;
41
+ return body * envelope * 2.2;
42
+ }
43
+
44
+ function renderPop(t, dur) {
45
+ // A soft knock: quickly decaying low sine with a faint click transient.
46
+ const knock = Math.sin(TWO_PI * 130 * t * (1 - t * 1.6)) * Math.exp(-t * 42);
47
+ const click = Math.sin(TWO_PI * 1900 * t) * Math.exp(-t * 300) * 0.25;
48
+ return (knock + click) * 0.9;
49
+ }
50
+
51
+ function renderRise(t, dur, noise, state) {
52
+ // Swell: filtered noise + a fifth dyad crescendo that stops just before the
53
+ // peak — the visual landing supplies the actual accent.
54
+ const p = t / dur;
55
+ const cutoff = 250 + 2600 * p * p;
56
+ const alpha = Math.min(1, cutoff / (SAMPLE_RATE / 2 / Math.PI));
57
+ state.lp += alpha * (noise() - state.lp);
58
+ const air = state.lp * 0.7;
59
+ const dyad = (Math.sin(TWO_PI * 220 * t) + Math.sin(TWO_PI * 330 * t) * 0.6) * 0.18;
60
+ const envelope = p * p * (p < 0.92 ? 1 : (1 - p) / 0.08);
61
+ return (air + dyad) * envelope * 1.4;
62
+ }
63
+
64
+ const SFX = [
65
+ { file: "whoosh.mp3", dur: 0.42, render: renderWhoosh },
66
+ { file: "pop.mp3", dur: 0.18, render: renderPop },
67
+ { file: "rise.mp3", dur: 0.85, render: renderRise },
68
+ ];
69
+
70
+ function writeWav(path, sfx) {
71
+ const frames = Math.round(SAMPLE_RATE * sfx.dur);
72
+ const dataSize = frames * 2;
73
+ const buffer = Buffer.alloc(44 + dataSize);
74
+ buffer.write("RIFF", 0);
75
+ buffer.writeUInt32LE(36 + dataSize, 4);
76
+ buffer.write("WAVE", 8);
77
+ buffer.write("fmt ", 12);
78
+ buffer.writeUInt32LE(16, 16);
79
+ buffer.writeUInt16LE(1, 20);
80
+ buffer.writeUInt16LE(1, 22);
81
+ buffer.writeUInt32LE(SAMPLE_RATE, 24);
82
+ buffer.writeUInt32LE(SAMPLE_RATE * 2, 28);
83
+ buffer.writeUInt16LE(2, 32);
84
+ buffer.writeUInt16LE(16, 34);
85
+ buffer.write("data", 36);
86
+ buffer.writeUInt32LE(dataSize, 40);
87
+
88
+ const noise = makeNoise();
89
+ const state = { lp: 0, lp2: 0 };
90
+ for (let i = 0; i < frames; i += 1) {
91
+ const t = i / SAMPLE_RATE;
92
+ const s = Math.max(-0.98, Math.min(0.98, sfx.render(t, sfx.dur, noise, state)));
93
+ buffer.writeInt16LE(Math.round(s * 32767), 44 + i * 2);
94
+ }
95
+ writeFileSync(path, buffer);
96
+ }
97
+
98
+ const outDir = join(dirname(fileURLToPath(import.meta.url)), "sfx");
99
+ spawnSync("mkdir", ["-p", outDir]);
100
+
101
+ const force = process.argv.includes("--force");
102
+ const existing = SFX.filter((s) => existsSync(join(outDir, s.file)));
103
+ if (existing.length > 0 && !force) {
104
+ console.error(
105
+ `refusing to overwrite curated SFX (${existing.map((s) => s.file).join(", ")}). ` +
106
+ `These are hand-picked ElevenLabs cues (sfx/SOURCES.md); rerun with --force to replace them with the procedural fallback.`
107
+ );
108
+ process.exit(1);
109
+ }
110
+
111
+ for (const sfx of SFX) {
112
+ const wavPath = join(outDir, sfx.file.replace(".mp3", ".wav"));
113
+ const mp3Path = join(outDir, sfx.file);
114
+ writeWav(wavPath, sfx);
115
+ const result = spawnSync(
116
+ "ffmpeg",
117
+ ["-y", "-hide_banner", "-loglevel", "error", "-i", wavPath, "-af", "loudnorm=I=-23:TP=-2", "-codec:a", "libmp3lame", "-b:a", "96k", mp3Path],
118
+ { stdio: "inherit" }
119
+ );
120
+ if (result.error || result.status !== 0) {
121
+ rmSync(wavPath, { force: true });
122
+ rmSync(mp3Path, { force: true });
123
+ process.exitCode = result.status || 1;
124
+ break;
125
+ }
126
+ rmSync(wavPath, { force: true });
127
+ console.log(`wrote ${mp3Path}`);
128
+ }
Binary file
Binary file
@@ -1,83 +1,76 @@
1
1
  [
2
2
  {
3
- "file": "calm-keys.mp3",
4
- "title": "Calm Keys",
5
- "artist": "ReelMe procedural audio generator",
6
- "source": "https://github.com/RubenGlez/reelme/blob/main/cli/assets/audio/generate-tracks.mjs#calm-keys",
3
+ "file": "lofi-dusk.mp3",
4
+ "title": "Lofi Dusk",
5
+ "artist": "holizna",
6
+ "source": "https://freesound.org/s/852231/",
7
7
  "license": "CC0",
8
- "tones": ["professional"],
9
- "duration": 32
8
+ "tones": [
9
+ "professional"
10
+ ],
11
+ "duration": 35,
12
+ "bpm": 82
10
13
  },
11
14
  {
12
- "file": "steady-launch.mp3",
13
- "title": "Steady Launch",
14
- "artist": "ReelMe procedural audio generator",
15
- "source": "https://github.com/RubenGlez/reelme/blob/main/cli/assets/audio/generate-tracks.mjs#steady-launch",
15
+ "file": "warm-memories.mp3",
16
+ "title": "Warm Memories",
17
+ "artist": "xkeril",
18
+ "source": "https://freesound.org/s/826622/",
16
19
  "license": "CC0",
17
- "tones": ["professional", "technical"],
18
- "duration": 32
20
+ "tones": [
21
+ "professional"
22
+ ],
23
+ "duration": 20,
24
+ "bpm": 108
19
25
  },
20
26
  {
21
- "file": "clean-horizon.mp3",
22
- "title": "Clean Horizon",
23
- "artist": "ReelMe procedural audio generator",
24
- "source": "https://github.com/RubenGlez/reelme/blob/main/cli/assets/audio/generate-tracks.mjs#clean-horizon",
27
+ "file": "neon-pulse.mp3",
28
+ "title": "Neon Pulse",
29
+ "artist": "newagesoup",
30
+ "source": "https://freesound.org/s/415922/",
25
31
  "license": "CC0",
26
- "tones": ["professional"],
27
- "duration": 32
32
+ "tones": [
33
+ "technical",
34
+ "playful"
35
+ ],
36
+ "duration": 17,
37
+ "bpm": 112
28
38
  },
29
39
  {
30
- "file": "bright-sparks.mp3",
31
- "title": "Bright Sparks",
32
- "artist": "ReelMe procedural audio generator",
33
- "source": "https://github.com/RubenGlez/reelme/blob/main/cli/assets/audio/generate-tracks.mjs#bright-sparks",
40
+ "file": "night-drive.mp3",
41
+ "title": "Night Drive",
42
+ "artist": "waveplaySFX",
43
+ "source": "https://freesound.org/s/211555/",
34
44
  "license": "CC0",
35
- "tones": ["playful"],
36
- "duration": 32
45
+ "tones": [
46
+ "technical"
47
+ ],
48
+ "duration": 30,
49
+ "bpm": 128
37
50
  },
38
51
  {
39
- "file": "pixel-bounce.mp3",
40
- "title": "Pixel Bounce",
41
- "artist": "ReelMe procedural audio generator",
42
- "source": "https://github.com/RubenGlez/reelme/blob/main/cli/assets/audio/generate-tracks.mjs#pixel-bounce",
52
+ "file": "sunny-bounce.mp3",
53
+ "title": "Sunny Bounce",
54
+ "artist": "mistermender",
55
+ "source": "https://freesound.org/s/512340/",
43
56
  "license": "CC0",
44
- "tones": ["playful", "technical"],
45
- "duration": 32
57
+ "tones": [
58
+ "playful"
59
+ ],
60
+ "duration": 58,
61
+ "bpm": 126
46
62
  },
47
63
  {
48
- "file": "sunny-loop.mp3",
49
- "title": "Sunny Loop",
50
- "artist": "ReelMe procedural audio generator",
51
- "source": "https://github.com/RubenGlez/reelme/blob/main/cli/assets/audio/generate-tracks.mjs#sunny-loop",
64
+ "file": "light-steps.mp3",
65
+ "title": "Light Steps",
66
+ "artist": "Jamessi",
67
+ "source": "https://freesound.org/s/410520/",
52
68
  "license": "CC0",
53
- "tones": ["playful"],
54
- "duration": 32
55
- },
56
- {
57
- "file": "circuit-pulse.mp3",
58
- "title": "Circuit Pulse",
59
- "artist": "ReelMe procedural audio generator",
60
- "source": "https://github.com/RubenGlez/reelme/blob/main/cli/assets/audio/generate-tracks.mjs#circuit-pulse",
61
- "license": "CC0",
62
- "tones": ["technical"],
63
- "duration": 32
64
- },
65
- {
66
- "file": "vector-grid.mp3",
67
- "title": "Vector Grid",
68
- "artist": "ReelMe procedural audio generator",
69
- "source": "https://github.com/RubenGlez/reelme/blob/main/cli/assets/audio/generate-tracks.mjs#vector-grid",
70
- "license": "CC0",
71
- "tones": ["technical"],
72
- "duration": 32
73
- },
74
- {
75
- "file": "midnight-protocol.mp3",
76
- "title": "Midnight Protocol",
77
- "artist": "ReelMe procedural audio generator",
78
- "source": "https://github.com/RubenGlez/reelme/blob/main/cli/assets/audio/generate-tracks.mjs#midnight-protocol",
79
- "license": "CC0",
80
- "tones": ["technical", "professional"],
81
- "duration": 32
69
+ "tones": [
70
+ "playful",
71
+ "professional"
72
+ ],
73
+ "duration": 35,
74
+ "bpm": 110
82
75
  }
83
76
  ]
Binary file
Binary file
@@ -0,0 +1,16 @@
1
+ # SFX provenance
2
+
3
+ The three cue files in this directory are curated **CC0 (public domain)** cues
4
+ from [Freesound](https://freesound.org), loudness-normalized (`loudnorm
5
+ I=-23:TP=-2`) to sit under the bundled music beds. CC0 requires no attribution;
6
+ the uploader credits are kept as a courtesy.
7
+
8
+ | file | Freesound | uploader | role |
9
+ |---|---|---|---|
10
+ | `whoosh.mp3` | https://freesound.org/s/60013/ | qubodup | fast lateral cut (soft air whoosh) |
11
+ | `pop.mp3` | https://freesound.org/s/245645/ | unfa | settle cut (clean pop) |
12
+ | `rise.mp3` | https://freesound.org/s/523057/ | magnuswaker | CTA swell (played from 18 frames before the cut) |
13
+
14
+ The peaks land where `template/src/Root.tsx` schedules each cue. If these ever
15
+ need regenerating without Freesound, `node cli/assets/audio/generate-sfx.mjs
16
+ --force` produces synthetic CC0 stand-ins.
Binary file
Binary file
Binary file
Binary file
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reelme",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
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": {
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
 
@@ -82,6 +82,7 @@ const SCENE_REQUIRED = {
82
82
  hook: ["text"],
83
83
  clip: ["src", "frame"],
84
84
  benchmark: ["bars"],
85
+ custom: ["component", "durationInFrames"],
85
86
  };
86
87
  const ARRAY_FIELDS = new Set([
87
88
  "commands", "nodes", "edges", "items", "stats", "entries", "keys", "bars",
@@ -118,6 +119,7 @@ function validateScene(scene, where, errors) {
118
119
  ["clip", "src", CLIP_EXTS],
119
120
  ["mobile", "screenshot", IMAGE_EXTS],
120
121
  ["browser", "image", IMAGE_EXTS],
122
+ ["custom", "component", ["tsx"]],
121
123
  ];
122
124
  for (const [type, field, exts] of assetChecks) {
123
125
  if (scene.type === type && typeof scene[field] === "string" && !exts.includes(extOf(scene[field]))) {
@@ -252,8 +254,28 @@ export function ensureScaffold(repoRoot) {
252
254
  filter: (src) => !SCAFFOLD_EXCLUDES.has(basename(src)),
253
255
  });
254
256
 
255
- // The user's brief always wins over the template sample.
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.
256
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);
257
279
 
258
280
  if (!existsSync(join(cacheDir, "node_modules"))) {
259
281
  console.log("reelme: installing render dependencies (first run only)…");
@@ -266,6 +288,74 @@ export function ensureScaffold(repoRoot) {
266
288
  return cacheDir;
267
289
  }
268
290
 
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
+
269
359
  // Removes the current project's cache by default; pass `all` to wipe every
270
360
  // project's cache (F16). A moved/renamed repo hashes to a new dir, so `--all`
271
361
  // is the way to reclaim orphaned caches.
package/src/render.mjs CHANGED
@@ -146,6 +146,13 @@ function copyAudioTrack(cacheDir, brief) {
146
146
  const audioDir = join(cacheDir, "public", "audio");
147
147
  mkdirSync(audioDir, { recursive: true });
148
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
+ }
149
156
  }
150
157
 
151
158
  function copyLogo(repoRoot, cacheDir, brief) {
@@ -3,13 +3,15 @@ import { AbsoluteFill, Audio, Sequence, staticFile, useVideoConfig } from "remot
3
3
  import { Brief, ProjectMeta, Scene } from "./brief";
4
4
  import { PlatformPreset } from "./platforms";
5
5
  import { buildTheme } from "./theme";
6
- import { sceneDuration } from "./duration";
6
+ import { sceneDurationsOnGrid } from "./duration";
7
7
  import { audioVolume } from "./audio";
8
- import { resolveLook } from "./cinematic/look";
8
+ import { resolveLook, TransitionStyle } from "./cinematic/look";
9
9
  import { Atmosphere, Grain } from "./cinematic/Atmosphere";
10
10
  import { Camera } from "./cinematic/Camera";
11
11
  import { Enter, transitionFor } from "./cinematic/transitions";
12
12
  import "./fonts";
13
+ import { CUSTOM_SCENES } from "./custom-scenes";
14
+ import { Caption } from "./components/primitives/Caption";
13
15
  import { Problem } from "./components/scenes/Problem";
14
16
  import { CodeReveal } from "./components/scenes/CodeReveal";
15
17
  import { TerminalScene } from "./components/scenes/TerminalScene";
@@ -41,11 +43,12 @@ export const Reel: React.FC<ReelProps> = ({ brief, platform, cut }) => {
41
43
  // The look owns motion personality: override the tone-derived default so every
42
44
  // scene's springs (which read theme.motion) inherit the look's physics.
43
45
  const theme = {
44
- ...buildTheme(brief.project.primaryColor || "#6366f1", brief.project.font, brief.project.monoFont, brief.project.tone, brief.project.bgStyle),
46
+ ...buildTheme(brief.project.primaryColor || "#6366f1", brief.project.font, brief.project.monoFont, brief.project.tone, brief.project.bgStyle, brief.project.secondaryColor),
45
47
  motion: look.motion,
46
48
  };
47
49
  const isGif = platform.output.codec === "gif";
48
50
  const shouldRenderAudio = !isGif && Boolean(brief.project.audio);
51
+ const bpm = brief.project.audio ? brief.project.audio.bpm : undefined;
49
52
 
50
53
  // Cut selection: teaser when requested, otherwise the cut named by the
51
54
  // platform preset, falling back to main when vertical is absent.
@@ -56,10 +59,13 @@ export const Reel: React.FC<ReelProps> = ({ brief, platform, cut }) => {
56
59
  ? (brief.cuts.vertical?.length ? brief.cuts.vertical : brief.cuts.main)
57
60
  : brief.cuts.main;
58
61
 
62
+ // Cut timing on the music's beat grid (falls back to natural durations when
63
+ // no track/bpm) — the same grid index.ts used for the composition length.
64
+ const durations = sceneDurationsOnGrid(scenes, fps, bpm);
59
65
  let cursor = 0;
60
- const sequenced = scenes.map((scene) => {
66
+ const sequenced = scenes.map((scene, i) => {
61
67
  const from = cursor;
62
- const duration = sceneDuration(scene);
68
+ const duration = durations[i];
63
69
  cursor += duration;
64
70
  return { scene, from, duration };
65
71
  });
@@ -96,11 +102,57 @@ export const Reel: React.FC<ReelProps> = ({ brief, platform, cut }) => {
96
102
  ))}
97
103
  </div>
98
104
 
105
+ {/* Cut SFX: a subtle cue under each transition makes the edit tactile.
106
+ The end-card gets a rise that starts BEFORE its cut so the swell
107
+ lands on the arrival, not after it. */}
108
+ {shouldRenderAudio &&
109
+ sequenced.map(({ from }, i) => {
110
+ if (i === 0) return null;
111
+ const isLast = i === sequenced.length - 1;
112
+ const style = transitionFor(look, i, sequenced.length);
113
+ const sfx = isLast ? null : cutSfx(style);
114
+ if (isLast) {
115
+ return (
116
+ <Sequence key={`sfx-${i}`} from={Math.max(0, from - 18)} durationInFrames={40}>
117
+ <Audio src={staticFile("audio/sfx/rise.mp3")} volume={0.5} />
118
+ </Sequence>
119
+ );
120
+ }
121
+ if (!sfx) return null;
122
+ return (
123
+ <Sequence key={`sfx-${i}`} from={from} durationInFrames={20}>
124
+ <Audio src={staticFile(`audio/sfx/${sfx.file}`)} volume={sfx.volume} />
125
+ </Sequence>
126
+ );
127
+ })}
128
+
99
129
  {!isGif && <Grain look={look} />}
100
130
  </AbsoluteFill>
101
131
  );
102
132
  };
103
133
 
134
+ /**
135
+ * Which cue a transition earns. Fast lateral cuts get air (whoosh); settle
136
+ * cuts get a soft knock; hard cuts stay silent — silence is also a cue.
137
+ */
138
+ function cutSfx(style: TransitionStyle): { file: string; volume: number } | null {
139
+ switch (style) {
140
+ case "whip":
141
+ case "wipe":
142
+ case "flip":
143
+ return { file: "whoosh.mp3", volume: 0.4 };
144
+ case "punch":
145
+ case "zoom":
146
+ return { file: "pop.mp3", volume: 0.45 };
147
+ case "rise":
148
+ case "fade":
149
+ case "dip":
150
+ return { file: "pop.mp3", volume: 0.25 };
151
+ default:
152
+ return null;
153
+ }
154
+ }
155
+
104
156
  interface SceneRendererProps {
105
157
  scene: Scene;
106
158
  theme: ReturnType<typeof buildTheme>;
@@ -122,7 +174,7 @@ const SceneRenderer: React.FC<SceneRendererProps> = ({ scene, theme, project, pl
122
174
  case "cta":
123
175
  return <CTA scene={scene} theme={theme} project={project} platform={platform} bottomInset={bottomInset} />;
124
176
  case "browser":
125
- return <BrowserFrame scene={scene} theme={theme} bottomInset={bottomInset} />;
177
+ return <BrowserFrame scene={scene} theme={theme} platform={platform} bottomInset={bottomInset} />;
126
178
  case "split":
127
179
  return <SplitComparison scene={scene} theme={theme} bottomInset={bottomInset} />;
128
180
  case "feature-list":
@@ -132,7 +184,7 @@ const SceneRenderer: React.FC<SceneRendererProps> = ({ scene, theme, project, pl
132
184
  case "file-tree":
133
185
  return <FileTree scene={scene} theme={theme} bottomInset={bottomInset} />;
134
186
  case "mobile":
135
- return <MobileScreen scene={scene} theme={theme} bottomInset={bottomInset} />;
187
+ return <MobileScreen scene={scene} theme={theme} platform={platform} bottomInset={bottomInset} />;
136
188
  case "os-window":
137
189
  return <OSWindow scene={scene} theme={theme} bottomInset={bottomInset} />;
138
190
  case "hotkey":
@@ -143,6 +195,22 @@ const SceneRenderer: React.FC<SceneRendererProps> = ({ scene, theme, project, pl
143
195
  return <Clip scene={scene} theme={theme} bottomInset={bottomInset} />;
144
196
  case "benchmark":
145
197
  return <Benchmark scene={scene} theme={theme} bottomInset={bottomInset} />;
198
+ case "custom": {
199
+ const Custom = CUSTOM_SCENES[scene.component];
200
+ if (!Custom) {
201
+ // The CLI stages and registers referenced components; reaching this
202
+ // means a direct-render path skipped staging. Fail soft on one scene
203
+ // rather than killing the whole reel.
204
+ console.warn(`reelme: custom scene component not registered: ${scene.component}`);
205
+ return null;
206
+ }
207
+ return (
208
+ <>
209
+ <Custom theme={theme} project={project} platform={platform} bottomInset={bottomInset} caption={scene.caption} />
210
+ {scene.caption && <Caption text={scene.caption} theme={theme} startFrame={40} bottomInset={bottomInset} />}
211
+ </>
212
+ );
213
+ }
146
214
  default:
147
215
  return null;
148
216
  }