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/src/cli.mjs
CHANGED
|
@@ -4,30 +4,33 @@
|
|
|
4
4
|
// skill owns the intelligence and writes reelme.json; this CLI never asks
|
|
5
5
|
// questions.
|
|
6
6
|
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import { render } from "./render.mjs";
|
|
7
|
+
import { ensureScaffold, cleanCache, readBrief, runShell, fail } from "./cache.mjs";
|
|
8
|
+
import { render, stageInputs } from "./render.mjs";
|
|
10
9
|
|
|
11
10
|
const USAGE = `reelme — launch videos for your repo, rendered locally
|
|
12
11
|
|
|
13
12
|
Usage: npx reelme <command>
|
|
14
13
|
|
|
15
14
|
Commands:
|
|
16
|
-
render
|
|
17
|
-
studio
|
|
18
|
-
|
|
15
|
+
render Render every platform in reelme.json to ./reelme-out/
|
|
16
|
+
studio Open Remotion Studio against the cached project (preview/tweak)
|
|
17
|
+
validate Check reelme.json (schema, platforms, scenes) without rendering
|
|
18
|
+
clean Remove this project's reelme cache
|
|
19
|
+
clean --all Remove every project's cache (~/.reelme/cache)
|
|
19
20
|
|
|
20
21
|
The brief (reelme.json) lives at your repo root — create it with the
|
|
21
22
|
/reelme agent skill: npx skills add RubenGlez/reelme
|
|
22
23
|
`;
|
|
23
24
|
|
|
25
|
+
// Studio runs the same staging pipeline as render (minus the render loop) so a
|
|
26
|
+
// preview shows exactly what render() would produce — assets, audio, and logo
|
|
27
|
+
// all staged, and a missing brief reported cleanly (F1).
|
|
24
28
|
function studio(repoRoot) {
|
|
29
|
+
const brief = readBrief(repoRoot);
|
|
25
30
|
const cacheDir = ensureScaffold(repoRoot);
|
|
31
|
+
stageInputs(repoRoot, cacheDir, brief);
|
|
26
32
|
console.log("reelme: opening Remotion Studio (Ctrl+C to stop)…");
|
|
27
|
-
const result =
|
|
28
|
-
cwd: cacheDir,
|
|
29
|
-
stdio: "inherit",
|
|
30
|
-
});
|
|
33
|
+
const result = runShell("pnpm", ["exec", "remotion", "studio"], cacheDir);
|
|
31
34
|
if (result.error) fail(`failed to open Studio: ${result.error.message}`);
|
|
32
35
|
}
|
|
33
36
|
|
|
@@ -41,8 +44,14 @@ switch (command) {
|
|
|
41
44
|
case "studio":
|
|
42
45
|
studio(repoRoot);
|
|
43
46
|
break;
|
|
47
|
+
case "validate":
|
|
48
|
+
// readBrief validates schema, platforms, and every scene, exiting non-zero
|
|
49
|
+
// with named errors on failure. A cheap pre-render check for the skill.
|
|
50
|
+
readBrief(repoRoot);
|
|
51
|
+
console.log("reelme: reelme.json is valid.");
|
|
52
|
+
break;
|
|
44
53
|
case "clean":
|
|
45
|
-
cleanCache();
|
|
54
|
+
cleanCache(repoRoot, process.argv.includes("--all"));
|
|
46
55
|
break;
|
|
47
56
|
case "--help":
|
|
48
57
|
case "-h":
|
package/src/render.mjs
CHANGED
|
@@ -2,19 +2,172 @@
|
|
|
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, rmSync } from "node:fs";
|
|
6
|
+
import { basename, dirname, join, resolve, relative, isAbsolute } from "node:path";
|
|
7
7
|
import { spawnSync } from "node:child_process";
|
|
8
|
-
import {
|
|
8
|
+
import { createRequire } from "node:module";
|
|
9
|
+
import { AUDIO_DIR, ensureScaffold, loadPlatforms, readBrief, runShell, fail } from "./cache.mjs";
|
|
10
|
+
|
|
11
|
+
const require = createRequire(import.meta.url);
|
|
12
|
+
|
|
13
|
+
// Reject asset paths that escape their base dir. A reelme.json is authored by an
|
|
14
|
+
// agent and gets committed/shared, so a `../../…` src/logo would otherwise copy
|
|
15
|
+
// (or read) files outside the cache at render time (F11). Guards both the repo
|
|
16
|
+
// source and the cache destination.
|
|
17
|
+
function safeJoin(baseDir, relPath, label) {
|
|
18
|
+
if (typeof relPath !== "string" || relPath.length === 0 || isAbsolute(relPath)) {
|
|
19
|
+
fail(`${label} "${relPath}" must be a repo-relative path.`);
|
|
20
|
+
}
|
|
21
|
+
const dest = resolve(baseDir, relPath);
|
|
22
|
+
const rel = relative(baseDir, dest);
|
|
23
|
+
if (rel.startsWith("..") || isAbsolute(rel)) {
|
|
24
|
+
fail(`${label} "${relPath}" escapes the project directory — refusing to copy.`);
|
|
25
|
+
}
|
|
26
|
+
return dest;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Resolve the gifsicle binary: prefer the optionalDependency's bundled build,
|
|
30
|
+
// fall back to a system install on PATH. Returns null when neither is present.
|
|
31
|
+
function gifsicleBin() {
|
|
32
|
+
try {
|
|
33
|
+
const mod = require("gifsicle");
|
|
34
|
+
// The package exports the binary path; under Node's require-ESM it arrives
|
|
35
|
+
// as a namespace ({ default: path }) rather than a bare string.
|
|
36
|
+
const p = typeof mod === "string" ? mod : mod?.default;
|
|
37
|
+
if (typeof p === "string" && existsSync(p)) return p;
|
|
38
|
+
} catch {
|
|
39
|
+
// optionalDependency not installed — fall through to a PATH lookup.
|
|
40
|
+
}
|
|
41
|
+
return "gifsicle";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Lossy gif pass. Remotion's native gif is already well quantized, but a
|
|
45
|
+
// gifsicle --lossy run roughly halves it again with text left crisp. Our
|
|
46
|
+
// atmosphere gradients band under palette/colour reduction, so we keep the full
|
|
47
|
+
// palette and only apply --lossy. Best-effort: a missing gifsicle is non-fatal.
|
|
48
|
+
function optimizeGif(file) {
|
|
49
|
+
const res = spawnSync(gifsicleBin(), ["-b", "-O3", "--lossy=60", file], { stdio: "ignore" });
|
|
50
|
+
if (res.error || res.status !== 0) {
|
|
51
|
+
console.warn("reelme: note — gif left unoptimized (install gifsicle for ~50% smaller files).");
|
|
52
|
+
}
|
|
53
|
+
}
|
|
9
54
|
|
|
10
55
|
function renderComposition(cacheDir, compositionId, outFile, codec) {
|
|
11
56
|
const args = ["exec", "remotion", "render", compositionId, join("out", outFile)];
|
|
12
|
-
|
|
57
|
+
// Render gifs at 0.6 scale (e.g. 1920x1080 -> 1152x648): a README gif needs no
|
|
58
|
+
// more, and fewer pixels roughly halves the file size. Layout and timing are
|
|
59
|
+
// unchanged — --scale downscales the output, not the composition coordinates.
|
|
60
|
+
if (codec === "gif") {
|
|
61
|
+
args.push("--codec=gif", "--scale=0.6");
|
|
62
|
+
} else {
|
|
63
|
+
// Remotion's default h264 bitrate is far higher than a social upload needs;
|
|
64
|
+
// crf 20 is visually lossless and cuts the file to roughly a third.
|
|
65
|
+
args.push("--crf=20");
|
|
66
|
+
}
|
|
13
67
|
console.log(`reelme: rendering ${compositionId} → reelme-out/${outFile}`);
|
|
14
|
-
const result =
|
|
68
|
+
const result = runShell("pnpm", args, cacheDir);
|
|
15
69
|
if (result.error || result.status !== 0) {
|
|
16
70
|
fail(`render failed for platform composition "${compositionId}".`);
|
|
17
71
|
}
|
|
72
|
+
if (codec === "gif") optimizeGif(join(cacheDir, "out", outFile));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function collectAssets(brief) {
|
|
76
|
+
const assets = [];
|
|
77
|
+
const cuts = [brief.cuts.main, brief.cuts.vertical, brief.cuts.teaser].filter(Boolean);
|
|
78
|
+
for (const cut of cuts) {
|
|
79
|
+
for (const scene of cut) {
|
|
80
|
+
if (scene.type === "clip" && scene.src) assets.push(scene.src);
|
|
81
|
+
if (scene.type === "mobile" && scene.screenshot) assets.push(scene.screenshot);
|
|
82
|
+
if (scene.type === "browser" && scene.image) assets.push(scene.image);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return [...new Set(assets)];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function copyAssets(repoRoot, cacheDir, brief) {
|
|
89
|
+
const assets = collectAssets(brief);
|
|
90
|
+
if (assets.length === 0) return;
|
|
91
|
+
const publicDir = join(cacheDir, "public");
|
|
92
|
+
mkdirSync(publicDir, { recursive: true });
|
|
93
|
+
// Resolve every path through the traversal guard before touching the disk.
|
|
94
|
+
const resolved = assets.map((asset) => ({
|
|
95
|
+
asset,
|
|
96
|
+
source: safeJoin(repoRoot, asset, "asset"),
|
|
97
|
+
dest: safeJoin(publicDir, asset, "asset"),
|
|
98
|
+
}));
|
|
99
|
+
const missing = resolved.filter(({ source }) => !existsSync(source));
|
|
100
|
+
if (missing.length > 0) {
|
|
101
|
+
fail(`missing asset file(s):\n${missing.map(({ source }) => ` ${source}`).join("\n")}`);
|
|
102
|
+
}
|
|
103
|
+
for (const { source, dest } of resolved) {
|
|
104
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
105
|
+
cpSync(source, dest);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function loadAudioManifest() {
|
|
110
|
+
const manifestPath = join(AUDIO_DIR, "manifest.json");
|
|
111
|
+
if (!existsSync(manifestPath)) {
|
|
112
|
+
fail(`audio manifest is missing from the package: ${manifestPath}`);
|
|
113
|
+
}
|
|
114
|
+
try {
|
|
115
|
+
const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
|
|
116
|
+
if (!Array.isArray(manifest)) throw new Error("manifest must be an array");
|
|
117
|
+
return manifest;
|
|
118
|
+
} catch (e) {
|
|
119
|
+
fail(`audio manifest is invalid: ${e.message}`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function copyAudioTrack(cacheDir, brief) {
|
|
124
|
+
const audio = brief.project?.audio;
|
|
125
|
+
if (!audio) return;
|
|
126
|
+
|
|
127
|
+
const track = audio.track;
|
|
128
|
+
if (typeof track !== "string" || track.length === 0 || basename(track) !== track) {
|
|
129
|
+
fail(`project.audio.track must be a filename from the bundled audio manifest.`);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const manifest = loadAudioManifest();
|
|
133
|
+
const validTracks = manifest.map((entry) => entry.file).filter(Boolean);
|
|
134
|
+
const entry = manifest.find((item) => item.file === track);
|
|
135
|
+
if (!entry) {
|
|
136
|
+
fail(
|
|
137
|
+
`unknown audio track "${track}". Valid tracks: ${validTracks.join(", ")}.`
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const source = join(AUDIO_DIR, track);
|
|
142
|
+
if (!existsSync(source)) {
|
|
143
|
+
fail(`bundled audio track "${track}" is missing from ${AUDIO_DIR}.`);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const audioDir = join(cacheDir, "public", "audio");
|
|
147
|
+
mkdirSync(audioDir, { recursive: true });
|
|
148
|
+
cpSync(source, join(audioDir, track));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function copyLogo(repoRoot, cacheDir, brief) {
|
|
152
|
+
const logo = brief.project?.logo;
|
|
153
|
+
if (!logo) return;
|
|
154
|
+
const source = safeJoin(repoRoot, logo, "project.logo");
|
|
155
|
+
if (!existsSync(source)) {
|
|
156
|
+
fail(`project.logo file not found: ${source}`);
|
|
157
|
+
}
|
|
158
|
+
const publicDir = join(cacheDir, "public");
|
|
159
|
+
const dest = safeJoin(publicDir, logo, "project.logo");
|
|
160
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
161
|
+
cpSync(source, dest);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Stage every render input into the cache. Shared by render() and studio() so
|
|
165
|
+
// Studio previews exactly what render() produces (F1) instead of showing 404s
|
|
166
|
+
// for missing assets/audio/logo.
|
|
167
|
+
export function stageInputs(repoRoot, cacheDir, brief) {
|
|
168
|
+
copyAssets(repoRoot, cacheDir, brief);
|
|
169
|
+
copyAudioTrack(cacheDir, brief);
|
|
170
|
+
copyLogo(repoRoot, cacheDir, brief);
|
|
18
171
|
}
|
|
19
172
|
|
|
20
173
|
export function render(repoRoot) {
|
|
@@ -24,13 +177,15 @@ export function render(repoRoot) {
|
|
|
24
177
|
const outDir = join(repoRoot, "reelme-out");
|
|
25
178
|
mkdirSync(outDir, { recursive: true });
|
|
26
179
|
|
|
180
|
+
stageInputs(repoRoot, cacheDir, brief);
|
|
181
|
+
|
|
27
182
|
const verticalFallback = brief.project.platforms.filter(
|
|
28
183
|
(id) => platforms[id].cut === "vertical" && !brief.cuts.vertical?.length
|
|
29
184
|
);
|
|
30
185
|
if (verticalFallback.length > 0) {
|
|
31
186
|
console.warn(
|
|
32
|
-
`reelme: warning — no cuts.vertical in reelme.json; ${verticalFallback.join(", ")} will render ` +
|
|
33
|
-
`the main cut
|
|
187
|
+
`reelme: warning — no cuts.vertical in reelme.json; ${verticalFallback.join(", ")} will re-render ` +
|
|
188
|
+
`the main cut at 9:16. Dense wide scenes may cramp — author a vertical cut for better results.`
|
|
34
189
|
);
|
|
35
190
|
}
|
|
36
191
|
|
|
@@ -38,7 +193,7 @@ export function render(repoRoot) {
|
|
|
38
193
|
const preset = platforms[id];
|
|
39
194
|
const outFile = basename(preset.output.file);
|
|
40
195
|
renderComposition(cacheDir, `Reel-${id}`, outFile, preset.output.codec);
|
|
41
|
-
|
|
196
|
+
copyOut(cacheDir, outFile, outDir);
|
|
42
197
|
}
|
|
43
198
|
|
|
44
199
|
const hasTeaser = Array.isArray(brief.cuts.teaser) && brief.cuts.teaser.length > 0;
|
|
@@ -49,9 +204,17 @@ export function render(repoRoot) {
|
|
|
49
204
|
for (const id of socialIds) {
|
|
50
205
|
const outFile = `${id}-teaser.mp4`;
|
|
51
206
|
renderComposition(cacheDir, `Reel-${id}-teaser`, outFile, "h264");
|
|
52
|
-
|
|
207
|
+
copyOut(cacheDir, outFile, outDir);
|
|
53
208
|
}
|
|
54
209
|
}
|
|
55
210
|
|
|
56
211
|
console.log(`reelme: done — outputs in ${outDir}`);
|
|
57
212
|
}
|
|
213
|
+
|
|
214
|
+
// Copy a finished render to reelme-out/, then drop the cache copy so out/ doesn't
|
|
215
|
+
// accumulate every render forever (F16). The deliverable already lives in the repo.
|
|
216
|
+
function copyOut(cacheDir, outFile, outDir) {
|
|
217
|
+
const cached = join(cacheDir, "out", outFile);
|
|
218
|
+
cpSync(cached, join(outDir, outFile));
|
|
219
|
+
rmSync(cached, { force: true });
|
|
220
|
+
}
|
package/template/package.json
CHANGED
|
@@ -9,11 +9,14 @@
|
|
|
9
9
|
"test": "vitest run"
|
|
10
10
|
},
|
|
11
11
|
"dependencies": {
|
|
12
|
-
"@remotion/cli": "4.0.
|
|
13
|
-
"@remotion/
|
|
12
|
+
"@remotion/cli": "4.0.410",
|
|
13
|
+
"@remotion/gif": "4.0.410",
|
|
14
|
+
"@remotion/google-fonts": "4.0.410",
|
|
14
15
|
"chroma-js": "^3.1.2",
|
|
15
16
|
"lucide-react": "^0.511.0",
|
|
16
|
-
"
|
|
17
|
+
"react": "^18.3.0",
|
|
18
|
+
"react-dom": "^18.3.0",
|
|
19
|
+
"remotion": "4.0.410"
|
|
17
20
|
},
|
|
18
21
|
"devDependencies": {
|
|
19
22
|
"@types/chroma-js": "^2.4.4",
|
|
@@ -21,8 +24,6 @@
|
|
|
21
24
|
"@typescript-eslint/eslint-plugin": "^8.0.0",
|
|
22
25
|
"@typescript-eslint/parser": "^8.0.0",
|
|
23
26
|
"eslint": "^9.0.0",
|
|
24
|
-
"react": "^18.3.0",
|
|
25
|
-
"react-dom": "^18.3.0",
|
|
26
27
|
"typescript": "^5.4.0",
|
|
27
28
|
"vite": "^7.3.5",
|
|
28
29
|
"vitest": "^4.1.8"
|