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.
- 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 +1 -1
- package/src/cache.mjs +92 -2
- package/src/render.mjs +7 -0
- package/template/src/Root.tsx +75 -7
- package/template/src/brief.ts +26 -3
- package/template/src/cinematic/Atmosphere.tsx +176 -30
- package/template/src/cinematic/Camera.tsx +21 -9
- package/template/src/cinematic/look.ts +28 -4
- package/template/src/cinematic/transitions.tsx +4 -2
- package/template/src/components/primitives/Arrow.tsx +8 -5
- package/template/src/components/primitives/CodeBlock.tsx +21 -7
- package/template/src/components/primitives/RevealText.tsx +8 -2
- package/template/src/components/primitives/Terminal.tsx +24 -7
- package/template/src/components/scenes/BrowserFrame.tsx +12 -5
- package/template/src/components/scenes/CTA.tsx +4 -2
- package/template/src/components/scenes/Clip.tsx +27 -14
- package/template/src/components/scenes/CodeReveal.tsx +8 -10
- package/template/src/components/scenes/DataFlow.tsx +46 -18
- package/template/src/components/scenes/FileTree.tsx +22 -6
- package/template/src/components/scenes/Hook.tsx +4 -3
- package/template/src/components/scenes/MobileScreen.tsx +10 -18
- package/template/src/components/scenes/OSWindowScene.tsx +1 -1
- package/template/src/components/scenes/SplitComparison.tsx +41 -14
- package/template/src/custom-scenes.ts +19 -0
- package/template/src/duration.ts +43 -4
- package/template/src/index.ts +9 -5
- package/template/src/theme.ts +10 -1
- 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 -218
- 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
|
@@ -1,16 +1,23 @@
|
|
|
1
1
|
# ReelMe Bundled Audio Provenance
|
|
2
2
|
|
|
3
|
-
The bundled
|
|
4
|
-
|
|
5
|
-
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
|
|
12
|
-
|
|
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
|
|
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": "
|
|
4
|
-
"title": "
|
|
5
|
-
"artist": "
|
|
6
|
-
"source": "https://
|
|
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": [
|
|
9
|
-
|
|
8
|
+
"tones": [
|
|
9
|
+
"professional"
|
|
10
|
+
],
|
|
11
|
+
"duration": 35,
|
|
12
|
+
"bpm": 82
|
|
10
13
|
},
|
|
11
14
|
{
|
|
12
|
-
"file": "
|
|
13
|
-
"title": "
|
|
14
|
-
"artist": "
|
|
15
|
-
"source": "https://
|
|
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": [
|
|
18
|
-
|
|
20
|
+
"tones": [
|
|
21
|
+
"professional"
|
|
22
|
+
],
|
|
23
|
+
"duration": 20,
|
|
24
|
+
"bpm": 108
|
|
19
25
|
},
|
|
20
26
|
{
|
|
21
|
-
"file": "
|
|
22
|
-
"title": "
|
|
23
|
-
"artist": "
|
|
24
|
-
"source": "https://
|
|
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": [
|
|
27
|
-
|
|
32
|
+
"tones": [
|
|
33
|
+
"technical",
|
|
34
|
+
"playful"
|
|
35
|
+
],
|
|
36
|
+
"duration": 17,
|
|
37
|
+
"bpm": 112
|
|
28
38
|
},
|
|
29
39
|
{
|
|
30
|
-
"file": "
|
|
31
|
-
"title": "
|
|
32
|
-
"artist": "
|
|
33
|
-
"source": "https://
|
|
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": [
|
|
36
|
-
|
|
45
|
+
"tones": [
|
|
46
|
+
"technical"
|
|
47
|
+
],
|
|
48
|
+
"duration": 30,
|
|
49
|
+
"bpm": 128
|
|
37
50
|
},
|
|
38
51
|
{
|
|
39
|
-
"file": "
|
|
40
|
-
"title": "
|
|
41
|
-
"artist": "
|
|
42
|
-
"source": "https://
|
|
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": [
|
|
45
|
-
|
|
57
|
+
"tones": [
|
|
58
|
+
"playful"
|
|
59
|
+
],
|
|
60
|
+
"duration": 58,
|
|
61
|
+
"bpm": 126
|
|
46
62
|
},
|
|
47
63
|
{
|
|
48
|
-
"file": "
|
|
49
|
-
"title": "
|
|
50
|
-
"artist": "
|
|
51
|
-
"source": "https://
|
|
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": [
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
"
|
|
58
|
-
"
|
|
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
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) {
|
package/template/src/Root.tsx
CHANGED
|
@@ -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 {
|
|
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 =
|
|
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
|
}
|