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,134 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Line two recordings up by their sound.
4
+ *
5
+ * npm run sync -- <clip-a> <clip-b> [--fps 30] [--max 30]
6
+ *
7
+ * Use it for: a clip-on mic recorded separately, a second camera angle, or a green-screen
8
+ * twin that started a moment late. Prints the offset in seconds and frames, and the exact
9
+ * ffmpeg line to apply it.
10
+ *
11
+ * How: match the loudness shape of both recordings (coarse), then match the waveform itself
12
+ * around that point (fine). Accurate to a handful of milliseconds on real audio.
13
+ */
14
+ import {existsSync} from 'node:fs';
15
+ import {decodePcm, energyEnvelope, fmt, probe} from './lib/media.mjs';
16
+
17
+ const args = process.argv.slice(2);
18
+ // Positional file arguments only: skip every --flag and the value that follows it.
19
+ const files = [];
20
+ for (let i = 0; i < args.length; i++) {
21
+ if (args[i].startsWith('--')) { i++; continue; }
22
+ files.push(args[i]);
23
+ }
24
+ const flag = (name, fallback) => {
25
+ const i = args.indexOf(`--${name}`);
26
+ return i >= 0 && args[i + 1] ? Number(args[i + 1]) : fallback;
27
+ };
28
+
29
+ if (files.length < 2 || !files.slice(0, 2).every((f) => existsSync(f))) {
30
+ console.error('Usage: npm run sync -- <clip-a> <clip-b> [--fps 30] [--max 30]');
31
+ process.exit(2);
32
+ }
33
+
34
+ const [fileA, fileB] = files;
35
+ const fps = flag('fps', 30);
36
+ const maxShift = flag('max', 30); // seconds to search either way
37
+
38
+ const COARSE_RATE = 8000;
39
+ const COARSE_HOP = 256;
40
+ const FINE_RATE = 22050;
41
+
42
+ const a = decodePcm(fileA, COARSE_RATE);
43
+ const b = decodePcm(fileB, COARSE_RATE);
44
+ if (!a || !b) {
45
+ console.error('One of those files has no usable audio.');
46
+ process.exit(1);
47
+ }
48
+
49
+ const norm = (env) => {
50
+ const m = env.reduce((x, y) => x + y, 0) / env.length;
51
+ const out = new Float64Array(env.length);
52
+ for (let i = 0; i < env.length; i++) out[i] = env[i] - m;
53
+ return out;
54
+ };
55
+
56
+ const ea = norm(energyEnvelope(a, COARSE_RATE, COARSE_HOP).env);
57
+ const eb = norm(energyEnvelope(b, COARSE_RATE, COARSE_HOP).env);
58
+ const hopSeconds = COARSE_HOP / COARSE_RATE;
59
+
60
+ /** Normalised correlation of two series at a given lag (b shifted by `lag` frames). */
61
+ function correlate(x, y, lag) {
62
+ const start = Math.max(0, -lag);
63
+ const end = Math.min(x.length, y.length - lag);
64
+ if (end - start < 20) return -1;
65
+ let num = 0;
66
+ let dx = 0;
67
+ let dy = 0;
68
+ for (let i = start; i < end; i++) {
69
+ const xv = x[i];
70
+ const yv = y[i + lag];
71
+ num += xv * yv;
72
+ dx += xv * xv;
73
+ dy += yv * yv;
74
+ }
75
+ return dx && dy ? num / Math.sqrt(dx * dy) : -1;
76
+ }
77
+
78
+ const maxLag = Math.round(maxShift / hopSeconds);
79
+ let bestLag = 0;
80
+ let bestScore = -1;
81
+ for (let lag = -maxLag; lag <= maxLag; lag++) {
82
+ const score = correlate(ea, eb, lag);
83
+ if (score > bestScore) {
84
+ bestScore = score;
85
+ bestLag = lag;
86
+ }
87
+ }
88
+ const coarseSeconds = bestLag * hopSeconds;
89
+
90
+ /* ---------------------------------------------------------------- fine pass on the waveform */
91
+
92
+ let offsetSeconds = coarseSeconds;
93
+ const fa = decodePcm(fileA, FINE_RATE);
94
+ const fb = decodePcm(fileB, FINE_RATE);
95
+ if (fa && fb) {
96
+ const centre = Math.round(coarseSeconds * FINE_RATE);
97
+ const window = Math.round(hopSeconds * 2 * FINE_RATE);
98
+ const step = Math.max(1, Math.round(FINE_RATE / 4000)); // ~0.25ms resolution
99
+ let fineBest = centre;
100
+ let fineScore = -1;
101
+ for (let lag = centre - window; lag <= centre + window; lag += step) {
102
+ const score = correlate(fa, fb, lag);
103
+ if (score > fineScore) {
104
+ fineScore = score;
105
+ fineBest = lag;
106
+ }
107
+ }
108
+ if (fineScore > 0) offsetSeconds = fineBest / FINE_RATE;
109
+ }
110
+
111
+ const infoA = probe(fileA);
112
+ const infoB = probe(fileB);
113
+ const frames = offsetSeconds * fps;
114
+ const confidence = bestScore > 0.75 ? 'strong' : bestScore > 0.45 ? 'usable' : 'weak — check it by ear';
115
+
116
+ console.log(`A ${fileA} (${fmt(infoA?.duration ?? 0, 2)}s)`);
117
+ console.log(`B ${fileB} (${fmt(infoB?.duration ?? 0, 2)}s)`);
118
+ console.log('');
119
+ if (Math.abs(offsetSeconds) < 0.005) {
120
+ console.log('These are already in sync.');
121
+ } else if (offsetSeconds > 0) {
122
+ // B's content arrives later than A's: skip the head of B.
123
+ console.log(`B starts ${fmt(offsetSeconds, 3)}s LATER than A (${fmt(frames, 1)} frames at ${fps}fps)`);
124
+ console.log(`Trim B: -ss ${fmt(offsetSeconds, 3)} -i "${fileB}"`);
125
+ console.log(`In the edit: start B at frame ${Math.round(frames)} of its own timeline.`);
126
+ } else {
127
+ console.log(`B starts ${fmt(-offsetSeconds, 3)}s EARLIER than A (${fmt(-frames, 1)} frames at ${fps}fps)`);
128
+ console.log(`Trim A: -ss ${fmt(-offsetSeconds, 3)} -i "${fileA}"`);
129
+ console.log(`In the edit: start A at frame ${Math.round(-frames)} of its own timeline.`);
130
+ }
131
+ console.log(`\nMatch ${fmt(bestScore, 3)} (${confidence})`);
132
+ if (infoA && infoB && Math.abs((infoA.fps || 0) - (infoB.fps || 0)) > 0.02) {
133
+ console.log(`Careful: different frame rates (${fmt(infoA.fps, 2)} vs ${fmt(infoB.fps, 2)}). Conform both before using this offset.`);
134
+ }
@@ -0,0 +1,103 @@
1
+ """Word-level transcript for a clip: WhisperX transcription + wav2vec2 forced alignment.
2
+
3
+ Usage (from the project root):
4
+ python scripts/transcribe.py <media> [--language en] [--model small]
5
+
6
+ Writes work/<clip-name>/words.json. Models are cached under the studio's .models folder.
7
+ Words the aligner cannot place (often digits like "186%") keep start/end = null and must be
8
+ fixed against the script before building the spine.
9
+ """
10
+
11
+ import argparse
12
+ import json
13
+ import os
14
+ import time
15
+ from pathlib import Path
16
+
17
+ import sys as _sys # noqa: E402
18
+ _sys.path.insert(0, str(Path(__file__).resolve().parent / "lib"))
19
+ from media import FFMPEG, use_certifi # noqa: E402
20
+
21
+ # Models are large; cache them inside the studio so they survive and are easy to find.
22
+ _MODELS = Path(__file__).resolve().parent.parent / ".models"
23
+ os.environ.setdefault("HF_HOME", str(_MODELS / "huggingface"))
24
+ os.environ.setdefault("TORCH_HOME", str(_MODELS / "torch"))
25
+ os.environ["PATH"] = str(Path(FFMPEG).parent) + os.pathsep + os.environ["PATH"]
26
+
27
+ # torch.hub fetches the word-alignment model with urllib, which on a python.org macOS
28
+ # build has no root certificates — transcription then dies with CERTIFICATE_VERIFY_FAILED
29
+ # after the slow part has already succeeded. Must run before torch is imported.
30
+ use_certifi()
31
+
32
+ try:
33
+ import whisperx # noqa: E402 (must import after the cache/PATH/cert setup above)
34
+ except ModuleNotFoundError as exc: # pragma: no cover
35
+ # Run with the wrong interpreter and the bare error reads as "never installed", which
36
+ # sends you off to fix the wrong thing. Say which python is running and what to do.
37
+ _venv = Path(__file__).resolve().parent.parent / ".venv"
38
+ _venv_py = _venv / ("Scripts/python.exe" if os.name == "nt" else "bin/python3")
39
+ print(f"Cannot import {exc.name}.", file=_sys.stderr)
40
+ print(f" running: {_sys.executable} (python {_sys.version.split()[0]})", file=_sys.stderr)
41
+ if _venv_py.exists():
42
+ print(f" the studio's environment is at {_venv_py}", file=_sys.stderr)
43
+ print(" run the transcriber with that python, or use: npm run prep", file=_sys.stderr)
44
+ else:
45
+ print(" the studio has no .venv yet. Build one with python 3.11 or 3.12:", file=_sys.stderr)
46
+ print(" py -3.12 -m venv .venv", file=_sys.stderr)
47
+ print(" .venv/Scripts/python.exe -m pip install -r requirements.txt", file=_sys.stderr)
48
+ print(" see what you have with: npm run doctor", file=_sys.stderr)
49
+ raise SystemExit(2)
50
+
51
+
52
+ def main() -> None:
53
+ parser = argparse.ArgumentParser()
54
+ parser.add_argument("media")
55
+ parser.add_argument("--language", default=None, help="e.g. en, hi. Detected when omitted.")
56
+ parser.add_argument("--project", default=None, help="Video project name: writes to work/<project>/<clip>/")
57
+ parser.add_argument("--model", default="small", help="Whisper size: small (fast on CPU) or medium (more accurate)")
58
+ args = parser.parse_args()
59
+
60
+ media = Path(args.media)
61
+ out_dir = (Path("work") / args.project / media.stem) if args.project else (Path("work") / media.stem)
62
+ out_dir.mkdir(parents=True, exist_ok=True)
63
+ started = time.time()
64
+
65
+ audio = whisperx.load_audio(str(media))
66
+ model = whisperx.load_model(args.model, device="cpu", compute_type="int8", language=args.language)
67
+ result = model.transcribe(audio, batch_size=4, language=args.language)
68
+ language = result["language"]
69
+
70
+ align_model, metadata = whisperx.load_align_model(language_code=language, device="cpu")
71
+ aligned = whisperx.align(result["segments"], align_model, metadata, audio, device="cpu", return_char_alignments=False)
72
+
73
+ words = []
74
+ for segment in aligned["segments"]:
75
+ for w in segment.get("words", []):
76
+ words.append(
77
+ {
78
+ "word": w["word"],
79
+ "start": round(w["start"], 3) if "start" in w else None,
80
+ "end": round(w["end"], 3) if "end" in w else None,
81
+ "score": round(w["score"], 3) if "score" in w else None,
82
+ }
83
+ )
84
+
85
+ output = {
86
+ "source": str(media),
87
+ "language": language,
88
+ "model": args.model,
89
+ "durationSec": round(len(audio) / 16000, 3),
90
+ "text": " ".join(s["text"].strip() for s in aligned["segments"]),
91
+ "words": words,
92
+ }
93
+ out_file = out_dir / "words.json"
94
+ out_file.write_text(json.dumps(output, indent=2, ensure_ascii=False), encoding="utf-8")
95
+
96
+ unplaced = [w["word"] for w in words if w["start"] is None]
97
+ print(f"language: {language} | words: {len(words)} | unplaced: {len(unplaced)} {unplaced if unplaced else ''}")
98
+ print(f"text: {output['text']}")
99
+ print(f"wrote {out_file} in {time.time() - started:.0f}s")
100
+
101
+
102
+ if __name__ == "__main__":
103
+ main()
@@ -0,0 +1,374 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Silence + filler trim, with automatic punch-ins.
4
+ *
5
+ * npm run trim -- <video> [--project <name>] [--words work/<n>/<stem>/words.json]
6
+ * [--min-silence 0.35] [--pad 0.08] [--floor <dBFS>] [--fps 30]
7
+ * [--write <out.mp4>]
8
+ *
9
+ * Talking-head footage is mostly waiting: breaths, resets, the half second before a
10
+ * sentence starts. This finds those gaps, removes them, and — because every removal
11
+ * leaves a jump cut — assigns each surviving segment a different framing so the cut
12
+ * reads as a deliberate punch-in instead of a splice.
13
+ *
14
+ * Writes work/<project>/<stem>.trim.json + .md:
15
+ * keep[] the segments that survive, each with its framing (Stage scale + shift)
16
+ * removed[] what went, and why (silence / filler)
17
+ * punchIns[] the frame of every join and the framing change across it
18
+ * ffmpeg select/aselect expressions, if you want to cut the file directly
19
+ *
20
+ * The voice is never processed here — segments are copied, not filtered. With --write
21
+ * the file is encoded once (law: at most one encode), and the cut list is what the
22
+ * composition should consume so the final render encodes the voice only that once.
23
+ *
24
+ * Detection is energy-based: an adaptive floor from the recording itself, hysteresis so
25
+ * a comma is not a cut, then padding so words keep their attack and tail. No model.
26
+ */
27
+ import {existsSync, mkdirSync, readFileSync, writeFileSync} from 'node:fs';
28
+ import {basename, extname, join} from 'node:path';
29
+ import {FFMPEG, decodePcm, energyEnvelope, fmt, probe, run, timecode} from './lib/media.mjs';
30
+
31
+ const args = process.argv.slice(2);
32
+ const file = args.find((a) => !a.startsWith('--'));
33
+ const flag = (name, fallback) => {
34
+ const i = args.indexOf(`--${name}`);
35
+ return i >= 0 && args[i + 1] && !args[i + 1].startsWith('--') ? args[i + 1] : fallback;
36
+ };
37
+ const has = (name) => args.includes(`--${name}`);
38
+
39
+ if (!file || !existsSync(file)) {
40
+ console.error('Usage: npm run trim -- <video> [--project <name>] [--words <words.json>] [--min-silence 0.35] [--pad 0.08] [--write out.mp4]');
41
+ process.exit(2);
42
+ }
43
+
44
+ const RATE = 22050;
45
+ const project = flag('project', null);
46
+ const fps = Number(flag('fps', 30));
47
+ const minSilence = Number(flag('min-silence', 0.35));
48
+ const pad = Number(flag('pad', 0.08));
49
+ const floorOverride = has('floor') ? Number(flag('floor', -38)) : null;
50
+ const wordsPath = flag('words', null);
51
+ const writePath = flag('write', null);
52
+
53
+ const info = probe(file);
54
+ const samples = decodePcm(file, RATE);
55
+ if (!samples || samples.length < RATE) {
56
+ console.error('No usable audio in that file.');
57
+ process.exit(1);
58
+ }
59
+ const duration = info?.duration || samples.length / RATE;
60
+
61
+ /* ---------------------------------------------------------------- level over time */
62
+
63
+ const {env, hopSeconds} = energyEnvelope(samples, RATE, 256);
64
+ const db = new Float64Array(env.length);
65
+ for (let i = 0; i < env.length; i++) db[i] = 20 * Math.log10(env[i] + 1e-9);
66
+
67
+ const percentile = (arr, p) => {
68
+ const s = Array.from(arr).sort((a, b) => a - b);
69
+ return s[Math.min(s.length - 1, Math.max(0, Math.round((p / 100) * (s.length - 1))))];
70
+ };
71
+
72
+ const noiseFloorDb = percentile(db, 10);
73
+ const speechPeakDb = percentile(db, 95);
74
+ /**
75
+ * Sit the gate between the room and the voice: well above the floor, well under the
76
+ * peaks. Whichever of those two is stricter wins, so a noisy room does not open the
77
+ * gate and a quiet, close-mic'd take does not clip its own word endings.
78
+ */
79
+ const thresholdDb =
80
+ floorOverride ?? Math.min(Math.max(noiseFloorDb + 9, speechPeakDb - 28), speechPeakDb - 14);
81
+
82
+ /* ---------------------------------------------------------------- speech regions */
83
+
84
+ const loud = new Uint8Array(db.length);
85
+ for (let i = 0; i < db.length; i++) loud[i] = db[i] > thresholdDb ? 1 : 0;
86
+
87
+ const toRegions = (mask, want) => {
88
+ const out = [];
89
+ let start = -1;
90
+ for (let i = 0; i < mask.length; i++) {
91
+ if (mask[i] === want && start < 0) start = i;
92
+ if ((mask[i] !== want || i === mask.length - 1) && start >= 0) {
93
+ out.push({from: start, to: i});
94
+ start = -1;
95
+ }
96
+ }
97
+ return out;
98
+ };
99
+
100
+ // Close short gaps first: a gap between two words is not a cut, only a gap between thoughts is.
101
+ const gapFrames = Math.round(minSilence / hopSeconds);
102
+ for (const gap of toRegions(loud, 0)) {
103
+ if (gap.to - gap.from < gapFrames && gap.from > 0 && gap.to < loud.length - 1) {
104
+ for (let i = gap.from; i <= gap.to; i++) loud[i] = 1;
105
+ }
106
+ }
107
+ // Then drop specks of "speech" too short to be a word — a chair creak, a mouth click.
108
+ const minSpeechFrames = Math.round(0.12 / hopSeconds);
109
+ for (const seg of toRegions(loud, 1)) {
110
+ if (seg.to - seg.from < minSpeechFrames) for (let i = seg.from; i <= seg.to; i++) loud[i] = 0;
111
+ }
112
+
113
+ let keep = toRegions(loud, 1).map((r) => ({
114
+ start: Math.max(0, r.from * hopSeconds - pad),
115
+ end: Math.min(duration, r.to * hopSeconds + pad),
116
+ }));
117
+
118
+ /* ---------------------------------------------------------------- filler words */
119
+
120
+ const FILLERS = new Set(['um', 'umm', 'uhm', 'uh', 'uhh', 'er', 'erm', 'ah', 'ahh', 'hmm', 'mmm', 'eh', 'mm']);
121
+ const removedFillers = [];
122
+
123
+ if (wordsPath && existsSync(wordsPath)) {
124
+ let words = [];
125
+ try {
126
+ const raw = JSON.parse(readFileSync(wordsPath, 'utf8'));
127
+ words = (Array.isArray(raw) ? raw : raw.words || []).map((w) => ({
128
+ text: String(w.text ?? w.word ?? ''),
129
+ start: Number(w.start),
130
+ end: Number(w.end),
131
+ }));
132
+ } catch {
133
+ console.error(`Could not read ${wordsPath} — skipping filler removal.`);
134
+ }
135
+ const hits = words.filter(
136
+ (w) => Number.isFinite(w.start) && Number.isFinite(w.end) && FILLERS.has(w.text.toLowerCase().replace(/[^a-z]/g, '')),
137
+ );
138
+ for (const w of hits) {
139
+ const from = w.start - 0.02;
140
+ const to = w.end + 0.02;
141
+ const next = [];
142
+ for (const seg of keep) {
143
+ if (to <= seg.start || from >= seg.end) {
144
+ next.push(seg);
145
+ continue;
146
+ }
147
+ if (from > seg.start) next.push({start: seg.start, end: from});
148
+ if (to < seg.end) next.push({start: to, end: seg.end});
149
+ }
150
+ keep = next;
151
+ removedFillers.push({start: Number(from.toFixed(3)), end: Number(to.toFixed(3)), word: w.text});
152
+ }
153
+ }
154
+
155
+ // Padding and filler splits can leave overlaps or slivers. Merge, then drop anything too short to read.
156
+ keep.sort((a, b) => a.start - b.start);
157
+ const merged = [];
158
+ for (const seg of keep) {
159
+ const last = merged[merged.length - 1];
160
+ if (last && seg.start <= last.end + 0.001) last.end = Math.max(last.end, seg.end);
161
+ else merged.push({...seg});
162
+ }
163
+ keep = merged.filter((s) => s.end - s.start >= 0.15);
164
+
165
+ if (!keep.length) {
166
+ console.error('Everything was gated out. The recording is quieter than expected — pass --floor <dBFS> to set the gate by hand.');
167
+ process.exit(1);
168
+ }
169
+
170
+ /* ---------------------------------------------------------------- punch-ins */
171
+
172
+ /**
173
+ * Every removal leaves a jump cut, so every cut gets a new framing. The pattern never
174
+ * repeats a framing across a join and never sits on one size for long. Scale stays at
175
+ * or above 1 — a punch-in pushes in, it never pulls back past the frame edge.
176
+ */
177
+ const FRAMING = {
178
+ wide: {scale: 1.0, shiftYPct: 0},
179
+ mid: {scale: 1.09, shiftYPct: -1.5},
180
+ close: {scale: 1.2, shiftYPct: -3.2},
181
+ };
182
+ const PATTERN = ['wide', 'close', 'mid', 'close', 'wide', 'mid'];
183
+
184
+ const segments = keep.map((seg, i) => {
185
+ const name = PATTERN[i % PATTERN.length];
186
+ return {
187
+ index: i,
188
+ start: Number(seg.start.toFixed(3)),
189
+ end: Number(seg.end.toFixed(3)),
190
+ seconds: Number((seg.end - seg.start).toFixed(3)),
191
+ framing: name,
192
+ ...FRAMING[name],
193
+ };
194
+ });
195
+
196
+ // Timeline positions after the gaps close.
197
+ let cursor = 0;
198
+ for (const s of segments) {
199
+ s.atSeconds = Number(cursor.toFixed(3));
200
+ s.atFrame = Math.round(cursor * fps);
201
+ s.durationFrames = Math.round(s.seconds * fps);
202
+ cursor += s.seconds;
203
+ }
204
+ const newDuration = cursor;
205
+
206
+ const punchIns = segments.slice(1).map((s, i) => ({
207
+ atSeconds: s.atSeconds,
208
+ atFrame: s.atFrame,
209
+ from: segments[i].framing,
210
+ to: s.framing,
211
+ scale: s.scale,
212
+ shiftYPct: s.shiftYPct,
213
+ }));
214
+
215
+ /* ---------------------------------------------------------------- what went */
216
+
217
+ const removed = [];
218
+ let prevEnd = 0;
219
+ for (const s of keep) {
220
+ if (s.start - prevEnd > 0.001) {
221
+ removed.push({start: Number(prevEnd.toFixed(3)), end: Number(s.start.toFixed(3)), seconds: Number((s.start - prevEnd).toFixed(3)), kind: 'silence'});
222
+ }
223
+ prevEnd = s.end;
224
+ }
225
+ if (duration - prevEnd > 0.001) {
226
+ removed.push({start: Number(prevEnd.toFixed(3)), end: Number(duration.toFixed(3)), seconds: Number((duration - prevEnd).toFixed(3)), kind: 'tail'});
227
+ }
228
+ for (const f of removedFillers) {
229
+ const hit = removed.find((r) => Math.abs(r.start - f.start) < 0.05);
230
+ if (hit) hit.kind = `filler "${f.word.trim()}"`;
231
+ }
232
+
233
+ const saved = duration - newDuration;
234
+
235
+ /* ---------------------------------------------------------------- what other gates would find */
236
+
237
+ /**
238
+ * The gate is the one judgement call in here, and it is invisible unless we show it. A
239
+ * take that comes back barely trimmed is usually just tightly delivered — but it can also
240
+ * mean the gate sat under the breaths. This says which, without anyone having to guess.
241
+ *
242
+ * Raising it finds more pauses and eventually starts eating quiet word endings, so the
243
+ * default stays conservative: damaging the voice is a worse failure than leaving a pause.
244
+ */
245
+ const gateTable = [-50, -45, -40, -37, -34, -31, -28]
246
+ .concat(Math.round(thresholdDb * 10) / 10)
247
+ .sort((a, b) => a - b)
248
+ .filter((v, i, arr) => arr.indexOf(v) === i)
249
+ .map((th) => {
250
+ let gaps = 0;
251
+ let total = 0;
252
+ let run = 0;
253
+ for (let i = 0; i < db.length; i++) {
254
+ if (db[i] <= th) run++;
255
+ else {
256
+ if (run * hopSeconds >= minSilence) {
257
+ gaps++;
258
+ total += run * hopSeconds;
259
+ }
260
+ run = 0;
261
+ }
262
+ }
263
+ if (run * hopSeconds >= minSilence) {
264
+ gaps++;
265
+ total += run * hopSeconds;
266
+ }
267
+ return {th, gaps, total};
268
+ });
269
+
270
+ /* ---------------------------------------------------------------- ffmpeg */
271
+
272
+ const between = keep.map((s) => `between(t,${fmt(s.start, 3)},${fmt(s.end, 3)})`).join('+');
273
+ const vf = `select='${between}',setpts=N/FRAME_RATE/TB`;
274
+ const af = `aselect='${between}',asetpts=N/SR/TB`;
275
+
276
+ /* ---------------------------------------------------------------- write */
277
+
278
+ const stem = basename(file, extname(file));
279
+ const dir = project ? join('work', project) : join('work', 'trim');
280
+ mkdirSync(dir, {recursive: true});
281
+
282
+ const out = {
283
+ source: file,
284
+ fps,
285
+ durationSeconds: Number(duration.toFixed(3)),
286
+ newDurationSeconds: Number(newDuration.toFixed(3)),
287
+ savedSeconds: Number(saved.toFixed(3)),
288
+ gate: {
289
+ thresholdDb: Number(thresholdDb.toFixed(1)),
290
+ noiseFloorDb: Number(noiseFloorDb.toFixed(1)),
291
+ speechPeakDb: Number(speechPeakDb.toFixed(1)),
292
+ minSilence,
293
+ pad,
294
+ },
295
+ keep: segments,
296
+ removed,
297
+ punchIns,
298
+ ffmpeg: {vf, af},
299
+ };
300
+
301
+ const jsonPath = join(dir, `${stem}.trim.json`);
302
+ writeFileSync(jsonPath, JSON.stringify(out, null, 2));
303
+
304
+ const md = `# Trim — ${stem}
305
+
306
+ - **Was** ${fmt(duration, 2)}s → **now** ${fmt(newDuration, 2)}s (**${fmt(saved, 2)}s removed**, ${fmt((saved / duration) * 100, 0)}%)
307
+ - **Segments kept:** ${segments.length} · **cuts created:** ${punchIns.length}
308
+ - **Gate:** ${fmt(thresholdDb, 1)} dBFS (room floor ${fmt(noiseFloorDb, 1)}, voice peaks ${fmt(speechPeakDb, 1)})
309
+ - **Filler words removed:** ${removedFillers.length || '—'}${removedFillers.length ? ` (${removedFillers.map((f) => f.word.trim()).join(', ')})` : ''}
310
+
311
+ ## Cut list
312
+
313
+ | # | source in → out | length | on timeline | framing |
314
+ |---|---|---|---|---|
315
+ ${segments.map((s) => `| ${String(s.index + 1).padStart(2, '0')} | ${timecode(s.start)} → ${timecode(s.end)} | ${fmt(s.seconds, 2)}s | ${timecode(s.atSeconds)} (f${s.atFrame}) | ${s.framing} ×${fmt(s.scale, 2)} |`).join('\n')}
316
+
317
+ ## Punch-ins
318
+
319
+ Every join below is a jump cut. The framing changes across it, so it reads as a push-in.
320
+ Zoom through each one (Stage \`cutZoom\`) — never leave a bare splice.
321
+
322
+ ${punchIns.length ? punchIns.map((p) => `- frame ${p.atFrame} (${timecode(p.atSeconds)}): ${p.from} → **${p.to}** (×${fmt(p.scale, 2)}, shift ${fmt(p.shiftYPct, 1)}%)`).join('\n') : '- none — the take had no gaps worth cutting.'}
323
+
324
+ ## Removed
325
+
326
+ ${removed.map((r) => `- ${timecode(r.start)} → ${timecode(r.end)} (${fmt(r.seconds, 2)}s) — ${r.kind}`).join('\n')}
327
+
328
+ ## The gate
329
+
330
+ Used **${fmt(thresholdDb, 1)} dBFS**${floorOverride === null ? ' (chosen from this recording)' : ' (set by hand with --floor)'}.
331
+ If the trim looks too light, the take is probably just tightly delivered — this says so either way.
332
+ Raising the gate finds more pauses and eventually starts eating quiet word endings, so it is
333
+ deliberately conservative. Override with \`--floor <dBFS>\`.
334
+
335
+ | gate | pauses ≥ ${minSilence}s | total |
336
+ |---|---|---|
337
+ ${gateTable.map((g) => `| ${fmt(g.th, 1)} dBFS${Math.abs(g.th - thresholdDb) < 0.05 ? ' ← used' : ''} | ${g.gaps} | ${fmt(g.total, 1)}s |`).join('\n')}
338
+
339
+ ## Using it
340
+
341
+ The cut list is the deliverable: feed \`keep[]\` to the composition so the voice is encoded
342
+ once, at final render. To cut the file directly for a preview:
343
+
344
+ \`\`\`
345
+ ffmpeg -i "${file}" -vf "${vf}" -af "${af}" -c:v libx264 -crf 18 -c:a aac -b:a 256k out.mp4
346
+ \`\`\`
347
+ `;
348
+ writeFileSync(join(dir, `${stem}.trim.md`), md);
349
+
350
+ if (writePath) {
351
+ console.log('Encoding trimmed file (one encode, voice not otherwise processed)...');
352
+ const r = run(FFMPEG, ['-y', '-v', 'error', '-i', file, '-vf', vf, '-af', af, '-c:v', 'libx264', '-crf', '18', '-preset', 'slow', '-c:a', 'aac', '-b:a', '256k', writePath]);
353
+ if (!r.ok) {
354
+ console.error(r.err.trim() || 'ffmpeg failed');
355
+ process.exit(1);
356
+ }
357
+ console.log(`Written ${writePath}`);
358
+ }
359
+
360
+ console.log(`Was ${fmt(duration, 2)}s`);
361
+ console.log(`Now ${fmt(newDuration, 2)}s (${fmt(saved, 2)}s removed, ${fmt((saved / duration) * 100, 0)}%)`);
362
+ console.log(`Segments ${segments.length} kept, ${punchIns.length} punch-in(s)`);
363
+ console.log(`Gate ${fmt(thresholdDb, 1)} dBFS (floor ${fmt(noiseFloorDb, 1)}, peaks ${fmt(speechPeakDb, 1)})`);
364
+ if (saved / duration < 0.05) {
365
+ const louder = gateTable.filter((g) => g.th > thresholdDb && g.gaps > removed.length);
366
+ console.log(
367
+ louder.length
368
+ ? ` Barely trimmed — either the take is tight, or the gate sat under the breaths.\n A gate of ${fmt(louder[0].th, 0)} dBFS would find ${louder[0].gaps} pauses (${fmt(louder[0].total, 1)}s): --floor ${fmt(louder[0].th, 0)}`
369
+ : ' Barely trimmed, and no higher gate finds more — this take is genuinely tight.',
370
+ );
371
+ }
372
+ if (removedFillers.length) console.log(`Fillers ${removedFillers.map((f) => f.word.trim()).join(', ')}`);
373
+ console.log(`Written ${jsonPath}`);
374
+ console.log('\nEvery join is a jump cut — zoom through each one. The punch-in list says which way.');
@@ -0,0 +1,42 @@
1
+ import React from 'react';
2
+ import {Composition} from 'remotion';
3
+ import {FILM_TEST_FRAMES, FilmTest} from './compositions/FilmTest';
4
+ import {PRESET_TEST_FRAMES, PresetTest} from './compositions/PresetTest';
5
+ import {LAYOUT} from './design/tokens';
6
+ import {Sample, SAMPLE_FRAMES} from './videos/sample/Sample';
7
+
8
+ /**
9
+ * Every video you make gets registered here. `npm run studio` shows this list.
10
+ *
11
+ * PresetTest and FilmTest are reference compositions, not videos — they show every
12
+ * transition and every film look side by side so you can judge them against each other
13
+ * and pick one by name. Leave them; they cost nothing and they are how you decide.
14
+ */
15
+ export const RemotionRoot: React.FC = () => (
16
+ <>
17
+ <Composition
18
+ id="Sample"
19
+ component={Sample}
20
+ durationInFrames={SAMPLE_FRAMES}
21
+ fps={LAYOUT.fps}
22
+ width={LAYOUT.width}
23
+ height={LAYOUT.height}
24
+ />
25
+ <Composition
26
+ id="PresetTest"
27
+ component={PresetTest}
28
+ durationInFrames={PRESET_TEST_FRAMES}
29
+ fps={LAYOUT.fps}
30
+ width={LAYOUT.width}
31
+ height={LAYOUT.height}
32
+ />
33
+ <Composition
34
+ id="FilmTest"
35
+ component={FilmTest}
36
+ durationInFrames={FILM_TEST_FRAMES}
37
+ fps={LAYOUT.fps}
38
+ width={LAYOUT.width}
39
+ height={LAYOUT.height}
40
+ />
41
+ </>
42
+ );
@@ -0,0 +1,22 @@
1
+ import React from 'react';
2
+ import {AbsoluteFill} from 'remotion';
3
+ import {BACKGROUND} from '../design/tokens';
4
+
5
+ /** Cream base, soft accent glow, faint fading grid — the site's hero treatment adapted for vertical video. */
6
+ export const BrandBackground: React.FC = () => {
7
+ const {gridLine, gridSize, gridLineWidth, gridMask} = BACKGROUND;
8
+ const lines = (angle: number) =>
9
+ `repeating-linear-gradient(${angle}deg, ${gridLine} 0 ${gridLineWidth}px, transparent ${gridLineWidth}px ${gridSize}px)`;
10
+
11
+ return (
12
+ <AbsoluteFill style={{background: BACKGROUND.creamGlow}}>
13
+ <AbsoluteFill
14
+ style={{
15
+ backgroundImage: `${lines(0)}, ${lines(90)}`,
16
+ maskImage: gridMask,
17
+ WebkitMaskImage: gridMask,
18
+ }}
19
+ />
20
+ </AbsoluteFill>
21
+ );
22
+ };