reelme 0.2.1 → 0.3.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 +16 -0
  2. package/assets/audio/bright-sparks.mp3 +0 -0
  3. package/assets/audio/calm-keys.mp3 +0 -0
  4. package/assets/audio/circuit-pulse.mp3 +0 -0
  5. package/assets/audio/clean-horizon.mp3 +0 -0
  6. package/assets/audio/generate-tracks.mjs +213 -0
  7. package/assets/audio/manifest.json +83 -0
  8. package/assets/audio/midnight-protocol.mp3 +0 -0
  9. package/assets/audio/pixel-bounce.mp3 +0 -0
  10. package/assets/audio/steady-launch.mp3 +0 -0
  11. package/assets/audio/sunny-loop.mp3 +0 -0
  12. package/assets/audio/vector-grid.mp3 +0 -0
  13. package/package.json +2 -1
  14. package/src/cache.mjs +1 -0
  15. package/src/render.mjs +105 -4
  16. package/template/package.json +3 -1
  17. package/template/pnpm-lock.yaml +339 -284
  18. package/template/src/Root.tsx +55 -45
  19. package/template/src/audio.ts +24 -0
  20. package/template/src/benchmark.ts +18 -0
  21. package/template/src/brief.json +1 -0
  22. package/template/src/brief.ts +61 -3
  23. package/template/src/cinematic/Atmosphere.tsx +139 -0
  24. package/template/src/cinematic/Camera.tsx +54 -0
  25. package/template/src/cinematic/look.ts +89 -0
  26. package/template/src/cinematic/transitions.tsx +92 -0
  27. package/template/src/components/primitives/Bar.tsx +82 -0
  28. package/template/src/components/primitives/Caption.tsx +3 -2
  29. package/template/src/components/primitives/Kicker.tsx +46 -0
  30. package/template/src/components/primitives/Label.tsx +18 -24
  31. package/template/src/components/primitives/RevealText.tsx +101 -0
  32. package/template/src/components/primitives/Terminal.tsx +11 -0
  33. package/template/src/components/scenes/Benchmark.tsx +83 -0
  34. package/template/src/components/scenes/BrowserFrame.tsx +4 -3
  35. package/template/src/components/scenes/CTA.tsx +56 -10
  36. package/template/src/components/scenes/Clip.tsx +125 -0
  37. package/template/src/components/scenes/CodeReveal.tsx +4 -3
  38. package/template/src/components/scenes/DataFlow.tsx +4 -3
  39. package/template/src/components/scenes/FeatureList.tsx +13 -8
  40. package/template/src/components/scenes/FileTree.tsx +4 -3
  41. package/template/src/components/scenes/Hook.tsx +55 -0
  42. package/template/src/components/scenes/HotkeyScene.tsx +4 -3
  43. package/template/src/components/scenes/MobileScreen.tsx +120 -82
  44. package/template/src/components/scenes/OSWindowScene.tsx +4 -3
  45. package/template/src/components/scenes/Problem.tsx +28 -29
  46. package/template/src/components/scenes/SplitComparison.tsx +65 -92
  47. package/template/src/components/scenes/StatCallout.tsx +93 -4
  48. package/template/src/components/scenes/TerminalScene.tsx +5 -6
  49. package/template/src/duration.ts +12 -1
  50. package/template/src/platforms.ts +4 -0
@@ -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,213 @@
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
+ rmSync(wavPath, { force: true });
208
+ if (result.error || result.status !== 0) {
209
+ process.exitCode = result.status || 1;
210
+ break;
211
+ }
212
+ console.log(`wrote ${mp3Path}`);
213
+ }
@@ -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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reelme",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "Turn your repo into launch videos for social platforms. Local Remotion rendering, zero cloud.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -8,6 +8,7 @@
8
8
  },
9
9
  "files": [
10
10
  "src",
11
+ "assets",
11
12
  "template",
12
13
  "!template/out",
13
14
  "!template/src/__tests__",
package/src/cache.mjs CHANGED
@@ -20,6 +20,7 @@ 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")
package/src/render.mjs CHANGED
@@ -2,14 +2,17 @@
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, mkdirSync } from "node:fs";
6
- import { basename, join } from "node:path";
5
+ import { cpSync, existsSync, mkdirSync, readFileSync } from "node:fs";
6
+ import { basename, dirname, join } from "node:path";
7
7
  import { spawnSync } from "node:child_process";
8
- import { ensureScaffold, loadPlatforms, readBrief, fail } from "./cache.mjs";
8
+ import { AUDIO_DIR, ensureScaffold, loadPlatforms, readBrief, fail } from "./cache.mjs";
9
9
 
10
10
  function renderComposition(cacheDir, compositionId, outFile, codec) {
11
11
  const args = ["exec", "remotion", "render", compositionId, join("out", outFile)];
12
- if (codec === "gif") args.push("--codec=gif");
12
+ // Render gifs at 0.6 scale (e.g. 1920x1080 -> 1152x648): a README gif needs no
13
+ // more, and fewer pixels roughly halves the file size. Layout and timing are
14
+ // unchanged — --scale downscales the output, not the composition coordinates.
15
+ if (codec === "gif") args.push("--codec=gif", "--scale=0.6");
13
16
  console.log(`reelme: rendering ${compositionId} → reelme-out/${outFile}`);
14
17
  const result = spawnSync("pnpm", args, { cwd: cacheDir, stdio: "inherit" });
15
18
  if (result.error || result.status !== 0) {
@@ -17,6 +20,90 @@ function renderComposition(cacheDir, compositionId, outFile, codec) {
17
20
  }
18
21
  }
19
22
 
23
+ function collectAssets(brief) {
24
+ const assets = [];
25
+ const cuts = [brief.cuts.main, brief.cuts.vertical, brief.cuts.teaser].filter(Boolean);
26
+ for (const cut of cuts) {
27
+ for (const scene of cut) {
28
+ if (scene.type === "clip" && scene.src) assets.push(scene.src);
29
+ if (scene.type === "mobile" && scene.screenshot) assets.push(scene.screenshot);
30
+ if (scene.type === "browser" && scene.image) assets.push(scene.image);
31
+ }
32
+ }
33
+ return [...new Set(assets)];
34
+ }
35
+
36
+ function copyAssets(repoRoot, cacheDir, brief) {
37
+ const assets = collectAssets(brief);
38
+ if (assets.length === 0) return;
39
+ const publicDir = join(cacheDir, "public");
40
+ mkdirSync(publicDir, { recursive: true });
41
+ const missing = assets.filter((a) => !existsSync(join(repoRoot, a)));
42
+ if (missing.length > 0) {
43
+ fail(`missing asset file(s):\n${missing.map((a) => ` ${join(repoRoot, a)}`).join("\n")}`);
44
+ }
45
+ for (const asset of assets) {
46
+ const dest = join(publicDir, asset);
47
+ mkdirSync(dirname(dest), { recursive: true });
48
+ cpSync(join(repoRoot, asset), dest);
49
+ }
50
+ }
51
+
52
+ function loadAudioManifest() {
53
+ const manifestPath = join(AUDIO_DIR, "manifest.json");
54
+ if (!existsSync(manifestPath)) {
55
+ fail(`audio manifest is missing from the package: ${manifestPath}`);
56
+ }
57
+ try {
58
+ const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
59
+ if (!Array.isArray(manifest)) throw new Error("manifest must be an array");
60
+ return manifest;
61
+ } catch (e) {
62
+ fail(`audio manifest is invalid: ${e.message}`);
63
+ }
64
+ }
65
+
66
+ function copyAudioTrack(cacheDir, brief) {
67
+ const audio = brief.project?.audio;
68
+ if (!audio) return;
69
+
70
+ const track = audio.track;
71
+ if (typeof track !== "string" || track.length === 0 || basename(track) !== track) {
72
+ fail(`project.audio.track must be a filename from the bundled audio manifest.`);
73
+ }
74
+
75
+ const manifest = loadAudioManifest();
76
+ const validTracks = manifest.map((entry) => entry.file).filter(Boolean);
77
+ const entry = manifest.find((item) => item.file === track);
78
+ if (!entry) {
79
+ fail(
80
+ `unknown audio track "${track}". Valid tracks: ${validTracks.join(", ")}.`
81
+ );
82
+ }
83
+
84
+ const source = join(AUDIO_DIR, track);
85
+ if (!existsSync(source)) {
86
+ fail(`bundled audio track "${track}" is missing from ${AUDIO_DIR}.`);
87
+ }
88
+
89
+ const audioDir = join(cacheDir, "public", "audio");
90
+ mkdirSync(audioDir, { recursive: true });
91
+ cpSync(source, join(audioDir, track));
92
+ }
93
+
94
+ function copyLogo(repoRoot, cacheDir, brief) {
95
+ const logo = brief.project?.logo;
96
+ if (!logo) return;
97
+ const source = join(repoRoot, logo);
98
+ if (!existsSync(source)) {
99
+ fail(`project.logo file not found: ${source}`);
100
+ }
101
+ const publicDir = join(cacheDir, "public");
102
+ const dest = join(publicDir, logo);
103
+ mkdirSync(dirname(dest), { recursive: true });
104
+ cpSync(source, dest);
105
+ }
106
+
20
107
  export function render(repoRoot) {
21
108
  const brief = readBrief(repoRoot);
22
109
  const platforms = loadPlatforms();
@@ -24,6 +111,20 @@ export function render(repoRoot) {
24
111
  const outDir = join(repoRoot, "reelme-out");
25
112
  mkdirSync(outDir, { recursive: true });
26
113
 
114
+ copyAssets(repoRoot, cacheDir, brief);
115
+ copyAudioTrack(cacheDir, brief);
116
+ copyLogo(repoRoot, cacheDir, brief);
117
+
118
+ const verticalFallback = brief.project.platforms.filter(
119
+ (id) => platforms[id].cut === "vertical" && !brief.cuts.vertical?.length
120
+ );
121
+ if (verticalFallback.length > 0) {
122
+ 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.`
125
+ );
126
+ }
127
+
27
128
  for (const id of brief.project.platforms) {
28
129
  const preset = platforms[id];
29
130
  const outFile = basename(preset.output.file);
@@ -10,6 +10,7 @@
10
10
  },
11
11
  "dependencies": {
12
12
  "@remotion/cli": "4.0.290",
13
+ "@remotion/gif": "4.0.290",
13
14
  "@remotion/google-fonts": "4.0.290",
14
15
  "chroma-js": "^3.1.2",
15
16
  "lucide-react": "^0.511.0",
@@ -24,6 +25,7 @@
24
25
  "react": "^18.3.0",
25
26
  "react-dom": "^18.3.0",
26
27
  "typescript": "^5.4.0",
27
- "vitest": "^4.1.0"
28
+ "vite": "^7.3.5",
29
+ "vitest": "^4.1.8"
28
30
  }
29
31
  }