creator-editing-studio 1.0.20260930

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 (69) hide show
  1. package/README.md +15 -0
  2. package/bin/init.mjs +72 -0
  3. package/package.json +18 -0
  4. package/template/CLAUDE.md +341 -0
  5. package/template/STYLE.md +165 -0
  6. package/template/package.json +45 -0
  7. package/template/remotion.config.ts +7 -0
  8. package/template/requirements.txt +17 -0
  9. package/template/scripts/align_script.py +106 -0
  10. package/template/scripts/assemble.mjs +364 -0
  11. package/template/scripts/beats.mjs +273 -0
  12. package/template/scripts/blur_regions.py +40 -0
  13. package/template/scripts/check_pair.py +90 -0
  14. package/template/scripts/doctor.mjs +141 -0
  15. package/template/scripts/find_cuts.py +45 -0
  16. package/template/scripts/grade.py +202 -0
  17. package/template/scripts/lib/media.mjs +228 -0
  18. package/template/scripts/lib/media.py +99 -0
  19. package/template/scripts/make_cutout.py +68 -0
  20. package/template/scripts/motion-check.mjs +268 -0
  21. package/template/scripts/motion-lint.mjs +122 -0
  22. package/template/scripts/new-video.mjs +49 -0
  23. package/template/scripts/prep.mjs +287 -0
  24. package/template/scripts/ramp.mjs +149 -0
  25. package/template/scripts/refs.mjs +72 -0
  26. package/template/scripts/refstyle.mjs +177 -0
  27. package/template/scripts/sounddesign.mjs +332 -0
  28. package/template/scripts/stills.mjs +64 -0
  29. package/template/scripts/sync-audio.mjs +134 -0
  30. package/template/scripts/transcribe.py +103 -0
  31. package/template/scripts/trim.mjs +374 -0
  32. package/template/src/Root.tsx +42 -0
  33. package/template/src/components/BrandBackground.tsx +22 -0
  34. package/template/src/components/Captions.tsx +141 -0
  35. package/template/src/components/Cursor.tsx +93 -0
  36. package/template/src/components/DrawnCircle.tsx +55 -0
  37. package/template/src/components/FilmOverlay.tsx +139 -0
  38. package/template/src/components/Hero.tsx +114 -0
  39. package/template/src/components/HeroTag.tsx +92 -0
  40. package/template/src/components/HighlightMark.tsx +34 -0
  41. package/template/src/components/LogoIcon.tsx +45 -0
  42. package/template/src/components/MacWindow.tsx +94 -0
  43. package/template/src/components/MaskReveal.tsx +34 -0
  44. package/template/src/components/MotionBlur.tsx +34 -0
  45. package/template/src/components/PlatformTile.tsx +153 -0
  46. package/template/src/components/Presence.tsx +27 -0
  47. package/template/src/components/RollingNumber.tsx +89 -0
  48. package/template/src/components/ScreenView.tsx +52 -0
  49. package/template/src/components/Stage.tsx +103 -0
  50. package/template/src/components/Transition.tsx +100 -0
  51. package/template/src/components/VelocityBlur.tsx +53 -0
  52. package/template/src/compositions/FilmTest.tsx +67 -0
  53. package/template/src/compositions/PresetTest.tsx +52 -0
  54. package/template/src/design/color.ts +6 -0
  55. package/template/src/design/glow.ts +11 -0
  56. package/template/src/design/logos.json +18 -0
  57. package/template/src/design/motion.ts +90 -0
  58. package/template/src/design/overlays.ts +121 -0
  59. package/template/src/design/presets.ts +65 -0
  60. package/template/src/design/tokens.ts +200 -0
  61. package/template/src/index.ts +4 -0
  62. package/template/src/lib/animate.ts +194 -0
  63. package/template/src/lib/camera.ts +58 -0
  64. package/template/src/lib/continuity.ts +86 -0
  65. package/template/src/lib/project.ts +13 -0
  66. package/template/src/lib/spine.ts +37 -0
  67. package/template/src/videos/sample/Sample.tsx +58 -0
  68. package/template/templates/BRIEF.md +88 -0
  69. package/template/tsconfig.json +17 -0
@@ -0,0 +1,287 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * One-command prep for a video, run by the user before a session so the session starts on design.
4
+ *
5
+ * npm run prep -- <name>
6
+ *
7
+ * Reads assets/projects/<name>/BRIEF.md and footage/, then:
8
+ * 1. probes every clip (codec, frame rate, HDR, size, length, variable frame rate)
9
+ * 2. conforms the talking head (+ green-screen twin) to H.264, SDR, constant 30fps when needed —
10
+ * audio is stream-copied, never re-encoded (see the Audio standard in CLAUDE.md)
11
+ * 3. transcribes it (WhisperX) and aligns the brief's script to the speech
12
+ * 4. finds jump cuts, checks the green-screen twin lines up frame for frame
13
+ * 5. conforms b-roll: crops black bars, 30fps, muted; flags genuinely vertical clips
14
+ * 6. pulls frames for every "Moments from references" line into contact sheets
15
+ * 7. writes work/<name>/prep/PREP.md — the first thing Claude reads in the session
16
+ *
17
+ * Safe to re-run: finished steps are skipped (delete work/<name>/prep to force a full redo).
18
+ */
19
+ import {spawnSync} from 'node:child_process';
20
+ import {existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync} from 'node:fs';
21
+ import {basename, extname, join} from 'node:path';
22
+
23
+ /**
24
+ * Tool paths come from lib/media.mjs, which resolves them per platform. Hardcoding a
25
+ * Windows path here is exactly what broke this on a Mac: the resolver already existed and
26
+ * its own docs said "every script goes through this" — and this one quietly did not.
27
+ */
28
+ import {FFMPEG, FFPROBE, PYTHON} from './lib/media.mjs';
29
+ // Clips shot at 25 or 30fps (constant) keep their native rate: converting 25 -> 30 duplicates frames
30
+ // and stutters. 25fps is what to shoot under 50Hz mains lighting (India) to avoid flicker.
31
+ const NATIVE_FPS = [25, 30];
32
+ const FPS = 30;
33
+ const VIDEO_EXT = new Set(['.mp4', '.mov', '.m4v', '.webm', '.mkv']);
34
+
35
+ const name = process.argv[2];
36
+ if (!name) {
37
+ console.error('Usage: npm run prep -- <video-name>');
38
+ process.exit(2);
39
+ }
40
+ const assets = join('assets', 'projects', name);
41
+ const footageDir = join(assets, 'footage');
42
+ const processedDir = join(assets, 'processed');
43
+ const code = join('src', 'videos', name);
44
+ const prepDir = join('work', name, 'prep');
45
+ if (!existsSync(footageDir)) {
46
+ console.error(`No footage folder at ${footageDir}. Run "npm run new-video -- ${name}" first.`);
47
+ process.exit(1);
48
+ }
49
+ for (const d of [processedDir, code, prepDir]) mkdirSync(d, {recursive: true});
50
+
51
+ const report = [];
52
+ const problems = [];
53
+ const log = (line) => {
54
+ console.log(line);
55
+ report.push(line);
56
+ };
57
+ const run = (cmd, args, opts = {}) => {
58
+ // PYTHONIOENCODING: the Python helpers print em dashes; without it Windows mangles them.
59
+ const r = spawnSync(cmd, args, {encoding: 'utf8', maxBuffer: 1e8, env: {...process.env, PYTHONIOENCODING: 'utf-8'}, ...opts});
60
+ return {ok: r.status === 0, out: `${r.stdout ?? ''}${r.stderr ?? ''}`};
61
+ };
62
+
63
+ /* ------------------------------------------------------------------ brief */
64
+
65
+ const briefPath = join(assets, 'BRIEF.md');
66
+ const brief = existsSync(briefPath) ? readFileSync(briefPath, 'utf8').replace(/<!--[\s\S]*?-->/g, '') : '';
67
+ const section = (title) => {
68
+ const m = brief.match(new RegExp(`^## ${title}[^\\n]*\\n([\\s\\S]*?)(?=^## |^---|$(?![\\s\\S]))`, 'm'));
69
+ return m ? m[1].trim() : '';
70
+ };
71
+ const field = (label) => {
72
+ const m = brief.match(new RegExp(`^${label}:[ \\t]*([^\\n]*)$`, 'm'));
73
+ return m ? m[1].trim() : '';
74
+ };
75
+ const language = field('Language') || 'en';
76
+ const format = field('Format') || '(not set)';
77
+
78
+ /* ------------------------------------------------------------------ probe */
79
+
80
+ const probe = (file) => {
81
+ const r = run(FFPROBE, ['-v', 'error', '-show_entries', 'stream=codec_type,codec_name,width,height,r_frame_rate,avg_frame_rate,color_transfer:format=duration', '-of', 'json', file]);
82
+ if (!r.ok) return null;
83
+ const j = JSON.parse(r.out);
84
+ const v = j.streams.find((s) => s.codec_type === 'video');
85
+ const a = j.streams.find((s) => s.codec_type === 'audio');
86
+ if (!v) return null;
87
+ const rate = (s) => {
88
+ const [n, d] = s.split('/').map(Number);
89
+ return d ? n / d : n;
90
+ };
91
+ const fps = rate(v.avg_frame_rate);
92
+ return {
93
+ codec: v.codec_name,
94
+ width: v.width,
95
+ height: v.height,
96
+ fps: Number(fps.toFixed(3)),
97
+ vfr: Math.abs(rate(v.r_frame_rate) - fps) > 0.05,
98
+ hdr: ['smpte2084', 'arib-std-b67'].includes(v.color_transfer),
99
+ audio: a ? a.codec_name : null,
100
+ duration: Number(Number(j.format.duration).toFixed(2)),
101
+ };
102
+ };
103
+
104
+ const clips = readdirSync(footageDir)
105
+ .filter((f) => VIDEO_EXT.has(extname(f).toLowerCase()))
106
+ .map((f) => ({file: f, path: join(footageDir, f), info: probe(join(footageDir, f)), size: statSync(join(footageDir, f)).size}));
107
+ if (!clips.length) {
108
+ console.error(`No video files in ${footageDir}.`);
109
+ process.exit(1);
110
+ }
111
+
112
+ // Talking head: the brief's choice, else the clip that has a "-GS"/greenscreen twin, else the largest.
113
+ const isTwinName = (f) => /(-|_| )(gs|greenscreen|green)\.[a-z0-9]+$/i.test(f);
114
+ const twinOf = (f) => clips.find((c) => c.file !== f && isTwinName(c.file) && c.file.replace(/(-|_| )(gs|greenscreen|green)(?=\.[a-z0-9]+$)/i, '') === f);
115
+ const named = field('Talking head file');
116
+ const main =
117
+ clips.find((c) => named && c.file === named) ?? clips.find((c) => !isTwinName(c.file) && twinOf(c.file)) ?? [...clips].filter((c) => !isTwinName(c.file)).sort((a, b) => b.size - a.size)[0];
118
+ const twinNamed = field('Green-screen twin');
119
+ const twin = clips.find((c) => twinNamed && c.file === twinNamed) ?? twinOf(main.file) ?? null;
120
+ const broll = clips.filter((c) => c !== main && c !== twin);
121
+
122
+ log(`# Prep: ${name}`);
123
+ log('');
124
+ log(`Format: ${format} · Language: ${language}`);
125
+ log(`Talking head: ${main.file}${twin ? ` · green-screen twin: ${twin.file}` : ' · no green-screen twin (no text behind the subject)'}`);
126
+ log('');
127
+ log('## Footage');
128
+ log('| File | Role | Size | fps | Codec | HDR | VFR | Length | Audio |');
129
+ log('|---|---|---|---|---|---|---|---|---|');
130
+ for (const c of clips) {
131
+ const i = c.info;
132
+ const role = c === main ? 'talking head' : c === twin ? 'green twin' : 'b-roll';
133
+ if (!i) {
134
+ log(`| ${c.file} | ${role} | unreadable | | | | | | |`);
135
+ problems.push(`${c.file} could not be read.`);
136
+ continue;
137
+ }
138
+ log(`| ${c.file} | ${role} | ${i.width}x${i.height} | ${i.fps} | ${i.codec} | ${i.hdr ? 'YES' : 'no'} | ${i.vfr ? 'YES' : 'no'} | ${i.duration}s | ${i.audio ?? 'none'} |`);
139
+ }
140
+ log('');
141
+
142
+ /* ------------------------------------------------------------------ conform talking head (+ twin) */
143
+
144
+ const targetFps = (i) => NATIVE_FPS.find((f) => Math.abs(i.fps - f) < 0.02) ?? FPS;
145
+ const needsConform = (i) => i.codec !== 'h264' || i.hdr || i.vfr || Math.abs(i.fps - targetFps(i)) > 0.02;
146
+ const tonemap = 'zscale=t=linear:npl=100,format=gbrpf32le,zscale=p=bt709,tonemap=hable:desat=0,zscale=t=bt709:m=bt709:r=tv,format=yuv420p';
147
+ const conform = (c) => {
148
+ if (!c.info || !needsConform(c.info)) return c.path;
149
+ const out = join(processedDir, `${basename(c.file, extname(c.file))}.mp4`);
150
+ if (existsSync(out)) return out;
151
+ const vf = [c.info.hdr ? tonemap : null, `fps=${targetFps(c.info)}`, 'format=yuv420p'].filter(Boolean).join(',');
152
+ console.log(`Conforming ${c.file} (${[c.info.hdr && 'HDR', c.info.vfr && 'variable frame rate', c.info.codec !== 'h264' && c.info.codec, Math.abs(c.info.fps - targetFps(c.info)) > 0.02 && `${c.info.fps}fps`].filter(Boolean).join(', ')})...`);
153
+ // Audio is copied untouched — the voice is never re-encoded.
154
+ const r = run(FFMPEG, ['-y', '-loglevel', 'error', '-i', c.path, '-vf', vf, '-c:v', 'libx264', '-crf', '14', '-preset', 'medium', '-c:a', 'copy', '-movflags', '+faststart', out]);
155
+ if (!r.ok) {
156
+ problems.push(`Conforming ${c.file} failed: ${r.out.slice(-300)}`);
157
+ return c.path;
158
+ }
159
+ return out;
160
+ };
161
+ const talk = conform(main);
162
+ // The edit runs at the talking head's frame rate; b-roll is conformed to match it.
163
+ const talkFps = main.info ? targetFps(main.info) : FPS;
164
+ const talkTwin = twin ? conform(twin) : null;
165
+ log('## Talking head');
166
+ log(`Edit frame rate: ${talkFps}fps (build the composition at this rate)`);
167
+ log(`Use in the edit: \`${talk.split('\\').join('/').replace(/^assets\//, '')}\` (path inside assets/)${talk === main.path ? ' — already clean, used as recorded' : ' — conformed, audio copied untouched'}`);
168
+ if (talkTwin) log(`Green twin: \`${talkTwin.split('\\').join('/').replace(/^assets\//, '')}\``);
169
+ log('');
170
+
171
+ /* ------------------------------------------------------------------ script, transcript, alignment */
172
+
173
+ const scriptFromBrief = section('Script');
174
+ const scriptPath = join(code, 'script.txt');
175
+ if (scriptFromBrief && (!existsSync(scriptPath) || !readFileSync(scriptPath, 'utf8').trim())) writeFileSync(scriptPath, `${scriptFromBrief}\n`);
176
+ const script = existsSync(scriptPath) ? readFileSync(scriptPath, 'utf8').trim() : '';
177
+
178
+ const stem = basename(talk, extname(talk));
179
+ const transcript = join('work', name, stem, 'words.json');
180
+ if (!existsSync(transcript)) {
181
+ console.log('Transcribing (a few minutes)...');
182
+ const r = run(PYTHON, ['scripts/transcribe.py', talk, '--language', language, '--project', name]);
183
+ if (!r.ok) problems.push(`Transcription failed: ${r.out.slice(-400)}`);
184
+ }
185
+ log('## Words');
186
+ if (existsSync(transcript)) {
187
+ const said = JSON.parse(readFileSync(transcript, 'utf8'));
188
+ const text = (said.words ?? said).map((w) => w.word ?? w.text).join(' ');
189
+ if (script) {
190
+ const r = run(PYTHON, ['scripts/align_script.py', scriptPath, transcript, join(code, 'words.json')]);
191
+ const stats = r.out.split('\n').filter((l) => /script words|interpolated/.test(l));
192
+ stats.forEach((l) => log(`- ${l.trim()}`));
193
+ if (!r.ok) problems.push(`Script alignment failed: ${r.out.slice(-300)}`);
194
+ } else {
195
+ problems.push('No script in BRIEF.md or script.txt — captions would follow the raw transcription. Paste the script and re-run.');
196
+ }
197
+ log('');
198
+ log(`Heard: "${text}"`);
199
+ } else {
200
+ log('- no transcript');
201
+ }
202
+ log('');
203
+
204
+ /* ------------------------------------------------------------------ cuts + twin check */
205
+
206
+ log('## Jump cuts and green twin');
207
+ const cutsPath = join(code, 'cuts.json');
208
+ if (!existsSync(cutsPath)) run(PYTHON, ['scripts/find_cuts.py', talk, cutsPath]);
209
+ if (existsSync(cutsPath)) {
210
+ const cuts = JSON.parse(readFileSync(cutsPath, 'utf8'));
211
+ log(`- ${cuts.length} jump cut(s) at frames ${cuts.join(', ') || '—'} (${cuts.map((f) => `${(f / talkFps).toFixed(2)}s`).join(', ')}) — zoom through each (Stage cutZoom).`);
212
+ } else {
213
+ problems.push('Jump-cut detection failed.');
214
+ }
215
+ if (talkTwin) {
216
+ const r = run(PYTHON, ['scripts/check_pair.py', talk, talkTwin]);
217
+ const result = r.out.split('\n').find((l) => l.startsWith('RESULT')) ?? 'no result';
218
+ log(`- Green twin: ${result.trim()}`);
219
+ if (!/offset of \+0/.test(result)) problems.push(`Green twin is not frame-aligned: ${result.trim()}`);
220
+ }
221
+ log('');
222
+
223
+ /* ------------------------------------------------------------------ b-roll */
224
+
225
+ log(`## B-roll (conformed: black bars cropped, ${talkFps}fps, muted)`);
226
+ if (!broll.length) log('- none');
227
+ for (const c of broll) {
228
+ if (!c.info) continue;
229
+ const slug = basename(c.file, extname(c.file)).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
230
+ const out = join(processedDir, `broll-${slug}.mp4`);
231
+ const detect = run(FFMPEG, ['-hide_banner', '-ss', '0.5', '-t', '8', '-i', c.path, '-vf', 'cropdetect=limit=24:round=2:reset=0', '-f', 'null', '-']);
232
+ const crops = [...detect.out.matchAll(/crop=(\d+):(\d+):(\d+):(\d+)/g)].map((m) => m.slice(1).map(Number));
233
+ const [cw, ch, cx, cy] = crops.length ? crops[crops.length - 1] : [c.info.width, c.info.height, 0, 0];
234
+ const letterboxed = ch < c.info.height * 0.85 || cw < c.info.width * 0.85;
235
+ const vertical = cw / ch < 0.9;
236
+ if (!existsSync(out)) {
237
+ const scale = cw >= ch ? 'scale=min(1920\\,iw):-2' : 'scale=-2:min(1920\\,ih)';
238
+ const vf = [letterboxed ? `crop=${cw}:${ch}:${cx}:${cy}` : null, c.info.hdr ? tonemap : null, scale, `fps=${talkFps}`, 'format=yuv420p'].filter(Boolean).join(',');
239
+ const r = run(FFMPEG, ['-y', '-loglevel', 'error', '-i', c.path, '-an', '-vf', vf, '-c:v', 'libx264', '-crf', '17', '-preset', 'medium', '-movflags', '+faststart', out]);
240
+ if (!r.ok) problems.push(`B-roll ${c.file} failed: ${r.out.slice(-200)}`);
241
+ }
242
+ log(`- ${c.file} → \`processed/${basename(out)}\` · picture ${cw}x${ch}${letterboxed ? ' (black bars removed)' : ''}${vertical ? ' · **genuinely vertical**' : ''}${c.info.width < 1280 && c.info.height < 1280 ? ' · low resolution — use small, not full screen' : ''}`);
243
+ }
244
+ log('');
245
+
246
+ /* ------------------------------------------------------------------ reference moments */
247
+
248
+ log('## Moments from references (contact sheets to describe back before building)');
249
+ const moments = section('Moments from references')
250
+ .split('\n')
251
+ .map((l) => l.match(/^-\s*(.+?\.(?:mp4|mov|webm|m4v))\s+(\d{1,2}:\d{2})(?:\s*[-–]\s*(\d{1,2}:\d{2}))?\s*[—–-]*\s*(.*)$/i))
252
+ .filter(Boolean);
253
+ const secs = (t) => t.split(':').reduce((s, v) => s * 60 + Number(v), 0);
254
+ if (!moments.length) log('- none listed');
255
+ moments.forEach(([, file, from, to, note], i) => {
256
+ const src = join('references', file.trim());
257
+ if (!existsSync(src)) {
258
+ problems.push(`Reference clip not found: references/${file.trim()}`);
259
+ return;
260
+ }
261
+ const start = Math.max(0, secs(from) - 0.3);
262
+ const len = Math.max(1, (to ? secs(to) : secs(from) + 1) - start + 0.3);
263
+ const sheet = join(prepDir, `ref-${i + 1}.jpg`);
264
+ run(FFMPEG, ['-y', '-loglevel', 'error', '-ss', String(start), '-t', String(len), '-i', src, '-vf', `fps=${Math.min(8, Math.max(3, Math.round(18 / len)))},scale=320:-2,tile=6x3`, '-frames:v', '1', sheet]);
265
+ log(`- ${i + 1}. ${file.trim()} ${from}${to ? `–${to}` : ''} — ${note || '(no note)'} → \`${sheet.split('\\').join('/')}\``);
266
+ });
267
+ log('');
268
+
269
+ /* ------------------------------------------------------------------ brief summary */
270
+
271
+ log('## From the brief');
272
+ for (const title of ['The one thing viewers must remember', 'Must-have moments', 'Effects from the library', 'B-roll', 'Screenshots / things to blur', 'CTA / ending', 'Sound', "Don't want"]) {
273
+ const body = section(title.replace(/[/()]/g, '.'));
274
+ if (body) {
275
+ log(`### ${title}`);
276
+ log(body);
277
+ log('');
278
+ }
279
+ }
280
+
281
+ log('## Problems');
282
+ if (!problems.length) log('- none');
283
+ problems.forEach((p) => log(`- ${p}`));
284
+
285
+ writeFileSync(join(prepDir, 'PREP.md'), `${report.join('\n')}\n`);
286
+ console.log(`\nPrep written to ${join(prepDir, 'PREP.md')}${problems.length ? ` — ${problems.length} problem(s) listed at the end` : ''}.`);
287
+ process.exit(problems.length ? 1 : 0);
@@ -0,0 +1,149 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Speed ramps — slow-mo and speed-ups that ease in and out instead of switching hard.
4
+ *
5
+ * npm run ramp -- <clip> --ramp 3.0-5.0:1->0.4 [--ramp 8-9:1->2.5] [--ease inOut] [--out path]
6
+ *
7
+ * Reads as: between 3s and 5s, glide from normal speed to 0.4x.
8
+ * Audio is stretched to match, so a ramped clip stays usable with sound.
9
+ *
10
+ * No frame interpolation in this version: going below about 0.4x on 30fps footage will
11
+ * start to judder. Shoot 60fps for heavy slow-mo, or keep ramps on b-roll.
12
+ */
13
+ import {existsSync, mkdirSync} from 'node:fs';
14
+ import {basename, dirname, extname, join} from 'node:path';
15
+ import {FFMPEG, fmt, probe, run} from './lib/media.mjs';
16
+
17
+ const args = process.argv.slice(2);
18
+ const positional = [];
19
+ for (let i = 0; i < args.length; i++) {
20
+ if (args[i].startsWith('--')) { i++; continue; }
21
+ positional.push(args[i]);
22
+ }
23
+ const flag = (name, fallback) => {
24
+ const i = args.indexOf(`--${name}`);
25
+ return i >= 0 && args[i + 1] ? args[i + 1] : fallback;
26
+ };
27
+ const allFlags = (name) => args.map((a, i) => (a === `--${name}` ? args[i + 1] : null)).filter(Boolean);
28
+
29
+ const input = positional[0];
30
+ if (!input || !existsSync(input)) {
31
+ console.error('Usage: npm run ramp -- <clip> --ramp 3.0-5.0:1->0.4 [--ease inOut] [--out path]');
32
+ process.exit(2);
33
+ }
34
+
35
+ const info = probe(input);
36
+ if (!info) {
37
+ console.error('Could not read that file.');
38
+ process.exit(1);
39
+ }
40
+
41
+ /* ---------------------------------------------------------------- parse the ramps */
42
+
43
+ const ramps = allFlags('ramp').map((spec) => {
44
+ const m = /^([\d.]+)-([\d.]+):([\d.]+)->([\d.]+)$/.exec(spec.trim());
45
+ if (!m) {
46
+ console.error(`Bad ramp "${spec}". Use start-end:fromSpeed->toSpeed, e.g. 3.0-5.0:1->0.4`);
47
+ process.exit(2);
48
+ }
49
+ const [, a, b, from, to] = m;
50
+ return {start: Number(a), end: Number(b), from: Number(from), to: Number(to)};
51
+ }).sort((x, y) => x.start - y.start);
52
+
53
+ if (!ramps.length) {
54
+ console.error('Give at least one --ramp, e.g. --ramp 3.0-5.0:1->0.4');
55
+ process.exit(2);
56
+ }
57
+ for (const r of ramps) {
58
+ if (r.end <= r.start) { console.error(`Ramp ends before it starts: ${r.start}-${r.end}`); process.exit(2); }
59
+ if (r.end > info.duration + 0.01) { console.error(`Ramp at ${r.end}s is past the end of the clip (${fmt(info.duration, 2)}s).`); process.exit(2); }
60
+ if (r.from <= 0 || r.to <= 0) { console.error('Speeds must be greater than 0.'); process.exit(2); }
61
+ }
62
+
63
+ /* ---------------------------------------------------------------- build the segments */
64
+
65
+ const ease = flag('ease', 'inOut');
66
+ const curve = (u) => {
67
+ if (ease === 'linear') return u;
68
+ if (ease === 'in') return u * u;
69
+ if (ease === 'out') return 1 - (1 - u) * (1 - u);
70
+ return u * u * (3 - 2 * u); // inOut: smoothstep, matches our motion feel
71
+ };
72
+
73
+ const segments = [];
74
+ let cursor = 0;
75
+ for (const r of ramps) {
76
+ if (r.start > cursor + 0.001) segments.push({start: cursor, end: r.start, speed: 1});
77
+ // A ramp is many short constant-speed pieces; enough of them and the change reads as smooth.
78
+ const steps = Math.max(8, Math.round((r.end - r.start) * 12));
79
+ const stepLen = (r.end - r.start) / steps;
80
+ for (let i = 0; i < steps; i++) {
81
+ const u = (i + 0.5) / steps;
82
+ segments.push({
83
+ start: r.start + i * stepLen,
84
+ end: r.start + (i + 1) * stepLen,
85
+ speed: r.from + (r.to - r.from) * curve(u),
86
+ });
87
+ }
88
+ cursor = r.end;
89
+ }
90
+ if (cursor < info.duration - 0.001) segments.push({start: cursor, end: info.duration, speed: 1});
91
+
92
+ /** atempo only accepts 0.5–2, so chain it for anything outside that. */
93
+ const atempoChain = (speed) => {
94
+ const parts = [];
95
+ let s = speed;
96
+ while (s < 0.5) { parts.push(0.5); s /= 0.5; }
97
+ while (s > 2) { parts.push(2); s /= 2; }
98
+ parts.push(Number(s.toFixed(6)));
99
+ return parts.map((p) => `atempo=${p}`).join(',');
100
+ };
101
+
102
+ const hasAudio = info.hasAudio;
103
+ const filters = [];
104
+ segments.forEach((seg, i) => {
105
+ filters.push(
106
+ `[0:v]trim=start=${fmt(seg.start, 4)}:end=${fmt(seg.end, 4)},setpts=(PTS-STARTPTS)/${fmt(seg.speed, 6)}[v${i}]`,
107
+ );
108
+ if (hasAudio) {
109
+ filters.push(
110
+ `[0:a]atrim=start=${fmt(seg.start, 4)}:end=${fmt(seg.end, 4)},asetpts=PTS-STARTPTS,${atempoChain(seg.speed)}[a${i}]`,
111
+ );
112
+ }
113
+ });
114
+ const streams = segments.map((_, i) => (hasAudio ? `[v${i}][a${i}]` : `[v${i}]`)).join('');
115
+ filters.push(`${streams}concat=n=${segments.length}:v=1:a=${hasAudio ? 1 : 0}[vout]${hasAudio ? '[aout]' : ''}`);
116
+
117
+ const outPath = flag('out', join(dirname(input), `${basename(input, extname(input))}-ramped.mp4`));
118
+ mkdirSync(dirname(outPath), {recursive: true});
119
+
120
+ const cmd = [
121
+ '-y', '-loglevel', 'error', '-i', input,
122
+ '-filter_complex', filters.join(';'),
123
+ '-map', '[vout]',
124
+ ...(hasAudio ? ['-map', '[aout]'] : []),
125
+ '-c:v', 'libx264', '-crf', '17', '-preset', 'medium', '-pix_fmt', 'yuv420p',
126
+ ...(hasAudio ? ['-c:a', 'aac', '-b:a', '256k'] : []),
127
+ '-movflags', '+faststart',
128
+ outPath,
129
+ ];
130
+
131
+ const outputDuration = segments.reduce((sum, s) => sum + (s.end - s.start) / s.speed, 0);
132
+ console.log(`In ${input} (${fmt(info.duration, 2)}s, ${fmt(info.fps, 2)}fps${hasAudio ? ', with audio' : ', silent'})`);
133
+ for (const r of ramps) {
134
+ console.log(`Ramp ${fmt(r.start, 2)}s → ${fmt(r.end, 2)}s : ${r.from}x → ${r.to}x (${ease})`);
135
+ }
136
+ console.log(`Out ${outPath} (${fmt(outputDuration, 2)}s expected)`);
137
+ console.log(` ${segments.length} segments, rendering…`);
138
+
139
+ const r = run(FFMPEG, cmd);
140
+ if (!r.ok) {
141
+ console.error('\nffmpeg failed:\n' + r.err.split('\n').slice(0, 8).join('\n'));
142
+ process.exit(1);
143
+ }
144
+
145
+ const done = probe(outPath);
146
+ console.log(`Done ${fmt(done?.duration ?? 0, 2)}s`);
147
+ if (Math.min(...ramps.map((x) => Math.min(x.from, x.to))) < 0.4 && info.fps < 50) {
148
+ console.log('\nHeads up: below 0.4x on sub-50fps footage can judder. Shoot 60fps for heavy slow-mo.');
149
+ }
@@ -0,0 +1,72 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Contact sheets for every timestamped moment in references/references.md, so Claude can study how
4
+ * things move between frames and describe each moment back before it becomes a beat.
5
+ *
6
+ * npm run refs only moments that don't have a sheet yet
7
+ * npm run refs -- --all redo every sheet
8
+ *
9
+ * Reads entries shaped like:
10
+ * ## 4. `farza.mp4` — Farza
11
+ * - **00:11–00:14** — what happens...
12
+ * Writes work/refs/<clip>-<mm.ss>-<mm.ss>.jpg and work/refs/INDEX.md (moment -> sheet), and lists
13
+ * clips that are in the folder but have no entry yet, or entries whose clip is missing.
14
+ */
15
+ import {spawnSync} from 'node:child_process';
16
+ import {existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync} from 'node:fs';
17
+ import {basename, extname, join} from 'node:path';
18
+
19
+ const FFMPEG = 'D:\\Tools\\ffmpeg\\bin\\ffmpeg.exe';
20
+ const REF_DIR = 'references';
21
+ const OUT = join('work', 'refs');
22
+ const all = process.argv.includes('--all');
23
+ mkdirSync(OUT, {recursive: true});
24
+
25
+ const md = readFileSync(join(REF_DIR, 'references.md'), 'utf8');
26
+ const secs = (t) => t.split(':').reduce((s, v) => s * 60 + Number(v), 0);
27
+
28
+ const entries = [];
29
+ let clip = null;
30
+ for (const line of md.split('\n')) {
31
+ const head = line.match(/^##\s+\d+\.\s+`([^`]+)`/);
32
+ if (head) {
33
+ clip = head[1];
34
+ continue;
35
+ }
36
+ if (line.startsWith('## ')) clip = null;
37
+ const moment = clip && line.match(/^-\s+\*\*(\d{1,2}:\d{2})(?:\s*[–-]\s*(\d{1,2}:\d{2}))?\*\*\s*[—–-]\s*(.*)$/);
38
+ if (moment) entries.push({clip, from: moment[1], to: moment[2], note: moment[3]});
39
+ }
40
+
41
+ const index = ['# Reference contact sheets', '', 'Frames left to right, top to bottom. Generated by `npm run refs`.', ''];
42
+ const missing = new Set();
43
+ let made = 0;
44
+ for (const e of entries) {
45
+ const src = join(REF_DIR, e.clip);
46
+ if (!existsSync(src)) {
47
+ missing.add(e.clip);
48
+ continue;
49
+ }
50
+ const tag = `${basename(e.clip, extname(e.clip)).toLowerCase().replace(/[^a-z0-9]+/g, '-')}-${e.from.replace(':', '.')}${e.to ? `-${e.to.replace(':', '.')}` : ''}`;
51
+ const sheet = join(OUT, `${tag}.jpg`);
52
+ if (all || !existsSync(sheet)) {
53
+ const start = Math.max(0, secs(e.from) - 0.3);
54
+ const len = Math.max(1, (e.to ? secs(e.to) : secs(e.from) + 1) - start + 0.3);
55
+ // ~18 frames spread over the moment (3-8 fps), 6 per row.
56
+ const fps = Math.min(8, Math.max(3, Math.round(18 / len)));
57
+ const r = spawnSync(FFMPEG, ['-y', '-loglevel', 'error', '-ss', String(start), '-t', String(len), '-i', src, '-vf', `fps=${fps},scale=320:-2,tile=6x3`, '-frames:v', '1', sheet]);
58
+ if (r.status === 0) made++;
59
+ }
60
+ index.push(`- \`${e.clip}\` ${e.from}${e.to ? `–${e.to}` : ''} — ${e.note.slice(0, 90)}${e.note.length > 90 ? '…' : ''} → \`${sheet.split('\\').join('/')}\``);
61
+ }
62
+
63
+ const described = new Set(entries.map((e) => e.clip));
64
+ const unlisted = readdirSync(REF_DIR).filter((f) => /\.(mp4|mov|webm|m4v)$/i.test(f) && !described.has(f));
65
+ index.push('');
66
+ if (unlisted.length) index.push(`Clips with no entry in references.md yet: ${unlisted.join(', ')}`);
67
+ if (missing.size) index.push(`Entries whose clip is missing from references/: ${[...missing].join(', ')}`);
68
+ writeFileSync(join(OUT, 'INDEX.md'), `${index.join('\n')}\n`);
69
+
70
+ console.log(`${entries.length} moments in references.md · ${made} new sheet(s) · index: ${join(OUT, 'INDEX.md')}`);
71
+ if (unlisted.length) console.log(`No entry yet: ${unlisted.join(', ')}`);
72
+ if (missing.size) console.log(`Clip missing: ${[...missing].join(', ')}`);