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.
- package/README.md +15 -0
- package/bin/init.mjs +72 -0
- package/package.json +18 -0
- package/template/CLAUDE.md +341 -0
- package/template/STYLE.md +165 -0
- package/template/package.json +45 -0
- package/template/remotion.config.ts +7 -0
- package/template/requirements.txt +17 -0
- package/template/scripts/align_script.py +106 -0
- package/template/scripts/assemble.mjs +364 -0
- package/template/scripts/beats.mjs +273 -0
- package/template/scripts/blur_regions.py +40 -0
- package/template/scripts/check_pair.py +90 -0
- package/template/scripts/doctor.mjs +141 -0
- package/template/scripts/find_cuts.py +45 -0
- package/template/scripts/grade.py +202 -0
- package/template/scripts/lib/media.mjs +228 -0
- package/template/scripts/lib/media.py +99 -0
- package/template/scripts/make_cutout.py +68 -0
- package/template/scripts/motion-check.mjs +268 -0
- package/template/scripts/motion-lint.mjs +122 -0
- package/template/scripts/new-video.mjs +49 -0
- package/template/scripts/prep.mjs +287 -0
- package/template/scripts/ramp.mjs +149 -0
- package/template/scripts/refs.mjs +72 -0
- package/template/scripts/refstyle.mjs +177 -0
- package/template/scripts/sounddesign.mjs +332 -0
- package/template/scripts/stills.mjs +64 -0
- package/template/scripts/sync-audio.mjs +134 -0
- package/template/scripts/transcribe.py +103 -0
- package/template/scripts/trim.mjs +374 -0
- package/template/src/Root.tsx +42 -0
- package/template/src/components/BrandBackground.tsx +22 -0
- package/template/src/components/Captions.tsx +141 -0
- package/template/src/components/Cursor.tsx +93 -0
- package/template/src/components/DrawnCircle.tsx +55 -0
- package/template/src/components/FilmOverlay.tsx +139 -0
- package/template/src/components/Hero.tsx +114 -0
- package/template/src/components/HeroTag.tsx +92 -0
- package/template/src/components/HighlightMark.tsx +34 -0
- package/template/src/components/LogoIcon.tsx +45 -0
- package/template/src/components/MacWindow.tsx +94 -0
- package/template/src/components/MaskReveal.tsx +34 -0
- package/template/src/components/MotionBlur.tsx +34 -0
- package/template/src/components/PlatformTile.tsx +153 -0
- package/template/src/components/Presence.tsx +27 -0
- package/template/src/components/RollingNumber.tsx +89 -0
- package/template/src/components/ScreenView.tsx +52 -0
- package/template/src/components/Stage.tsx +103 -0
- package/template/src/components/Transition.tsx +100 -0
- package/template/src/components/VelocityBlur.tsx +53 -0
- package/template/src/compositions/FilmTest.tsx +67 -0
- package/template/src/compositions/PresetTest.tsx +52 -0
- package/template/src/design/color.ts +6 -0
- package/template/src/design/glow.ts +11 -0
- package/template/src/design/logos.json +18 -0
- package/template/src/design/motion.ts +90 -0
- package/template/src/design/overlays.ts +121 -0
- package/template/src/design/presets.ts +65 -0
- package/template/src/design/tokens.ts +200 -0
- package/template/src/index.ts +4 -0
- package/template/src/lib/animate.ts +194 -0
- package/template/src/lib/camera.ts +58 -0
- package/template/src/lib/continuity.ts +86 -0
- package/template/src/lib/project.ts +13 -0
- package/template/src/lib/spine.ts +37 -0
- package/template/src/videos/sample/Sample.tsx +58 -0
- package/template/templates/BRIEF.md +88 -0
- package/template/tsconfig.json +17 -0
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Read a reference edit and write a style brief: how fast it cuts, how it paces itself,
|
|
4
|
+
* and what its colour looks like. Feed the brief to a session and say "match this energy".
|
|
5
|
+
*
|
|
6
|
+
* npm run refstyle -- references/somereel.mp4 [--threshold 0.30] [--fps 30]
|
|
7
|
+
*
|
|
8
|
+
* Writes work/refs/<clip>/style-brief.md and .json — cut list, shot lengths, pacing over
|
|
9
|
+
* time, and colour stats. Nothing from the clip is ever used in your videos; this is
|
|
10
|
+
* measurement, the same way an editor watches a reference and takes notes.
|
|
11
|
+
*/
|
|
12
|
+
import {existsSync, mkdirSync, writeFileSync} from 'node:fs';
|
|
13
|
+
import {basename, extname, join} from 'node:path';
|
|
14
|
+
import {FFMPEG, fmt, median, probe, run, timecode} from './lib/media.mjs';
|
|
15
|
+
|
|
16
|
+
const args = process.argv.slice(2);
|
|
17
|
+
const positional = [];
|
|
18
|
+
for (let i = 0; i < args.length; i++) {
|
|
19
|
+
if (args[i].startsWith('--')) { i++; continue; }
|
|
20
|
+
positional.push(args[i]);
|
|
21
|
+
}
|
|
22
|
+
const flag = (name, fallback) => {
|
|
23
|
+
const i = args.indexOf(`--${name}`);
|
|
24
|
+
return i >= 0 && args[i + 1] ? args[i + 1] : fallback;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const file = positional[0];
|
|
28
|
+
if (!file || !existsSync(file)) {
|
|
29
|
+
console.error('Usage: npm run refstyle -- <reference-clip> [--threshold 0.30] [--fps 30]');
|
|
30
|
+
process.exit(2);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const threshold = Number(flag('threshold', 0.3));
|
|
34
|
+
const fps = Number(flag('fps', 30));
|
|
35
|
+
const info = probe(file);
|
|
36
|
+
if (!info) {
|
|
37
|
+
console.error('Could not read that file.');
|
|
38
|
+
process.exit(1);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/* ---------------------------------------------------------------- cuts */
|
|
42
|
+
|
|
43
|
+
const scene = run(FFMPEG, [
|
|
44
|
+
'-hide_banner', '-i', file,
|
|
45
|
+
'-filter_complex', `select='gt(scene,${threshold})',metadata=print:file=-`,
|
|
46
|
+
'-an', '-f', 'null', '-',
|
|
47
|
+
]);
|
|
48
|
+
|
|
49
|
+
const cuts = [];
|
|
50
|
+
for (const line of (scene.out + '\n' + scene.err).split('\n')) {
|
|
51
|
+
const m = /pts_time:([\d.]+)/.exec(line);
|
|
52
|
+
if (m) {
|
|
53
|
+
const t = Number(m[1]);
|
|
54
|
+
if (!cuts.length || t - cuts[cuts.length - 1] > 0.12) cuts.push(Number(t.toFixed(3)));
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const bounds = [0, ...cuts, info.duration];
|
|
59
|
+
const shots = [];
|
|
60
|
+
for (let i = 0; i < bounds.length - 1; i++) {
|
|
61
|
+
const len = bounds[i + 1] - bounds[i];
|
|
62
|
+
if (len > 0.05) shots.push({start: bounds[i], end: bounds[i + 1], length: Number(len.toFixed(3))});
|
|
63
|
+
}
|
|
64
|
+
const lengths = shots.map((s) => s.length);
|
|
65
|
+
const cutsPerMinute = info.duration ? (cuts.length / info.duration) * 60 : 0;
|
|
66
|
+
|
|
67
|
+
/* ---------------------------------------------------------------- colour, one sample per second */
|
|
68
|
+
|
|
69
|
+
const raw = run(FFMPEG, [
|
|
70
|
+
'-v', 'error', '-i', file,
|
|
71
|
+
'-vf', 'fps=1,scale=32:18',
|
|
72
|
+
'-pix_fmt', 'rgb24', '-f', 'rawvideo', '-',
|
|
73
|
+
], {encoding: 'buffer'});
|
|
74
|
+
|
|
75
|
+
const pixels = raw.ok ? raw.out : Buffer.alloc(0);
|
|
76
|
+
const perFrame = 32 * 18 * 3;
|
|
77
|
+
const samples = [];
|
|
78
|
+
for (let f = 0; f + perFrame <= pixels.length; f += perFrame) {
|
|
79
|
+
let r = 0, g = 0, b = 0, lum = 0, sat = 0;
|
|
80
|
+
const count = 32 * 18;
|
|
81
|
+
const lums = [];
|
|
82
|
+
for (let p = 0; p < count; p++) {
|
|
83
|
+
const i = f + p * 3;
|
|
84
|
+
const pr = pixels[i], pg = pixels[i + 1], pb = pixels[i + 2];
|
|
85
|
+
r += pr; g += pg; b += pb;
|
|
86
|
+
const mx = Math.max(pr, pg, pb);
|
|
87
|
+
const mn = Math.min(pr, pg, pb);
|
|
88
|
+
sat += mx ? (mx - mn) / mx : 0;
|
|
89
|
+
const l = 0.2126 * pr + 0.7152 * pg + 0.0722 * pb;
|
|
90
|
+
lum += l;
|
|
91
|
+
lums.push(l);
|
|
92
|
+
}
|
|
93
|
+
lums.sort((x, y) => x - y);
|
|
94
|
+
samples.push({
|
|
95
|
+
second: samples.length,
|
|
96
|
+
r: r / count, g: g / count, b: b / count,
|
|
97
|
+
brightness: lum / count,
|
|
98
|
+
saturation: sat / count,
|
|
99
|
+
contrast: (lums[Math.floor(lums.length * 0.95)] - lums[Math.floor(lums.length * 0.05)]) / 255,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const avg = (key) => (samples.length ? samples.reduce((s, x) => s + x[key], 0) / samples.length : 0);
|
|
104
|
+
const warmth = avg('r') - avg('b'); // positive = warm, negative = cool
|
|
105
|
+
const colour = {
|
|
106
|
+
r: Math.round(avg('r')), g: Math.round(avg('g')), b: Math.round(avg('b')),
|
|
107
|
+
brightness: Math.round(avg('brightness')),
|
|
108
|
+
saturation: Number(avg('saturation').toFixed(3)),
|
|
109
|
+
contrast: Number(avg('contrast').toFixed(3)),
|
|
110
|
+
warmth: Number(warmth.toFixed(1)),
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
/* ---------------------------------------------------------------- pacing over time */
|
|
114
|
+
|
|
115
|
+
const buckets = [];
|
|
116
|
+
const bucketSize = Math.max(5, Math.round(info.duration / 8));
|
|
117
|
+
for (let t = 0; t < info.duration; t += bucketSize) {
|
|
118
|
+
const inBucket = shots.filter((s) => s.start >= t && s.start < t + bucketSize);
|
|
119
|
+
buckets.push({
|
|
120
|
+
from: t,
|
|
121
|
+
to: Math.min(t + bucketSize, info.duration),
|
|
122
|
+
shots: inBucket.length,
|
|
123
|
+
avgShot: inBucket.length ? Number((inBucket.reduce((a, s) => a + s.length, 0) / inBucket.length).toFixed(2)) : null,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/* ---------------------------------------------------------------- write */
|
|
128
|
+
|
|
129
|
+
const name = basename(file, extname(file));
|
|
130
|
+
const dir = join('work', 'refs', name);
|
|
131
|
+
mkdirSync(dir, {recursive: true});
|
|
132
|
+
|
|
133
|
+
const med = median(lengths);
|
|
134
|
+
const pace = med < 0.8 ? 'very fast' : med < 1.6 ? 'fast' : med < 3 ? 'medium' : 'slow and held';
|
|
135
|
+
const look =
|
|
136
|
+
`${colour.warmth > 8 ? 'warm' : colour.warmth < -8 ? 'cool' : 'neutral'}, ` +
|
|
137
|
+
`${colour.saturation > 0.45 ? 'saturated' : colour.saturation < 0.25 ? 'desaturated' : 'moderately saturated'}, ` +
|
|
138
|
+
`${colour.brightness > 150 ? 'bright' : colour.brightness < 80 ? 'dark' : 'mid-key'}, ` +
|
|
139
|
+
`${colour.contrast > 0.75 ? 'high contrast' : colour.contrast < 0.45 ? 'soft contrast' : 'normal contrast'}`;
|
|
140
|
+
|
|
141
|
+
const brief = `# Style brief — ${name}
|
|
142
|
+
|
|
143
|
+
Measured from the reference, not copied from it. Use it to match energy and look.
|
|
144
|
+
|
|
145
|
+
## Pacing
|
|
146
|
+
- **Length:** ${fmt(info.duration, 1)}s
|
|
147
|
+
- **Cuts:** ${cuts.length} (${fmt(cutsPerMinute, 1)} per minute)
|
|
148
|
+
- **Shot length:** median ${fmt(med, 2)}s · shortest ${fmt(Math.min(...lengths, 0), 2)}s · longest ${fmt(Math.max(...lengths, 0), 2)}s
|
|
149
|
+
- **Feel:** ${pace}
|
|
150
|
+
|
|
151
|
+
| Section | Shots | Average shot |
|
|
152
|
+
|---|---|---|
|
|
153
|
+
${buckets.map((b) => `| ${timecode(b.from)}–${timecode(b.to)} | ${b.shots} | ${b.avgShot ? `${b.avgShot}s` : '—'} |`).join('\n')}
|
|
154
|
+
|
|
155
|
+
## Colour
|
|
156
|
+
- **Look:** ${look}
|
|
157
|
+
- Average RGB ${colour.r}, ${colour.g}, ${colour.b} · brightness ${colour.brightness}/255
|
|
158
|
+
- Saturation ${colour.saturation} · contrast ${colour.contrast} · warmth ${colour.warmth > 0 ? '+' : ''}${colour.warmth}
|
|
159
|
+
|
|
160
|
+
## How to match it
|
|
161
|
+
- Cut roughly every **${fmt(med, 2)}s** (${Math.round(med * fps)} frames at ${fps}fps).
|
|
162
|
+
- ${med < 1 ? 'Keep almost every shot under a second; hold only the payoff.' : med < 2 ? 'Most shots under two seconds, with one or two longer holds to breathe.' : 'Let shots breathe; use motion inside the frame instead of cutting.'}
|
|
163
|
+
- ${colour.warmth > 8 ? 'Warm the grade slightly; protect skin from going orange.' : colour.warmth < -8 ? 'Cool the grade slightly; keep skin neutral.' : 'Keep the grade neutral.'}
|
|
164
|
+
- ${colour.saturation > 0.45 ? 'Colour is doing work here — one strong hue can dominate a shot.' : 'Colour is restrained; contrast and framing carry it.'}
|
|
165
|
+
|
|
166
|
+
## Cut list
|
|
167
|
+
${shots.map((s, i) => `${String(i + 1).padStart(2, '0')}. ${timecode(s.start)} → ${timecode(s.end)} (${fmt(s.length, 2)}s)`).join('\n')}
|
|
168
|
+
`;
|
|
169
|
+
|
|
170
|
+
writeFileSync(join(dir, 'style-brief.md'), brief);
|
|
171
|
+
writeFileSync(
|
|
172
|
+
join(dir, 'style-brief.json'),
|
|
173
|
+
JSON.stringify({source: file, duration: info.duration, fps: info.fps, cuts, shots, cutsPerMinute, medianShot: med, colour, buckets}, null, 2),
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
console.log(`${name}: ${cuts.length} cuts, median shot ${fmt(med, 2)}s (${pace}), ${look}`);
|
|
177
|
+
console.log(`Written ${join(dir, 'style-brief.md')}`);
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Sound design pass: turn a cut list into placed, timed, ducked SFX cues.
|
|
4
|
+
*
|
|
5
|
+
* npm run sound -- --cuts <assembly.json|*.trim.json|cuts.json> [--beats <beats.json>]
|
|
6
|
+
* [--words <words.json>] [--project <name>] [--fps 30] [--sfx <dir>]
|
|
7
|
+
*
|
|
8
|
+
* Writes work/<project>/sound/:
|
|
9
|
+
* SFX-CUES.txt the numbered, timecoded cue sheet the user rebalances from
|
|
10
|
+
* sound-cues.json the same thing for the composition
|
|
11
|
+
* mix.txt a ready ffmpeg filter_complex: SFX bus, ducked off the voice,
|
|
12
|
+
* high-passed, then the final loudness stage
|
|
13
|
+
*
|
|
14
|
+
* The rules come straight from the audio standard in CLAUDE.md, and they are applied,
|
|
15
|
+
* not suggested: nothing over 0.8s sits under a word unless it is 24 dB down, risers go
|
|
16
|
+
* in pauses, whooshes lead the picture by TRANSITION.sfxLeadMs, everything is
|
|
17
|
+
* high-passed off the voice. Claude cannot hear — this engineers the mix so the user
|
|
18
|
+
* only has to judge taste, not fix collisions.
|
|
19
|
+
*/
|
|
20
|
+
import {existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync} from 'node:fs';
|
|
21
|
+
import {basename, join} from 'node:path';
|
|
22
|
+
import {fmt, timecode} from './lib/media.mjs';
|
|
23
|
+
|
|
24
|
+
const args = process.argv.slice(2);
|
|
25
|
+
const flag = (name, fallback) => {
|
|
26
|
+
const i = args.indexOf(`--${name}`);
|
|
27
|
+
return i >= 0 && args[i + 1] && !args[i + 1].startsWith('--') ? args[i + 1] : fallback;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const cutsPath = flag('cuts', null);
|
|
31
|
+
const beatsPath = flag('beats', null);
|
|
32
|
+
const wordsPath = flag('words', null);
|
|
33
|
+
const project = flag('project', null);
|
|
34
|
+
const fps = Number(flag('fps', 30));
|
|
35
|
+
const sfxDir = flag('sfx', null);
|
|
36
|
+
|
|
37
|
+
if (!cutsPath || !existsSync(cutsPath)) {
|
|
38
|
+
console.error('Usage: npm run sound -- --cuts <assembly.json|*.trim.json|cuts.json> [--beats <beats.json>] [--words <words.json>] [--sfx <dir>]');
|
|
39
|
+
process.exit(2);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** From src/design/motion.ts — kept in step by hand; these two are the whole contract. */
|
|
43
|
+
const SFX_LEAD = 0.133;
|
|
44
|
+
const SFX_TAIL = 0.267;
|
|
45
|
+
|
|
46
|
+
/* ---------------------------------------------------------------- read the cut list */
|
|
47
|
+
|
|
48
|
+
const raw = JSON.parse(readFileSync(cutsPath, 'utf8'));
|
|
49
|
+
let cuts = [];
|
|
50
|
+
let phases = [];
|
|
51
|
+
let source = cutsPath;
|
|
52
|
+
|
|
53
|
+
if (Array.isArray(raw) && raw.every((n) => typeof n === 'number')) {
|
|
54
|
+
// scripts/find_cuts.py output: bare frame numbers.
|
|
55
|
+
cuts = raw.map((f) => ({at: f / fps, kind: 'jump', phase: 'talk'}));
|
|
56
|
+
} else if (raw.shots) {
|
|
57
|
+
// assembly.json
|
|
58
|
+
phases = raw.phases || [];
|
|
59
|
+
cuts = raw.shots
|
|
60
|
+
.filter((s) => s.transitionIn && s.transitionIn !== 'none')
|
|
61
|
+
.map((s) => ({at: s.startSeconds, kind: s.transitionIn, phase: s.phase, beats: s.beats}));
|
|
62
|
+
source = raw.music || cutsPath;
|
|
63
|
+
} else if (raw.punchIns) {
|
|
64
|
+
// trim.json
|
|
65
|
+
cuts = raw.punchIns.map((p) => ({at: p.atSeconds, kind: 'punchIn', phase: 'talk'}));
|
|
66
|
+
source = raw.source || cutsPath;
|
|
67
|
+
} else {
|
|
68
|
+
console.error('Unrecognised cut list. Expected assembly.json, a .trim.json, or cuts.json.');
|
|
69
|
+
process.exit(1);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (!cuts.length) {
|
|
73
|
+
console.error('That cut list has no cuts in it.');
|
|
74
|
+
process.exit(1);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/* ---------------------------------------------------------------- speech windows */
|
|
78
|
+
|
|
79
|
+
let speech = [];
|
|
80
|
+
if (wordsPath && existsSync(wordsPath)) {
|
|
81
|
+
try {
|
|
82
|
+
const w = JSON.parse(readFileSync(wordsPath, 'utf8'));
|
|
83
|
+
speech = (Array.isArray(w) ? w : w.words || [])
|
|
84
|
+
.filter((x) => Number.isFinite(x.start) && Number.isFinite(x.end))
|
|
85
|
+
.map((x) => ({start: Number(x.start), end: Number(x.end)}));
|
|
86
|
+
} catch {
|
|
87
|
+
console.error(`Could not read ${wordsPath} — placing cues without voice awareness.`);
|
|
88
|
+
}
|
|
89
|
+
} else if (raw.keep) {
|
|
90
|
+
// A trim list already tells us exactly where the voice is.
|
|
91
|
+
speech = raw.keep.map((k) => ({start: k.atSeconds, end: k.atSeconds + k.seconds}));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const underVoice = (from, to) => speech.some((s) => from < s.end && to > s.start);
|
|
95
|
+
/** Nearest gap in the voice, within reach, so a riser can move instead of being dropped. */
|
|
96
|
+
const nearestGap = (t, need) => {
|
|
97
|
+
for (let shift = 0; shift <= 0.25; shift += 0.02) {
|
|
98
|
+
for (const cand of [t - shift, t + shift]) {
|
|
99
|
+
if (cand >= 0 && !underVoice(cand, cand + need)) return Number(cand.toFixed(3));
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return null;
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
/* ---------------------------------------------------------------- beats, drop, bars */
|
|
106
|
+
|
|
107
|
+
let beats = null;
|
|
108
|
+
let dropAt = null;
|
|
109
|
+
let barSeconds = null;
|
|
110
|
+
if (beatsPath && existsSync(beatsPath)) {
|
|
111
|
+
beats = JSON.parse(readFileSync(beatsPath, 'utf8'));
|
|
112
|
+
const db = beats.downbeatSeconds || [];
|
|
113
|
+
if (db.length > 1) barSeconds = db[1] - db[0];
|
|
114
|
+
}
|
|
115
|
+
const dropPhase = phases.find((p) => p.name === 'drop');
|
|
116
|
+
if (dropPhase) dropAt = dropPhase.fromSeconds;
|
|
117
|
+
|
|
118
|
+
/* ---------------------------------------------------------------- the cue rules */
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Each cue type carries its own shape. Durations are the top of the range in the audio
|
|
122
|
+
* standard; gains are relative to the music bus, before ducking.
|
|
123
|
+
*/
|
|
124
|
+
const TYPES = {
|
|
125
|
+
whoosh: {durationMs: 600, gainDb: -8, highpassHz: 140, fadeOutMs: 90, note: 'movement — leads the picture'},
|
|
126
|
+
impact: {durationMs: 450, gainDb: -6, highpassHz: 120, fadeOutMs: 70, note: 'lands on the cut'},
|
|
127
|
+
riser: {durationMs: 2000, gainDb: -10, highpassHz: 180, fadeOutMs: 120, note: 'tension into a change — pauses only'},
|
|
128
|
+
sub: {durationMs: 800, gainDb: -7, highpassHz: 30, fadeOutMs: 120, note: 'weight under the drop'},
|
|
129
|
+
tick: {durationMs: 120, gainDb: -18, highpassHz: 400, fadeOutMs: 40, note: 'text arriving'},
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const cues = [];
|
|
133
|
+
const moved = [];
|
|
134
|
+
const dropped = [];
|
|
135
|
+
let dropCuts = 0;
|
|
136
|
+
|
|
137
|
+
const add = (type, at, reason, extra = {}) => {
|
|
138
|
+
const spec = TYPES[type];
|
|
139
|
+
const need = spec.durationMs / 1000;
|
|
140
|
+
let start = Number(at.toFixed(3));
|
|
141
|
+
let gainDb = spec.gainDb;
|
|
142
|
+
let durationMs = spec.durationMs;
|
|
143
|
+
let placement = 'clear';
|
|
144
|
+
|
|
145
|
+
if (speech.length && underVoice(start, start + need)) {
|
|
146
|
+
if (type === 'riser' || type === 'sub') {
|
|
147
|
+
// Long, tonal, and unmissable under a word — the exact mistake that broke an early video.
|
|
148
|
+
const gap = nearestGap(start, need);
|
|
149
|
+
if (gap === null) {
|
|
150
|
+
dropped.push({type, at: start, reason: 'no gap in the voice long enough — add it by hand or shorten the VO'});
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
moved.push({type, from: start, to: gap});
|
|
154
|
+
start = gap;
|
|
155
|
+
} else {
|
|
156
|
+
// Short cues may stay, but only well under the voice, and trimmed to the motion.
|
|
157
|
+
durationMs = Math.min(durationMs, 800);
|
|
158
|
+
gainDb = Math.min(gainDb, -24);
|
|
159
|
+
placement = 'under voice — trimmed and 24 dB down';
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
cues.push({
|
|
164
|
+
n: cues.length + 1,
|
|
165
|
+
type,
|
|
166
|
+
atSeconds: start,
|
|
167
|
+
atFrame: Math.round(start * fps),
|
|
168
|
+
timecode: timecode(start),
|
|
169
|
+
durationMs,
|
|
170
|
+
gainDb,
|
|
171
|
+
highpassHz: spec.highpassHz,
|
|
172
|
+
fadeOutMs: spec.fadeOutMs,
|
|
173
|
+
placement,
|
|
174
|
+
reason,
|
|
175
|
+
...extra,
|
|
176
|
+
});
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
/** Whooshes lead the picture; impacts land on it. Which one depends on what the cut is. */
|
|
180
|
+
for (const cut of cuts) {
|
|
181
|
+
if (cut.kind === 'whipPan' || cut.kind === 'push' || cut.kind === 'punchIn') {
|
|
182
|
+
add('whoosh', Math.max(0, cut.at - SFX_LEAD), `${cut.kind} at ${timecode(cut.at)}`);
|
|
183
|
+
} else if (cut.kind === 'colourFlip' || cut.kind === 'dip') {
|
|
184
|
+
add('impact', cut.at, `${cut.kind} at ${timecode(cut.at)}`);
|
|
185
|
+
} else if (cut.kind === 'jump') {
|
|
186
|
+
add('whoosh', Math.max(0, cut.at - SFX_LEAD), `jump cut at ${timecode(cut.at)} — the zoom needs a sound`);
|
|
187
|
+
} else if (cut.phase === 'drop') {
|
|
188
|
+
// A sound on every cut through the drop is noise, and silence through it is worse.
|
|
189
|
+
// Accent every third, so the section has a pulse without turning into a drum fill.
|
|
190
|
+
if (dropCuts++ % 3 === 0) add('impact', cut.at, 'accent on the drop');
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** The riser goes two bars before the drop, the sub lands on it. */
|
|
195
|
+
if (dropAt !== null) {
|
|
196
|
+
const lead = barSeconds ? barSeconds * 2 : 2;
|
|
197
|
+
add('riser', Math.max(0, dropAt - lead), 'two bars into the drop');
|
|
198
|
+
add('sub', dropAt, 'the drop itself');
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Text arriving gets a tick, but only where nothing else is already happening. */
|
|
202
|
+
if (speech.length && raw.shots) {
|
|
203
|
+
for (const s of speech.slice(0, 40)) {
|
|
204
|
+
const clash = cues.some((c) => Math.abs(c.atSeconds - s.start) < 0.25);
|
|
205
|
+
if (!clash) add('tick', s.start, 'text lands on this word');
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
cues.sort((a, b) => a.atSeconds - b.atSeconds);
|
|
210
|
+
cues.forEach((c, i) => {
|
|
211
|
+
c.n = i + 1;
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
/* ---------------------------------------------------------------- match real files */
|
|
215
|
+
|
|
216
|
+
let library = {};
|
|
217
|
+
if (sfxDir && existsSync(sfxDir)) {
|
|
218
|
+
// Real SFX libraries are folders of folders ("Whooshs", "Risers", "Impacts"), so walk
|
|
219
|
+
// the whole tree and match on the full relative path — the folder name is usually the
|
|
220
|
+
// only thing that says what a file is.
|
|
221
|
+
const walk = (dir) =>
|
|
222
|
+
readdirSync(dir, {withFileTypes: true}).flatMap((e) => {
|
|
223
|
+
const p = join(dir, e.name);
|
|
224
|
+
if (e.isDirectory()) return walk(p);
|
|
225
|
+
return /\.(wav|mp3|aif|aiff|flac|m4a)$/i.test(e.name) ? [p] : [];
|
|
226
|
+
});
|
|
227
|
+
const files = walk(sfxDir);
|
|
228
|
+
const pick = (re) => files.filter((f) => re.test(f));
|
|
229
|
+
library = {
|
|
230
|
+
whoosh: pick(/whoosh|swoosh|swish|transition/i),
|
|
231
|
+
impact: pick(/impact|hit|boom|thud|punch/i),
|
|
232
|
+
riser: pick(/riser|rise|build|uplift/i),
|
|
233
|
+
sub: pick(/sub|drop|bass|808/i),
|
|
234
|
+
tick: pick(/tick|click|pop|blip|ui/i),
|
|
235
|
+
};
|
|
236
|
+
for (const c of cues) {
|
|
237
|
+
const pool = library[c.type] || [];
|
|
238
|
+
if (pool.length) c.file = pool[(c.n - 1) % pool.length];
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/* ---------------------------------------------------------------- the mix */
|
|
243
|
+
|
|
244
|
+
const placed = cues.filter((c) => c.file);
|
|
245
|
+
const mixLines = [];
|
|
246
|
+
mixLines.push('# SFX bus — one input per cue, trimmed, high-passed, faded, delayed into place.');
|
|
247
|
+
mixLines.push('# Inputs: 0 = voice/picture audio, 1 = music, 2.. = the SFX files below, in cue order.');
|
|
248
|
+
mixLines.push('');
|
|
249
|
+
if (placed.length) {
|
|
250
|
+
const parts = placed.map((c, i) => {
|
|
251
|
+
const idx = i + 2;
|
|
252
|
+
const delayMs = Math.round(c.atSeconds * 1000);
|
|
253
|
+
return `[${idx}:a]atrim=0:${fmt(c.durationMs / 1000, 3)},highpass=f=${c.highpassHz},afade=t=out:st=${fmt((c.durationMs - c.fadeOutMs) / 1000, 3)}:d=${fmt(c.fadeOutMs / 1000, 3)},volume=${c.gainDb}dB,adelay=${delayMs}|${delayMs}[s${i}]`;
|
|
254
|
+
});
|
|
255
|
+
mixLines.push(`${parts.join(';\n')};`);
|
|
256
|
+
mixLines.push('');
|
|
257
|
+
mixLines.push(`${placed.map((_, i) => `[s${i}]`).join('')}amix=inputs=${placed.length}:normalize=0[sfxraw];`);
|
|
258
|
+
mixLines.push('[sfxraw]alimiter=limit=0.9[sfx];');
|
|
259
|
+
mixLines.push('# Duck the SFX bus off the voice, then sit it under the music.');
|
|
260
|
+
mixLines.push('[sfx][0:a]sidechaincompress=threshold=0.04:ratio=6:attack=8:release=260[sfxducked];');
|
|
261
|
+
mixLines.push('[1:a][sfxducked]amix=inputs=2:normalize=0[bed];');
|
|
262
|
+
mixLines.push('[0:a][bed]amix=inputs=2:normalize=0[mixout]');
|
|
263
|
+
mixLines.push('');
|
|
264
|
+
mixLines.push('# Final stage — one encode, per the audio standard:');
|
|
265
|
+
mixLines.push('# -map "[mixout]" -af "loudnorm=I=-14:TP=-1:LRA=11:print_format=summary" -c:a aac -b:a 256k');
|
|
266
|
+
mixLines.push('# Stems: render voice.wav (untouched), sfx.wav ([sfx] before ducking), music.wav separately.');
|
|
267
|
+
} else {
|
|
268
|
+
mixLines.push('# No --sfx directory given, so no files are wired up yet.');
|
|
269
|
+
mixLines.push('# Point --sfx at a folder with whoosh*/impact*/riser*/sub*/tick* files and run again.');
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/* ---------------------------------------------------------------- write */
|
|
273
|
+
|
|
274
|
+
const dir = join('work', project || basename(cutsPath).replace(/\..*$/, ''), 'sound');
|
|
275
|
+
mkdirSync(dir, {recursive: true});
|
|
276
|
+
|
|
277
|
+
const json = {
|
|
278
|
+
source,
|
|
279
|
+
cutList: cutsPath,
|
|
280
|
+
fps,
|
|
281
|
+
sfxLeadSeconds: SFX_LEAD,
|
|
282
|
+
sfxTailSeconds: SFX_TAIL,
|
|
283
|
+
voiceAware: speech.length > 0,
|
|
284
|
+
cues,
|
|
285
|
+
moved,
|
|
286
|
+
dropped,
|
|
287
|
+
};
|
|
288
|
+
writeFileSync(join(dir, 'sound-cues.json'), JSON.stringify(json, null, 2));
|
|
289
|
+
writeFileSync(join(dir, 'mix.txt'), `${mixLines.join('\n')}\n`);
|
|
290
|
+
|
|
291
|
+
const sheet = `SFX CUES — ${basename(String(source))}
|
|
292
|
+
${'='.repeat(60)}
|
|
293
|
+
Cut list ${cutsPath}
|
|
294
|
+
Voice ${speech.length ? `${speech.length} spoken windows — cues are placed around them` : 'not supplied — cues are NOT voice-aware, check every one under speech'}
|
|
295
|
+
Lead whooshes start ${Math.round(SFX_LEAD * 1000)}ms before the picture changes
|
|
296
|
+
|
|
297
|
+
# TIME FRAME TYPE LEN GAIN HPF WHY
|
|
298
|
+
${cues
|
|
299
|
+
.map(
|
|
300
|
+
(c) =>
|
|
301
|
+
`${String(c.n).padStart(2, '0')} ${c.timecode} ${String(c.atFrame).padStart(5)} ${c.type.padEnd(7)} ${String(c.durationMs).padStart(4)}ms ${String(c.gainDb).padStart(4)}dB ${String(c.highpassHz).padStart(4)}Hz ${c.reason}${c.placement !== 'clear' ? ` [${c.placement}]` : ''}${c.file ? `\n file: ${basename(c.file)}` : ''}`,
|
|
302
|
+
)
|
|
303
|
+
.join('\n')}
|
|
304
|
+
|
|
305
|
+
${moved.length ? `MOVED (would have played under a word)\n${moved.map((m) => `- ${m.type}: ${timecode(m.from)} → ${timecode(m.to)}`).join('\n')}\n` : ''}${dropped.length ? `DROPPED (nowhere clear to put them)\n${dropped.map((d) => `- ${d.type} at ${timecode(d.at)} — ${d.reason}`).join('\n')}\n` : ''}
|
|
306
|
+
HOW THIS WAS BUILT
|
|
307
|
+
- Whooshes lead the picture, impacts land on it, risers only go in pauses.
|
|
308
|
+
- Nothing longer than 0.8s sits under a word unless it is at least 24 dB down.
|
|
309
|
+
- Everything is high-passed off the voice (120-180Hz), except the sub.
|
|
310
|
+
- The SFX bus is ducked off the voice with sidechaincompress — see mix.txt.
|
|
311
|
+
|
|
312
|
+
BEFORE DELIVERY
|
|
313
|
+
- Measure: voice-vs-mix per 0.5s window; fix any window where SFX is within 12 dB of the voice.
|
|
314
|
+
- Final: -14 LUFS integrated, true peak <= -1 dBTP, one AAC encode at 256k.
|
|
315
|
+
- Ship voice.wav + sfx.wav + this sheet alongside the MP4.
|
|
316
|
+
|
|
317
|
+
Claude cannot hear any of this. Listen once and send timecoded notes.
|
|
318
|
+
`;
|
|
319
|
+
writeFileSync(join(dir, 'SFX-CUES.txt'), sheet);
|
|
320
|
+
|
|
321
|
+
const byType = Object.keys(TYPES)
|
|
322
|
+
.map((t) => `${cues.filter((c) => c.type === t).length} ${t}`)
|
|
323
|
+
.filter((s) => !s.startsWith('0 '))
|
|
324
|
+
.join(', ');
|
|
325
|
+
|
|
326
|
+
console.log(`Cues ${cues.length} (${byType})`);
|
|
327
|
+
console.log(`Voice ${speech.length ? `${speech.length} spoken windows — cues placed around them` : 'not supplied — cues are not voice-aware'}`);
|
|
328
|
+
if (moved.length) console.log(`Moved ${moved.length} cue(s) out from under the voice`);
|
|
329
|
+
if (dropped.length) console.log(`Dropped ${dropped.length} cue(s) with nowhere clear to sit`);
|
|
330
|
+
console.log(`Files ${placed.length ? `${placed.length} matched from ${sfxDir}` : 'none matched — pass --sfx <dir> to wire real files'}`);
|
|
331
|
+
console.log(`Written ${join(dir, 'SFX-CUES.txt')}`);
|
|
332
|
+
console.log('\nThe mix is engineered, not guessed — but only you can hear it. Listen once, send timecodes.');
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Render many stills of a composition from one bundle, then tile them into contact sheets for review.
|
|
4
|
+
*
|
|
5
|
+
* node scripts/stills.mjs <CompositionId> [--every=45] [--from=0] [--to=<last>] [--frames=10,200]
|
|
6
|
+
* [--scale=0.4] [--cols=8] [--out=work/stills/<id>]
|
|
7
|
+
*
|
|
8
|
+
* Writes s000.jpg, s001.jpg... , sheet-0.jpg, sheet-1.jpg... (cols x 2 per sheet) and frames.txt.
|
|
9
|
+
*/
|
|
10
|
+
import {bundle} from '@remotion/bundler';
|
|
11
|
+
import {getCompositions, openBrowser, renderStill} from '@remotion/renderer';
|
|
12
|
+
import {spawnSync} from 'node:child_process';
|
|
13
|
+
import {mkdirSync, rmSync, writeFileSync} from 'node:fs';
|
|
14
|
+
import {join, resolve} from 'node:path';
|
|
15
|
+
|
|
16
|
+
const FFMPEG = 'D:\\Tools\\ffmpeg\\bin\\ffmpeg.exe';
|
|
17
|
+
|
|
18
|
+
const args = process.argv.slice(2);
|
|
19
|
+
const id = args.find((a) => !a.startsWith('--'));
|
|
20
|
+
const opt = (name) => args.find((a) => a.startsWith(`--${name}=`))?.split('=')[1];
|
|
21
|
+
if (!id) {
|
|
22
|
+
console.error('Usage: node scripts/stills.mjs <CompositionId> [--every=45] [--frames=a,b,c] [--scale=0.4]');
|
|
23
|
+
process.exit(2);
|
|
24
|
+
}
|
|
25
|
+
const scale = Number(opt('scale') ?? 0.4);
|
|
26
|
+
const cols = Number(opt('cols') ?? 8);
|
|
27
|
+
const out = opt('out') ?? join('work', 'stills', id);
|
|
28
|
+
rmSync(out, {recursive: true, force: true});
|
|
29
|
+
mkdirSync(out, {recursive: true});
|
|
30
|
+
|
|
31
|
+
const serveUrl = await bundle({entryPoint: resolve('src/index.ts'), publicDir: resolve('assets')});
|
|
32
|
+
const comp = (await getCompositions(serveUrl)).find((c) => c.id === id);
|
|
33
|
+
if (!comp) throw new Error(`No composition ${id}`);
|
|
34
|
+
|
|
35
|
+
let frames;
|
|
36
|
+
if (opt('frames')) {
|
|
37
|
+
frames = opt('frames').split(',').map(Number);
|
|
38
|
+
} else {
|
|
39
|
+
frames = [];
|
|
40
|
+
const every = Number(opt('every') ?? 45);
|
|
41
|
+
const to = Number(opt('to') ?? comp.durationInFrames - 1);
|
|
42
|
+
for (let f = Number(opt('from') ?? 0); f <= to; f += every) frames.push(f);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const browser = await openBrowser('chrome');
|
|
46
|
+
for (const [i, frame] of frames.entries()) {
|
|
47
|
+
const name = `s${String(i).padStart(3, '0')}.jpg`;
|
|
48
|
+
await renderStill({composition: comp, serveUrl, frame, output: join(out, name), imageFormat: 'jpeg', scale, puppeteerInstance: browser});
|
|
49
|
+
process.stdout.write(`${frame} `);
|
|
50
|
+
}
|
|
51
|
+
writeFileSync(join(out, 'frames.txt'), frames.map((f, i) => `s${String(i).padStart(3, '0')} = frame ${f}`).join('\n'));
|
|
52
|
+
|
|
53
|
+
const perSheet = cols * 2;
|
|
54
|
+
for (let s = 0; s * perSheet < frames.length; s++) {
|
|
55
|
+
spawnSync(
|
|
56
|
+
FFMPEG,
|
|
57
|
+
['-y', '-loglevel', 'error', '-start_number', String(s * perSheet), '-i', join(out, 's%03d.jpg'), '-frames:v', '1', '-vf', `tile=${cols}x2:padding=6:color=black`, join(out, `sheet-${s}.jpg`)],
|
|
58
|
+
{stdio: 'inherit'},
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
await browser.close({silent: true});
|
|
62
|
+
// The bundle holds a full copy of assets/ (cutouts are GBs) — never leave it behind.
|
|
63
|
+
rmSync(serveUrl, {recursive: true, force: true});
|
|
64
|
+
console.log(`\n${frames.length} stills and ${Math.ceil(frames.length / perSheet)} sheets -> ${out}`);
|