@devinilabs/reelstack 1.2.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 (145) hide show
  1. package/LICENSE +128 -0
  2. package/README.md +125 -0
  3. package/cli/beats.js +124 -0
  4. package/cli/bootstrap.js +124 -0
  5. package/cli/capture.js +34 -0
  6. package/cli/direction.js +114 -0
  7. package/cli/icons.js +49 -0
  8. package/cli/index.js +101 -0
  9. package/cli/init.js +253 -0
  10. package/cli/license.js +168 -0
  11. package/cli/lint.js +865 -0
  12. package/cli/preview.js +59 -0
  13. package/cli/render.js +404 -0
  14. package/cli/scaffold.js +239 -0
  15. package/cli/smoke.js +76 -0
  16. package/cli/update.js +26 -0
  17. package/cli/utils.js +184 -0
  18. package/docs/buyers-guide.md +220 -0
  19. package/docs/design-discipline.md +130 -0
  20. package/docs/family-galleries/dark.md +95 -0
  21. package/docs/family-galleries/forbidden.md +78 -0
  22. package/docs/family-galleries/glass.md +98 -0
  23. package/docs/family-galleries/paper.md +82 -0
  24. package/docs/family-galleries/warm.md +86 -0
  25. package/docs/superpowers/plans/2026-05-09-reelstack-init-readiness-gate.md +1166 -0
  26. package/docs/superpowers/specs/2026-05-09-reelstack-init-readiness-gate-design.md +233 -0
  27. package/families/dark/components/DriftingSpotlights.tsx +59 -0
  28. package/families/dark/components/FilmGrain.tsx +44 -0
  29. package/families/dark/components/ForestCard.tsx +43 -0
  30. package/families/dark/components/GridBackground.tsx +29 -0
  31. package/families/dark/components/RadialVignette.tsx +21 -0
  32. package/families/dark/components/Scanlines.tsx +35 -0
  33. package/families/dark/components/SegmentOpacity.ts +37 -0
  34. package/families/dark/components/index.ts +13 -0
  35. package/families/dark/index.ts +31 -0
  36. package/families/dark/palette.ts +98 -0
  37. package/families/dark/presets/claudedispatch.ts +46 -0
  38. package/families/dark/presets/codedrop.ts +37 -0
  39. package/families/dark/presets/gpt55.ts +54 -0
  40. package/families/dark/presets/notebooklm.ts +50 -0
  41. package/families/dark/presets/resourcescta.ts +35 -0
  42. package/families/dark/presets/skills.ts +40 -0
  43. package/families/dark/presets/stitch.ts +46 -0
  44. package/families/dark/presets/stitch2.ts +43 -0
  45. package/families/dark/typography.ts +16 -0
  46. package/families/forbidden/components/ForbiddenCausticBlobs.tsx +52 -0
  47. package/families/forbidden/components/NewsprintTexture.tsx +28 -0
  48. package/families/forbidden/components/TintedShadow.tsx +36 -0
  49. package/families/forbidden/components/index.ts +38 -0
  50. package/families/forbidden/index.ts +17 -0
  51. package/families/forbidden/palette.ts +88 -0
  52. package/families/forbidden/presets/heretic.ts +44 -0
  53. package/families/forbidden/typography.ts +18 -0
  54. package/families/glass/components/BreakdownCard.tsx +158 -0
  55. package/families/glass/components/CausticBlobs.tsx +49 -0
  56. package/families/glass/components/Counter.tsx +72 -0
  57. package/families/glass/components/EyebrowPill.tsx +59 -0
  58. package/families/glass/components/FilmStrip.tsx +202 -0
  59. package/families/glass/components/FloatingGlyphs.tsx +78 -0
  60. package/families/glass/components/GlassCard.tsx +58 -0
  61. package/families/glass/components/GlassCardBezel.tsx +45 -0
  62. package/families/glass/components/HairlineGrid.tsx +30 -0
  63. package/families/glass/components/IridescentRing.tsx +114 -0
  64. package/families/glass/components/IridescentText.tsx +98 -0
  65. package/families/glass/components/LightBeam.tsx +46 -0
  66. package/families/glass/components/ParticleBurst.tsx +62 -0
  67. package/families/glass/components/SonarRings.tsx +81 -0
  68. package/families/glass/components/StaggeredWords.tsx +74 -0
  69. package/families/glass/components/index.ts +20 -0
  70. package/families/glass/index.ts +31 -0
  71. package/families/glass/palette.ts +93 -0
  72. package/families/glass/presets/claudewatch.ts +64 -0
  73. package/families/glass/presets/claudewatchcta.ts +43 -0
  74. package/families/glass/presets/graphify.ts +45 -0
  75. package/families/glass/presets/gstack.ts +48 -0
  76. package/families/glass/presets/jcode.ts +50 -0
  77. package/families/glass/presets/lilagents.ts +52 -0
  78. package/families/glass/presets/paperclip.ts +43 -0
  79. package/families/glass/typography.ts +15 -0
  80. package/families/index.ts +49 -0
  81. package/families/paper/components/CardSpring.tsx +42 -0
  82. package/families/paper/components/CreamGrid.tsx +26 -0
  83. package/families/paper/components/EditorialSerifText.tsx +51 -0
  84. package/families/paper/components/GreenAccentCard.tsx +10 -0
  85. package/families/paper/components/PaperShadow.tsx +30 -0
  86. package/families/paper/components/ScaleBlurText.tsx +40 -0
  87. package/families/paper/components/index.ts +11 -0
  88. package/families/paper/index.ts +23 -0
  89. package/families/paper/palette.ts +102 -0
  90. package/families/paper/presets/designreel.ts +32 -0
  91. package/families/paper/presets/devini3d.ts +45 -0
  92. package/families/paper/presets/justdrop.ts +39 -0
  93. package/families/paper/presets/opus.ts +48 -0
  94. package/families/paper/typography.ts +17 -0
  95. package/families/warm/components/AccentGlow.tsx +60 -0
  96. package/families/warm/components/BentoCell.tsx +56 -0
  97. package/families/warm/components/BentoGrid.tsx +30 -0
  98. package/families/warm/components/FilmGrain.tsx +36 -0
  99. package/families/warm/components/ScaleBlurCounter.tsx +71 -0
  100. package/families/warm/components/WarmSurface.tsx +35 -0
  101. package/families/warm/components/index.ts +11 -0
  102. package/families/warm/index.ts +19 -0
  103. package/families/warm/palette.ts +81 -0
  104. package/families/warm/presets/huashu.ts +49 -0
  105. package/families/warm/presets/mempalace.ts +51 -0
  106. package/families/warm/typography.ts +17 -0
  107. package/package.json +85 -0
  108. package/reference/dark/claudedispatch.tsx +2441 -0
  109. package/reference/dark/notebooklm.tsx +2316 -0
  110. package/reference/dark/stitch.tsx +3040 -0
  111. package/reference/forbidden/heretic.tsx +2636 -0
  112. package/reference/glass/claudewatch.tsx +3827 -0
  113. package/reference/glass/graphify.tsx +2418 -0
  114. package/reference/glass/paperclip.tsx +2218 -0
  115. package/reference/paper/designreel.tsx +883 -0
  116. package/reference/paper/justdrop.tsx +1898 -0
  117. package/reference/paper/opus.tsx +1770 -0
  118. package/reference/warm/huashu.tsx +3413 -0
  119. package/reference/warm/mempalace.tsx +2909 -0
  120. package/skill/SKILL.md +229 -0
  121. package/skill/commands/reelstack-beats.md +20 -0
  122. package/skill/commands/reelstack-capture.md +24 -0
  123. package/skill/commands/reelstack-critique.md +15 -0
  124. package/skill/commands/reelstack-dark.md +40 -0
  125. package/skill/commands/reelstack-direction.md +17 -0
  126. package/skill/commands/reelstack-forbidden.md +25 -0
  127. package/skill/commands/reelstack-glass.md +39 -0
  128. package/skill/commands/reelstack-icons.md +22 -0
  129. package/skill/commands/reelstack-init.md +17 -0
  130. package/skill/commands/reelstack-lint.md +22 -0
  131. package/skill/commands/reelstack-paper.md +36 -0
  132. package/skill/commands/reelstack-render.md +20 -0
  133. package/skill/commands/reelstack-warm.md +36 -0
  134. package/templates/dark/template.tsx +115 -0
  135. package/templates/forbidden/template.tsx +111 -0
  136. package/templates/glass/template.tsx +201 -0
  137. package/templates/paper/template.tsx +133 -0
  138. package/templates/warm/template.tsx +210 -0
  139. package/utils/ai-purple-blocklist.ts +13 -0
  140. package/utils/banned-fonts.ts +11 -0
  141. package/utils/cubic-bezier.ts +36 -0
  142. package/utils/easing.ts +84 -0
  143. package/utils/grid.ts +13 -0
  144. package/utils/render-presets.json +56 -0
  145. package/utils/safe-zones.tsx +57 -0
package/cli/preview.js ADDED
@@ -0,0 +1,59 @@
1
+ /**
2
+ * `reelstack preview` — render a 10-second free demo (no license required).
3
+ * Used as a marketing hook so prospective buyers can see the quality before
4
+ * they pay.
5
+ */
6
+ const path = require("node:path");
7
+ const fs = require("node:fs");
8
+ const { spawnSync } = require("node:child_process");
9
+ const { banner, info, success, warn, fail, c } = require("./utils");
10
+
11
+ async function run(_argv) {
12
+ banner();
13
+ info("Rendering a 10-second ReelStack demo (free, no license).");
14
+ console.log("");
15
+ warn("This previews the DesignReel preset (Cream Paper family) for 300 frames.");
16
+ console.log("");
17
+
18
+ const cwdPkgJson = path.join(process.cwd(), "package.json");
19
+ if (!fs.existsSync(cwdPkgJson)) {
20
+ fail("Run this inside a Remotion project (npm create video@latest first).");
21
+ process.exit(2);
22
+ }
23
+
24
+ // The preview uses the existing scaffolder but renders to /tmp.
25
+ const scaffold = require("./scaffold");
26
+ await scaffold.run([
27
+ "--family=paper",
28
+ "--preset=designreel",
29
+ "--name=ReelStackPreview",
30
+ "--force",
31
+ ]);
32
+
33
+ const out = path.join("/tmp", `reelstack-preview-${Date.now()}.mp4`);
34
+ info(`Rendering to ${out}…`);
35
+ const result = spawnSync(
36
+ "npx",
37
+ [
38
+ "remotion", "render", "ReelStackPreviewReel", out,
39
+ "--codec=h264", "--crf=18", "--video-bitrate=8M", "--pixel-format=yuv420p",
40
+ ],
41
+ { stdio: "inherit" },
42
+ );
43
+
44
+ if (result.status !== 0) {
45
+ fail("Preview render failed. Try: npm run dev to debug in Studio.");
46
+ process.exit(result.status ?? 1);
47
+ }
48
+
49
+ console.log("");
50
+ success(`Preview rendered: ${out}`);
51
+ console.log("");
52
+ console.log(c.gray("Like what you see? Get the full ReelStack:"));
53
+ console.log(` ${c.cyan("https://devini.io/reelstack")}`);
54
+ console.log("");
55
+ console.log(c.gray("₹149 INR / $3 USD one-time (region-detected). All 5 families, all 22 presets, lifetime v1.x updates."));
56
+ console.log("");
57
+ }
58
+
59
+ module.exports = { run };
package/cli/render.js ADDED
@@ -0,0 +1,404 @@
1
+ /**
2
+ * `reelstack render <comp-id> [flags]`
3
+ *
4
+ * Flags:
5
+ * --platform=ig|tiktok|shorts Apply platform-optimal preset (default: ig).
6
+ * --out=<path> Override output path (default: out/<id>-<platform>.mp4).
7
+ * --force Skip pre-render lint failures.
8
+ * --format=mp4,gif Comma list. When `gif` is included, also
9
+ * produce a palette-optimized GIF (lanczos+palettegen).
10
+ * --bgm=<path.mp3> Auto-mix background music at -14 LUFS-ish (volume=0.4).
11
+ * --interpolate=60 Render at 60fps (passes `--fps 60` to Remotion if supported).
12
+ * --palette-optimize Use a 64-color palette quantization for GIF (default 256).
13
+ *
14
+ * Pre-render pipeline:
15
+ * 1. Lint. Aborts on violations unless --force.
16
+ * 2. Smoke check: render frame 0 / midpoint / last via `remotion still`,
17
+ * check each PNG > 30KB. Warn if any frame looks all-black.
18
+ * 3. Render MP4.
19
+ * 4. Optional: mix BGM, produce GIF.
20
+ */
21
+ const path = require("node:path");
22
+ const fs = require("node:fs");
23
+ const { spawnSync } = require("node:child_process");
24
+ const { info, success, fail, warn, c, readJson, ask } = require("./utils");
25
+ const { lint } = require("./lint");
26
+
27
+ const REELSTACK_PKG = path.dirname(__dirname);
28
+ const PRESETS = readJson(path.join(REELSTACK_PKG, "utils", "render-presets.json"));
29
+
30
+ function parseArgs(argv) {
31
+ const out = { positional: [] };
32
+ for (const a of argv) {
33
+ if (a.startsWith("--")) {
34
+ const m = a.match(/^--([^=]+)(?:=(.*))?$/);
35
+ out[m[1]] = m[2] ?? true;
36
+ } else out.positional.push(a);
37
+ }
38
+ return out;
39
+ }
40
+
41
+ /**
42
+ * Recursive directory walker — collects absolute paths of every .tsx file
43
+ * under `dir`, ignoring node_modules / dot-dirs / build outputs.
44
+ */
45
+ function walkTsx(dir, results = []) {
46
+ let entries;
47
+ try {
48
+ entries = fs.readdirSync(dir, { withFileTypes: true });
49
+ } catch {
50
+ return results;
51
+ }
52
+ for (const ent of entries) {
53
+ if (ent.name.startsWith(".") || ent.name === "node_modules" || ent.name === "dist" || ent.name === "out") continue;
54
+ const full = path.join(dir, ent.name);
55
+ if (ent.isDirectory()) walkTsx(full, results);
56
+ else if (ent.isFile() && ent.name.endsWith(".tsx")) results.push(full);
57
+ }
58
+ return results;
59
+ }
60
+
61
+ /**
62
+ * Resolve the source file for a composition id using a tiered strategy:
63
+ * 1. ./src/<compId>.tsx (the simple convention)
64
+ * 2. Parse src/Root.tsx (or src/index.tsx) for `import { CompId } from "./Path"`
65
+ * that matches `compId` or `${compId}Reel`.
66
+ * 3. Recursive scan of src/ for <compId>.tsx and <compId>Reel.tsx.
67
+ * If multiple, prefer the shallowest path.
68
+ * 4. stderr prompt to the user.
69
+ */
70
+ async function resolveCompFile(cwd, compId) {
71
+ const srcDir = path.join(cwd, "src");
72
+
73
+ // 1. Simple convention.
74
+ const candidates = [
75
+ path.join(srcDir, `${compId}.tsx`),
76
+ path.join(srcDir, `${compId}Reel.tsx`),
77
+ ];
78
+ for (const cand of candidates) {
79
+ if (fs.existsSync(cand)) return cand;
80
+ }
81
+
82
+ // 2. Parse Root.tsx / index.tsx imports.
83
+ const rootCandidates = [
84
+ path.join(srcDir, "Root.tsx"),
85
+ path.join(srcDir, "index.tsx"),
86
+ path.join(srcDir, "Root.ts"),
87
+ ];
88
+ for (const root of rootCandidates) {
89
+ if (!fs.existsSync(root)) continue;
90
+ const rootSrc = fs.readFileSync(root, "utf8");
91
+ const importRegex = /import\s*\{\s*([^}]+)\s*\}\s*from\s*["']([^"']+)["']/g;
92
+ let m;
93
+ while ((m = importRegex.exec(rootSrc)) !== null) {
94
+ const names = m[1].split(",").map((s) => s.trim().split(/\s+as\s+/)[0].trim());
95
+ const relPath = m[2];
96
+ if (!relPath.startsWith(".")) continue;
97
+ const wantsForms = [compId, `${compId}Reel`];
98
+ const hit = names.find((n) => wantsForms.includes(n));
99
+ if (!hit) continue;
100
+ const baseResolved = path.resolve(path.dirname(root), relPath);
101
+ const tries = [
102
+ baseResolved + ".tsx",
103
+ baseResolved + ".ts",
104
+ path.join(baseResolved, "index.tsx"),
105
+ path.join(baseResolved, "index.ts"),
106
+ ];
107
+ for (const t of tries) {
108
+ if (fs.existsSync(t)) return t;
109
+ }
110
+ }
111
+ }
112
+
113
+ // 3. Recursive scan of src/.
114
+ if (fs.existsSync(srcDir)) {
115
+ const all = walkTsx(srcDir);
116
+ const targets = new Set([`${compId}.tsx`, `${compId}Reel.tsx`]);
117
+ const matches = all.filter((p) => targets.has(path.basename(p)));
118
+ if (matches.length > 0) {
119
+ matches.sort(
120
+ (a, b) =>
121
+ path.relative(srcDir, a).split(path.sep).length -
122
+ path.relative(srcDir, b).split(path.sep).length,
123
+ );
124
+ return matches[0];
125
+ }
126
+ }
127
+
128
+ // 4. Prompt.
129
+ warn(`Could not auto-resolve source file for composition "${compId}".`);
130
+ const answer = await ask("Enter the path to the .tsx file (relative to cwd, or absolute):");
131
+ if (!answer) return null;
132
+ const resolved = path.isAbsolute(answer) ? answer : path.resolve(cwd, answer);
133
+ if (!fs.existsSync(resolved)) {
134
+ fail(`File not found: ${resolved}`);
135
+ return null;
136
+ }
137
+ return resolved;
138
+ }
139
+
140
+ /**
141
+ * Pre-render smoke check — render 3 stills (frame 0, midpoint, last) and
142
+ * warn if any PNG is < 30KB (likely all-black, probably a broken composition).
143
+ *
144
+ * Best-effort: if `npx remotion still` doesn't accept the args we pass, we
145
+ * skip silently rather than block the render.
146
+ */
147
+ function preRenderSmokeCheck(compId, totalFrames) {
148
+ const checkFrames = [
149
+ 0,
150
+ Math.max(0, Math.floor(totalFrames / 2)),
151
+ Math.max(0, totalFrames - 1),
152
+ ];
153
+ const outDir = path.join(process.cwd(), "out");
154
+ try { fs.mkdirSync(outDir, { recursive: true }); } catch {}
155
+ const warnings = [];
156
+ for (const f of checkFrames) {
157
+ const pngPath = path.join(outDir, `_check_f${f}.png`);
158
+ const r = spawnSync(
159
+ "npx",
160
+ ["remotion", "still", compId, pngPath, `--frame=${f}`],
161
+ { stdio: "pipe", timeout: 60_000 },
162
+ );
163
+ if (r.error && r.error.code === "ETIMEDOUT") {
164
+ warn(`Smoke check frame ${f}: remotion still timed out after 60 seconds.`);
165
+ return { ok: true, skipped: true };
166
+ }
167
+ if (r.status !== 0) {
168
+ // Smoke check failed mechanically — don't block.
169
+ return { ok: true, skipped: true };
170
+ }
171
+ try {
172
+ const stat = fs.statSync(pngPath);
173
+ if (stat.size < 30 * 1024) {
174
+ warnings.push(`Frame ${f}: ${(stat.size / 1024).toFixed(1)}KB — looks all-black.`);
175
+ }
176
+ } catch {}
177
+ }
178
+ return { ok: warnings.length === 0, warnings, skipped: false };
179
+ }
180
+
181
+ /**
182
+ * Convert MP4 → palette-optimized GIF using ffmpeg.
183
+ * Two-pass: palettegen then paletteuse with lanczos scaling at 12fps / 540w.
184
+ */
185
+ function mp4ToGif(mp4Path, gifPath, paletteOptimize) {
186
+ const palettePath = gifPath.replace(/\.gif$/, "-palette.png");
187
+ const maxColors = paletteOptimize ? 64 : 256;
188
+ const palettegen = spawnSync(
189
+ "ffmpeg",
190
+ [
191
+ "-y", "-i", mp4Path,
192
+ "-vf", `fps=12,scale=540:-1:flags=lanczos,palettegen=stats_mode=diff:max_colors=${maxColors}`,
193
+ palettePath,
194
+ ],
195
+ { stdio: "inherit", timeout: 120_000 },
196
+ );
197
+ if (palettegen.error && palettegen.error.code === "ETIMEDOUT") {
198
+ fail("ffmpeg palettegen timed out after 2 minutes. Check that the input file isn't corrupted.");
199
+ return false;
200
+ }
201
+ if (palettegen.status !== 0) return false;
202
+ const paletteuse = spawnSync(
203
+ "ffmpeg",
204
+ [
205
+ "-y", "-i", mp4Path, "-i", palettePath,
206
+ "-filter_complex", "fps=12,scale=540:-1:flags=lanczos[x];[x][1:v]paletteuse",
207
+ gifPath,
208
+ ],
209
+ { stdio: "inherit", timeout: 120_000 },
210
+ );
211
+ try { fs.unlinkSync(palettePath); } catch {}
212
+ if (paletteuse.error && paletteuse.error.code === "ETIMEDOUT") {
213
+ fail("ffmpeg paletteuse timed out after 2 minutes. Check that the input file isn't corrupted.");
214
+ return false;
215
+ }
216
+ return paletteuse.status === 0;
217
+ }
218
+
219
+ /**
220
+ * Mix BGM into existing MP4, replacing the original.
221
+ */
222
+ function mixBgm(mp4Path, bgmPath) {
223
+ const tmpOut = mp4Path.replace(/\.mp4$/, ".bgm.mp4");
224
+ const r = spawnSync(
225
+ "ffmpeg",
226
+ [
227
+ "-y", "-i", mp4Path, "-i", bgmPath,
228
+ "-filter_complex", "[1:a]volume=0.4[bgm];[0:a][bgm]amix=inputs=2:duration=first[a]",
229
+ "-map", "0:v", "-map", "[a]", "-c:v", "copy",
230
+ tmpOut,
231
+ ],
232
+ { stdio: "inherit", timeout: 120_000 },
233
+ );
234
+ if (r.error && r.error.code === "ETIMEDOUT") {
235
+ fail("ffmpeg BGM mix timed out after 2 minutes. Check that the input file isn't corrupted.");
236
+ return false;
237
+ }
238
+ if (r.status !== 0) return false;
239
+ try {
240
+ fs.renameSync(tmpOut, mp4Path);
241
+ return true;
242
+ } catch {
243
+ return false;
244
+ }
245
+ }
246
+
247
+ /**
248
+ * Pure function: given an interpolate fps target, return the Remotion CLI
249
+ * flags to add and an optional warning string. Exported for unit testing.
250
+ *
251
+ * Remotion 4 (verified at 4.0.459) supports `--fps <n>` via the
252
+ * `overrideFpsOption` in @remotion/renderer (cliFlag = 'fps'). It overrides
253
+ * the composition's declared fps but does NOT perform frame interpolation.
254
+ *
255
+ * Returns:
256
+ * { flagsToAdd: string[], warning: string|null }
257
+ *
258
+ * - If `interpolateFps` is null/undefined/NaN, returns no-op (no flags).
259
+ * - If `interpolateFps` is a finite positive number, returns `--fps=<n>`.
260
+ * - If `interpolateFps` is unparseable (NaN), returns no flags + a warning.
261
+ */
262
+ function applyInterpolate({ interpolateFps }) {
263
+ if (interpolateFps === null || interpolateFps === undefined) {
264
+ return { flagsToAdd: [], warning: null };
265
+ }
266
+ if (!Number.isFinite(interpolateFps) || interpolateFps <= 0) {
267
+ return {
268
+ flagsToAdd: [],
269
+ warning:
270
+ "--interpolate=<fps> requires a positive number. Falling back to source fps. " +
271
+ "Note: Remotion 4's --fps flag overrides composition fps but doesn't interpolate frames; " +
272
+ "for true interpolation, use ffmpeg minterpolate post-render (planned for v1.2).",
273
+ };
274
+ }
275
+ return {
276
+ flagsToAdd: [`--fps=${interpolateFps}`],
277
+ warning: null,
278
+ };
279
+ }
280
+
281
+ async function run(argv) {
282
+ const args = parseArgs(argv);
283
+ const compId = args.positional[0];
284
+ if (!compId) {
285
+ fail("Usage: reelstack render <comp-id> [--platform=ig|tiktok|shorts] [--out=<path>] [--force] [--format=mp4,gif] [--bgm=<path>] [--interpolate=60] [--palette-optimize]");
286
+ process.exit(2);
287
+ }
288
+ const platform = args.platform || "ig";
289
+ const preset = PRESETS[platform];
290
+ if (!preset) {
291
+ fail(`Unknown platform "${platform}". Valid: ${Object.keys(PRESETS).join(", ")}.`);
292
+ process.exit(2);
293
+ }
294
+
295
+ const formats = (typeof args.format === "string" ? args.format : "mp4")
296
+ .split(",")
297
+ .map((s) => s.trim().toLowerCase())
298
+ .filter(Boolean);
299
+ const bgmPath = typeof args.bgm === "string" ? args.bgm : null;
300
+ const interpolateFps = typeof args.interpolate === "string" ? Number(args.interpolate) : null;
301
+ const paletteOptimize = args["palette-optimize"] === true || args["palette-optimize"] === "true";
302
+
303
+ if (bgmPath && !fs.existsSync(bgmPath)) {
304
+ fail(`BGM file not found: ${bgmPath}`);
305
+ process.exit(2);
306
+ }
307
+
308
+ // Find matching src file by composition id (tiered resolution).
309
+ const candidate = await resolveCompFile(process.cwd(), compId);
310
+ if (candidate && !args.force) {
311
+ info(`Linting ${path.relative(process.cwd(), candidate)} before render…`);
312
+ const v = lint(candidate);
313
+ if (v.length > 0) {
314
+ warn(`Lint reported ${v.length} violation(s). Pass --force to render anyway.`);
315
+ v.forEach((item) =>
316
+ console.log(` ${c.gray(`L${item.line}`)} ${c.yellow(item.code)} ${item.msg}`),
317
+ );
318
+ process.exit(1);
319
+ }
320
+ } else if (!candidate) {
321
+ warn(`Could not resolve source file for "${compId}". Skipping pre-render lint.`);
322
+ }
323
+
324
+ // Pre-render smoke check (frame 0 / mid / last). Best-effort.
325
+ // We don't know the comp's totalFrames here without parsing — use a coarse
326
+ // 270-frame guess (9s at 30fps) for the midpoint/last check. Remotion's
327
+ // `still` will clamp out-of-range frames and may still error; we treat any
328
+ // smoke-check failure as a skip rather than a hard block.
329
+ const guessTotalFrames = 270;
330
+ if (!args.force && !args["skip-smoke"]) {
331
+ info("Smoke-checking 3 frames (0 / mid / last)…");
332
+ const smoke = preRenderSmokeCheck(compId, guessTotalFrames);
333
+ if (smoke.skipped) {
334
+ warn("Smoke check skipped (remotion still didn't run cleanly). Continuing.");
335
+ } else if (smoke.warnings && smoke.warnings.length > 0) {
336
+ warn("Smoke check warnings:");
337
+ smoke.warnings.forEach((w) => console.log(` ${c.yellow("•")} ${w}`));
338
+ const proceed = await ask("Looks like one or more frames may be all-black. Continue rendering? (y/N)");
339
+ if (!/^y(es)?$/i.test(proceed.trim())) {
340
+ fail("Aborted by user.");
341
+ process.exit(1);
342
+ }
343
+ } else {
344
+ success("Smoke check passed.");
345
+ }
346
+ }
347
+
348
+ const out = args.out || `out/${compId}-${platform}.mp4`;
349
+ const remotionFlags = [...preset.remotionFlags];
350
+
351
+ // --interpolate=<fps> → pass `--fps <n>` to Remotion (Remotion 4 supports
352
+ // `overrideFpsOption` with cliFlag 'fps' — verified via @remotion/renderer
353
+ // source @ dist/options/override-fps.js, available since 4.0.x).
354
+ //
355
+ // CAVEAT: Remotion's `--fps` overrides the composition's declared fps but
356
+ // does NOT perform frame interpolation between frames — it just plays the
357
+ // existing N frames at the new rate. For real motion interpolation (RIFE
358
+ // / optical flow), use ffmpeg `minterpolate` post-render or
359
+ // Remotion-Lambda's interpolation utility (see TODO below).
360
+ //
361
+ // TODO(v1.2): swap --fps for true frame interpolation via either
362
+ // (a) ffmpeg minterpolate=fps=60:mi_mode=mci in mp4ToGif's sibling step,
363
+ // (b) @remotion/lambda's interpolation pipeline once it ships.
364
+ const { flagsToAdd, warning } = applyInterpolate({ interpolateFps });
365
+ if (warning) warn(warning);
366
+ remotionFlags.push(...flagsToAdd);
367
+ if (flagsToAdd.length > 0) {
368
+ info(`Interpolating to ${interpolateFps}fps via Remotion --fps flag.`);
369
+ }
370
+
371
+ const effectiveFps = flagsToAdd.length > 0 && Number.isFinite(interpolateFps) && interpolateFps > 0
372
+ ? interpolateFps
373
+ : preset.fps;
374
+ info(`Rendering ${compId} → ${out} (${preset.label}, ${preset.width}×${preset.height} ${effectiveFps}fps)`);
375
+ const result = spawnSync(
376
+ "npx",
377
+ ["remotion", "render", compId, out, ...remotionFlags],
378
+ { stdio: "inherit" },
379
+ );
380
+ if (result.status !== 0) {
381
+ fail("Render failed. See remotion output above.");
382
+ process.exit(result.status ?? 1);
383
+ }
384
+ success(`Rendered MP4 to ${out}`);
385
+
386
+ // Optional: BGM mix.
387
+ if (bgmPath) {
388
+ info(`Mixing BGM (${bgmPath}) at -14 LUFS-ish (volume=0.4)…`);
389
+ const ok = mixBgm(out, bgmPath);
390
+ if (ok) success(`BGM mixed in-place: ${out}`);
391
+ else warn("BGM mix failed (ffmpeg error). Original MP4 is preserved.");
392
+ }
393
+
394
+ // Optional: GIF.
395
+ if (formats.includes("gif")) {
396
+ const gifOut = out.replace(/\.mp4$/, ".gif");
397
+ info(`Producing palette-optimized GIF → ${gifOut} (${paletteOptimize ? "64-color" : "256-color"})`);
398
+ const ok = mp4ToGif(out, gifOut, paletteOptimize);
399
+ if (ok) success(`Rendered GIF to ${gifOut}`);
400
+ else warn("GIF generation failed (ffmpeg error).");
401
+ }
402
+ }
403
+
404
+ module.exports = { run, resolveCompFile, mp4ToGif, mixBgm, applyInterpolate };