reelme 0.2.2 → 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.
- package/README.md +10 -3
- package/assets/audio/PROVENANCE.md +16 -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 +218 -0
- package/assets/audio/manifest.json +83 -0
- 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/package.json +9 -2
- package/src/cache.mjs +155 -14
- package/src/cli.mjs +20 -11
- package/src/render.mjs +172 -9
- package/template/package.json +6 -5
- package/template/pnpm-lock.yaml +186 -116
- package/template/pnpm-workspace.yaml +2 -0
- package/template/src/Root.tsx +73 -51
- package/template/src/audio.ts +24 -0
- package/template/src/benchmark.ts +18 -0
- package/template/src/brief.json +7 -6
- package/template/src/brief.ts +73 -5
- package/template/src/cinematic/Atmosphere.tsx +139 -0
- package/template/src/cinematic/Camera.tsx +54 -0
- package/template/src/cinematic/look.ts +106 -0
- package/template/src/cinematic/transitions.tsx +110 -0
- package/template/src/components/primitives/Bar.tsx +86 -0
- package/template/src/components/primitives/Caption.tsx +5 -4
- package/template/src/components/primitives/Icon.tsx +7 -0
- package/template/src/components/primitives/KeyPill.tsx +1 -1
- package/template/src/components/primitives/Kicker.tsx +46 -0
- package/template/src/components/primitives/Label.tsx +22 -25
- package/template/src/components/primitives/Loop.tsx +65 -0
- package/template/src/components/primitives/RevealText.tsx +123 -0
- package/template/src/components/primitives/Stage.tsx +62 -0
- package/template/src/components/primitives/Terminal.tsx +11 -0
- package/template/src/components/scenes/Benchmark.tsx +83 -0
- package/template/src/components/scenes/BrowserFrame.tsx +5 -4
- package/template/src/components/scenes/CTA.tsx +67 -58
- package/template/src/components/scenes/Clip.tsx +131 -0
- package/template/src/components/scenes/CodeReveal.tsx +10 -8
- package/template/src/components/scenes/DataFlow.tsx +5 -4
- package/template/src/components/scenes/FeatureList.tsx +80 -78
- package/template/src/components/scenes/FileTree.tsx +5 -4
- package/template/src/components/scenes/Hook.tsx +55 -0
- package/template/src/components/scenes/HotkeyScene.tsx +8 -6
- package/template/src/components/scenes/MobileScreen.tsx +308 -155
- package/template/src/components/scenes/OSWindowScene.tsx +8 -7
- package/template/src/components/scenes/Problem.tsx +29 -30
- package/template/src/components/scenes/SplitComparison.tsx +65 -92
- package/template/src/components/scenes/StatCallout.tsx +162 -18
- package/template/src/components/scenes/TerminalScene.tsx +16 -16
- package/template/src/duration.ts +23 -2
- package/template/src/index.ts +7 -5
- package/template/src/platforms.ts +4 -0
- 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
|
|
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
|
|
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
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# ReelMe Bundled Audio Provenance
|
|
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.
|
|
6
|
+
|
|
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
|
+
|
|
11
|
+
```bash
|
|
12
|
+
node cli/assets/audio/generate-tracks.mjs
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Each track is 32 seconds long, encoded as MP3, and kept below 2 MB for package
|
|
16
|
+
size.
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { rmSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { spawnSync } from "node:child_process";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
|
|
8
|
+
const SAMPLE_RATE = 44100;
|
|
9
|
+
const DURATION = 32;
|
|
10
|
+
const TWO_PI = Math.PI * 2;
|
|
11
|
+
|
|
12
|
+
const TRACKS = [
|
|
13
|
+
{
|
|
14
|
+
id: "calm-keys",
|
|
15
|
+
file: "calm-keys.mp3",
|
|
16
|
+
bpm: 92,
|
|
17
|
+
key: 57,
|
|
18
|
+
scale: [0, 2, 4, 7, 9],
|
|
19
|
+
chords: [[0, 4, 7, 11], [9, 0, 4, 7], [5, 9, 0, 4], [7, 11, 2, 5]],
|
|
20
|
+
melody: [0, 2, 4, 7, 4, 2, 0, 9],
|
|
21
|
+
mood: "soft",
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
id: "steady-launch",
|
|
25
|
+
file: "steady-launch.mp3",
|
|
26
|
+
bpm: 104,
|
|
27
|
+
key: 50,
|
|
28
|
+
scale: [0, 2, 5, 7, 9],
|
|
29
|
+
chords: [[0, 5, 9], [7, 0, 5], [9, 2, 5], [5, 9, 0]],
|
|
30
|
+
melody: [0, 5, 7, 9, 7, 5, 2, 0],
|
|
31
|
+
mood: "drive",
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
id: "clean-horizon",
|
|
35
|
+
file: "clean-horizon.mp3",
|
|
36
|
+
bpm: 88,
|
|
37
|
+
key: 55,
|
|
38
|
+
scale: [0, 2, 4, 7, 11],
|
|
39
|
+
chords: [[0, 4, 7], [4, 7, 11], [9, 0, 4], [7, 11, 2]],
|
|
40
|
+
melody: [7, 4, 2, 0, 2, 4, 7, 11],
|
|
41
|
+
mood: "soft",
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
id: "bright-sparks",
|
|
45
|
+
file: "bright-sparks.mp3",
|
|
46
|
+
bpm: 118,
|
|
47
|
+
key: 60,
|
|
48
|
+
scale: [0, 2, 4, 7, 9],
|
|
49
|
+
chords: [[0, 4, 7], [5, 9, 0], [7, 11, 2], [4, 7, 11]],
|
|
50
|
+
melody: [0, 4, 7, 9, 7, 4, 2, 4],
|
|
51
|
+
mood: "bounce",
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
id: "pixel-bounce",
|
|
55
|
+
file: "pixel-bounce.mp3",
|
|
56
|
+
bpm: 126,
|
|
57
|
+
key: 58,
|
|
58
|
+
scale: [0, 3, 5, 7, 10],
|
|
59
|
+
chords: [[0, 3, 7], [10, 3, 5], [5, 8, 0], [7, 10, 2]],
|
|
60
|
+
melody: [0, 3, 7, 10, 7, 3, 5, 7],
|
|
61
|
+
mood: "chip",
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
id: "sunny-loop",
|
|
65
|
+
file: "sunny-loop.mp3",
|
|
66
|
+
bpm: 112,
|
|
67
|
+
key: 62,
|
|
68
|
+
scale: [0, 2, 4, 7, 9],
|
|
69
|
+
chords: [[0, 4, 7], [9, 0, 4], [5, 9, 0], [7, 11, 2]],
|
|
70
|
+
melody: [4, 7, 9, 12, 9, 7, 4, 2],
|
|
71
|
+
mood: "bounce",
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
id: "circuit-pulse",
|
|
75
|
+
file: "circuit-pulse.mp3",
|
|
76
|
+
bpm: 124,
|
|
77
|
+
key: 45,
|
|
78
|
+
scale: [0, 2, 3, 7, 10],
|
|
79
|
+
chords: [[0, 3, 7], [7, 10, 2], [3, 7, 10], [10, 2, 5]],
|
|
80
|
+
melody: [0, 7, 3, 10, 2, 7, 3, 0],
|
|
81
|
+
mood: "pulse",
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
id: "vector-grid",
|
|
85
|
+
file: "vector-grid.mp3",
|
|
86
|
+
bpm: 116,
|
|
87
|
+
key: 48,
|
|
88
|
+
scale: [0, 2, 5, 7, 10],
|
|
89
|
+
chords: [[0, 5, 10], [7, 0, 5], [10, 2, 7], [5, 10, 0]],
|
|
90
|
+
melody: [0, 2, 5, 7, 10, 7, 5, 2],
|
|
91
|
+
mood: "pulse",
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
id: "midnight-protocol",
|
|
95
|
+
file: "midnight-protocol.mp3",
|
|
96
|
+
bpm: 98,
|
|
97
|
+
key: 47,
|
|
98
|
+
scale: [0, 2, 3, 7, 9],
|
|
99
|
+
chords: [[0, 3, 7], [9, 0, 3], [7, 10, 2], [3, 7, 9]],
|
|
100
|
+
melody: [0, 3, 7, 9, 7, 3, 2, 0],
|
|
101
|
+
mood: "drive",
|
|
102
|
+
},
|
|
103
|
+
];
|
|
104
|
+
|
|
105
|
+
function midiToFreq(note) {
|
|
106
|
+
return 440 * 2 ** ((note - 69) / 12);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function triangle(phase) {
|
|
110
|
+
return (2 / Math.PI) * Math.asin(Math.sin(phase));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function env(position, attack, release) {
|
|
114
|
+
if (position < attack) return position / attack;
|
|
115
|
+
return Math.exp(-(position - attack) * release);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function renderSample(track, t, i) {
|
|
119
|
+
const beat = 60 / track.bpm;
|
|
120
|
+
const bar = beat * 4;
|
|
121
|
+
const chord = track.chords[Math.floor(t / (bar * 2)) % track.chords.length];
|
|
122
|
+
const chordPos = (t % (bar * 2)) / (bar * 2);
|
|
123
|
+
const noteStep = Math.floor(t / (beat / 2)) % track.melody.length;
|
|
124
|
+
const notePos = (t % (beat / 2)) / (beat / 2);
|
|
125
|
+
const root = track.key;
|
|
126
|
+
|
|
127
|
+
let sample = 0;
|
|
128
|
+
|
|
129
|
+
for (let c = 0; c < chord.length; c += 1) {
|
|
130
|
+
const freq = midiToFreq(root + chord[c]);
|
|
131
|
+
const wobble = 1 + Math.sin(TWO_PI * t * 0.08 + c) * 0.002;
|
|
132
|
+
const amp = track.mood === "soft" ? 0.055 : 0.04;
|
|
133
|
+
sample += Math.sin(TWO_PI * freq * wobble * t) * amp * env(chordPos, 0.08, 1.2);
|
|
134
|
+
sample += triangle(TWO_PI * freq * 2 * t) * amp * 0.18;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Sustained sub-bass that swells once per chord — a smooth harmonic floor
|
|
138
|
+
// rather than a per-beat thump. The old per-beat kick/hat read as a disturbing
|
|
139
|
+
// pulse under narration, so these beds are now ambient, not percussive.
|
|
140
|
+
const bassRoot = root - 24 + chord[0];
|
|
141
|
+
sample += Math.sin(TWO_PI * midiToFreq(bassRoot) * t) * 0.11 * env(chordPos, 0.06, 0.9);
|
|
142
|
+
|
|
143
|
+
const melodyNote = root + 12 + track.melody[noteStep];
|
|
144
|
+
const leadWave = track.mood === "chip"
|
|
145
|
+
? Math.sign(Math.sin(TWO_PI * midiToFreq(melodyNote) * t))
|
|
146
|
+
: triangle(TWO_PI * midiToFreq(melodyNote) * t);
|
|
147
|
+
sample += leadWave * 0.06 * env(notePos, 0.03, track.mood === "soft" ? 4 : 7);
|
|
148
|
+
|
|
149
|
+
return Math.max(-0.98, Math.min(0.98, sample));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function writeWav(path, track) {
|
|
153
|
+
const frames = SAMPLE_RATE * DURATION;
|
|
154
|
+
const dataSize = frames * 2 * 2;
|
|
155
|
+
const buffer = Buffer.alloc(44 + dataSize);
|
|
156
|
+
|
|
157
|
+
buffer.write("RIFF", 0);
|
|
158
|
+
buffer.writeUInt32LE(36 + dataSize, 4);
|
|
159
|
+
buffer.write("WAVE", 8);
|
|
160
|
+
buffer.write("fmt ", 12);
|
|
161
|
+
buffer.writeUInt32LE(16, 16);
|
|
162
|
+
buffer.writeUInt16LE(1, 20);
|
|
163
|
+
buffer.writeUInt16LE(2, 22);
|
|
164
|
+
buffer.writeUInt32LE(SAMPLE_RATE, 24);
|
|
165
|
+
buffer.writeUInt32LE(SAMPLE_RATE * 2 * 2, 28);
|
|
166
|
+
buffer.writeUInt16LE(4, 32);
|
|
167
|
+
buffer.writeUInt16LE(16, 34);
|
|
168
|
+
buffer.write("data", 36);
|
|
169
|
+
buffer.writeUInt32LE(dataSize, 40);
|
|
170
|
+
|
|
171
|
+
for (let i = 0; i < frames; i += 1) {
|
|
172
|
+
const t = i / SAMPLE_RATE;
|
|
173
|
+
const left = renderSample(track, t, i);
|
|
174
|
+
const right = renderSample(track, t + 0.006, i + 17);
|
|
175
|
+
buffer.writeInt16LE(Math.round(left * 32767), 44 + i * 4);
|
|
176
|
+
buffer.writeInt16LE(Math.round(right * 32767), 44 + i * 4 + 2);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
writeFileSync(path, buffer);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const outDir = dirname(fileURLToPath(import.meta.url));
|
|
183
|
+
|
|
184
|
+
for (const track of TRACKS) {
|
|
185
|
+
const wavPath = join(outDir, `${track.id}.wav`);
|
|
186
|
+
const mp3Path = join(outDir, track.file);
|
|
187
|
+
writeWav(wavPath, track);
|
|
188
|
+
const result = spawnSync(
|
|
189
|
+
"ffmpeg",
|
|
190
|
+
[
|
|
191
|
+
"-y",
|
|
192
|
+
"-hide_banner",
|
|
193
|
+
"-loglevel",
|
|
194
|
+
"error",
|
|
195
|
+
"-i",
|
|
196
|
+
wavPath,
|
|
197
|
+
"-af",
|
|
198
|
+
"loudnorm=I=-21:LRA=10:TP=-1.5",
|
|
199
|
+
"-codec:a",
|
|
200
|
+
"libmp3lame",
|
|
201
|
+
"-b:a",
|
|
202
|
+
"96k",
|
|
203
|
+
mp3Path,
|
|
204
|
+
],
|
|
205
|
+
{ stdio: "inherit" }
|
|
206
|
+
);
|
|
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.
|
|
210
|
+
if (result.error || result.status !== 0) {
|
|
211
|
+
rmSync(wavPath, { force: true });
|
|
212
|
+
rmSync(mp3Path, { force: true });
|
|
213
|
+
process.exitCode = result.status || 1;
|
|
214
|
+
break;
|
|
215
|
+
}
|
|
216
|
+
rmSync(wavPath, { force: true });
|
|
217
|
+
console.log(`wrote ${mp3Path}`);
|
|
218
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
[
|
|
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",
|
|
7
|
+
"license": "CC0",
|
|
8
|
+
"tones": ["professional"],
|
|
9
|
+
"duration": 32
|
|
10
|
+
},
|
|
11
|
+
{
|
|
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",
|
|
16
|
+
"license": "CC0",
|
|
17
|
+
"tones": ["professional", "technical"],
|
|
18
|
+
"duration": 32
|
|
19
|
+
},
|
|
20
|
+
{
|
|
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",
|
|
25
|
+
"license": "CC0",
|
|
26
|
+
"tones": ["professional"],
|
|
27
|
+
"duration": 32
|
|
28
|
+
},
|
|
29
|
+
{
|
|
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",
|
|
34
|
+
"license": "CC0",
|
|
35
|
+
"tones": ["playful"],
|
|
36
|
+
"duration": 32
|
|
37
|
+
},
|
|
38
|
+
{
|
|
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",
|
|
43
|
+
"license": "CC0",
|
|
44
|
+
"tones": ["playful", "technical"],
|
|
45
|
+
"duration": 32
|
|
46
|
+
},
|
|
47
|
+
{
|
|
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",
|
|
52
|
+
"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
|
|
82
|
+
}
|
|
83
|
+
]
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "reelme",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Turn your repo into launch videos for social platforms.
|
|
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",
|
|
14
|
+
"assets",
|
|
11
15
|
"template",
|
|
12
16
|
"!template/out",
|
|
13
17
|
"!template/src/__tests__",
|
|
@@ -18,6 +22,9 @@
|
|
|
18
22
|
"engines": {
|
|
19
23
|
"node": ">=18"
|
|
20
24
|
},
|
|
25
|
+
"optionalDependencies": {
|
|
26
|
+
"gifsicle": "^7.0.1"
|
|
27
|
+
},
|
|
21
28
|
"keywords": [
|
|
22
29
|
"launch-video",
|
|
23
30
|
"remotion",
|
package/src/cache.mjs
CHANGED
|
@@ -20,11 +20,25 @@ export const CACHE_ROOT = join(homedir(), ".reelme", "cache");
|
|
|
20
20
|
|
|
21
21
|
const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
22
22
|
export const TEMPLATE_DIR = join(PKG_ROOT, "template");
|
|
23
|
+
export const AUDIO_DIR = join(PKG_ROOT, "assets", "audio");
|
|
23
24
|
|
|
24
25
|
const CLI_VERSION = JSON.parse(
|
|
25
26
|
readFileSync(join(PKG_ROOT, "package.json"), "utf8")
|
|
26
27
|
).version;
|
|
27
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
|
+
|
|
28
42
|
// Not copied into the cache scaffold (mirrors the old SKILL.md rsync excludes).
|
|
29
43
|
const SCAFFOLD_EXCLUDES = new Set([
|
|
30
44
|
"node_modules",
|
|
@@ -47,6 +61,84 @@ export function loadPlatforms() {
|
|
|
47
61
|
);
|
|
48
62
|
}
|
|
49
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
|
+
|
|
50
142
|
export function readBrief(repoRoot) {
|
|
51
143
|
const briefPath = join(repoRoot, "reelme.json");
|
|
52
144
|
if (!existsSync(briefPath)) {
|
|
@@ -82,6 +174,17 @@ export function readBrief(repoRoot) {
|
|
|
82
174
|
if (!Array.isArray(brief.cuts?.main) || brief.cuts.main.length === 0) {
|
|
83
175
|
fail(`cuts.main is missing or empty — the main cut is required.`);
|
|
84
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
|
+
}
|
|
85
188
|
return brief;
|
|
86
189
|
}
|
|
87
190
|
|
|
@@ -90,8 +193,16 @@ export function projectCacheDir(repoRoot) {
|
|
|
90
193
|
return join(CACHE_ROOT, hash);
|
|
91
194
|
}
|
|
92
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
|
+
|
|
93
204
|
function run(command, args, cwd) {
|
|
94
|
-
const result =
|
|
205
|
+
const result = runShell(command, args, cwd);
|
|
95
206
|
if (result.error) fail(`failed to run ${command}: ${result.error.message}`);
|
|
96
207
|
if (result.status !== 0) {
|
|
97
208
|
fail(`${command} ${args.join(" ")} exited with status ${result.status}`);
|
|
@@ -104,13 +215,18 @@ function run(command, args, cwd) {
|
|
|
104
215
|
export function ensureScaffold(repoRoot) {
|
|
105
216
|
const cacheDir = projectCacheDir(repoRoot);
|
|
106
217
|
const versionMarker = join(cacheDir, ".reelme-template-version");
|
|
218
|
+
const fingerprint = templateFingerprint();
|
|
107
219
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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) {
|
|
112
228
|
console.log(
|
|
113
|
-
`reelme: template
|
|
229
|
+
`reelme: template changed (CLI ${CLI_VERSION}) — rebuilding the cache scaffold.`
|
|
114
230
|
);
|
|
115
231
|
rmSync(cacheDir, { recursive: true, force: true });
|
|
116
232
|
}
|
|
@@ -122,27 +238,52 @@ export function ensureScaffold(repoRoot) {
|
|
|
122
238
|
recursive: true,
|
|
123
239
|
filter: (src) => !SCAFFOLD_EXCLUDES.has(basename(src)),
|
|
124
240
|
});
|
|
125
|
-
writeFileSync(versionMarker, `${
|
|
241
|
+
writeFileSync(versionMarker, `${fingerprint}\n`);
|
|
126
242
|
}
|
|
127
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
|
+
|
|
128
255
|
// The user's brief always wins over the template sample.
|
|
129
256
|
cpSync(join(repoRoot, "reelme.json"), join(cacheDir, "src", "brief.json"));
|
|
130
257
|
|
|
131
258
|
if (!existsSync(join(cacheDir, "node_modules"))) {
|
|
132
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.
|
|
133
263
|
run("pnpm", ["install"], cacheDir);
|
|
134
|
-
// esbuild needs its post-install script; pnpm-workspace.yaml persists the approval.
|
|
135
|
-
run("pnpm", ["approve-builds", "--all"], cacheDir);
|
|
136
264
|
}
|
|
137
265
|
|
|
138
266
|
return cacheDir;
|
|
139
267
|
}
|
|
140
268
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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.");
|
|
144
285
|
return;
|
|
145
286
|
}
|
|
146
|
-
rmSync(
|
|
147
|
-
console.log(`reelme: removed ${
|
|
287
|
+
rmSync(cacheDir, { recursive: true, force: true });
|
|
288
|
+
console.log(`reelme: removed this project's cache (${cacheDir})`);
|
|
148
289
|
}
|