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,7 @@
|
|
|
1
|
+
import {Config} from '@remotion/cli/config';
|
|
2
|
+
|
|
3
|
+
Config.setEntryPoint('./src/index.ts');
|
|
4
|
+
// Everything in assets/ is reachable with staticFile('footage/clip.mp4') etc.
|
|
5
|
+
Config.setPublicDir('./assets');
|
|
6
|
+
Config.setVideoImageFormat('jpeg');
|
|
7
|
+
Config.setOverwriteOutput(true);
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# The Python half of the studio: transcription, word alignment, cut detection,
|
|
2
|
+
# green-screen cutouts and colour grading.
|
|
3
|
+
#
|
|
4
|
+
# python -m venv .venv
|
|
5
|
+
# .venv/bin/pip install -r requirements.txt (Windows: .venv\Scripts\pip)
|
|
6
|
+
#
|
|
7
|
+
# Python 3.11 or 3.12. Not 3.13+ — WhisperX's dependencies do not support it yet, and the
|
|
8
|
+
# failure is a wall of build errors rather than a clear message.
|
|
9
|
+
#
|
|
10
|
+
# certifi is not optional on macOS: torch downloads the alignment model with urllib, which
|
|
11
|
+
# on a python.org build has no root certificates at all.
|
|
12
|
+
|
|
13
|
+
whisperx==3.8.6
|
|
14
|
+
torch>=2.2
|
|
15
|
+
numpy>=1.26
|
|
16
|
+
Pillow>=10.0
|
|
17
|
+
certifi>=2024.2.2
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Give every word of the written script a time, using the WhisperX word timings.
|
|
2
|
+
|
|
3
|
+
Usage (from the project root):
|
|
4
|
+
python scripts/align_script.py <script.txt> <words.json> <out.json>
|
|
5
|
+
|
|
6
|
+
The script is the text truth (spelling, names, numbers); the transcript only provides timing.
|
|
7
|
+
Script words are matched to spoken words in order (difflib). Words the speaker skipped or
|
|
8
|
+
changed get times interpolated between the nearest matched neighbours, proportional to length.
|
|
9
|
+
Spoken ad-libs that are not in the script are ignored.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import difflib
|
|
13
|
+
import json
|
|
14
|
+
import re
|
|
15
|
+
import sys
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def keys(token: str) -> list[str]:
|
|
19
|
+
"""Comparable keys for a word: lowercase, no punctuation, hyphenated parts split,
|
|
20
|
+
so "12-pack" matches spoken "12 pack" and "$26.50" matches spoken "2650"."""
|
|
21
|
+
parts = re.split(r"[-/]", token.lower())
|
|
22
|
+
out = []
|
|
23
|
+
for part in parts:
|
|
24
|
+
norm = re.sub(r"[^a-z0-9]", "", part.replace("'", ""))
|
|
25
|
+
if norm:
|
|
26
|
+
out.append(norm)
|
|
27
|
+
return out
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def main() -> None:
|
|
31
|
+
script_path, words_path, out_path = sys.argv[1:4]
|
|
32
|
+
script = open(script_path, encoding="utf-8").read()
|
|
33
|
+
spoken = json.load(open(words_path, encoding="utf-8"))["words"]
|
|
34
|
+
|
|
35
|
+
# Script tokens, remembering sentence and paragraph boundaries.
|
|
36
|
+
tokens = []
|
|
37
|
+
for p_index, paragraph in enumerate(l for l in script.splitlines() if l.strip()):
|
|
38
|
+
for raw in paragraph.split():
|
|
39
|
+
tokens.append({"text": raw, "paragraph": p_index, "keys": keys(raw)})
|
|
40
|
+
|
|
41
|
+
script_keys, key_owner = [], []
|
|
42
|
+
for i, t in enumerate(tokens):
|
|
43
|
+
for k in t["keys"]:
|
|
44
|
+
script_keys.append(k)
|
|
45
|
+
key_owner.append(i)
|
|
46
|
+
|
|
47
|
+
spoken_keys, spoken_owner = [], []
|
|
48
|
+
for j, w in enumerate(spoken):
|
|
49
|
+
for k in keys(w["word"]):
|
|
50
|
+
spoken_keys.append(k)
|
|
51
|
+
spoken_owner.append(j)
|
|
52
|
+
|
|
53
|
+
matcher = difflib.SequenceMatcher(a=script_keys, b=spoken_keys, autojunk=False)
|
|
54
|
+
times: dict[int, list[float]] = {}
|
|
55
|
+
for block in matcher.get_matching_blocks():
|
|
56
|
+
for n in range(block.size):
|
|
57
|
+
ti = key_owner[block.a + n]
|
|
58
|
+
w = spoken[spoken_owner[block.b + n]]
|
|
59
|
+
if w["start"] is None:
|
|
60
|
+
continue
|
|
61
|
+
span = times.setdefault(ti, [w["start"], w["end"]])
|
|
62
|
+
span[0] = min(span[0], w["start"])
|
|
63
|
+
span[1] = max(span[1], w["end"])
|
|
64
|
+
|
|
65
|
+
audio_end = max(w["end"] for w in spoken if w["end"] is not None)
|
|
66
|
+
matched = sorted(times)
|
|
67
|
+
# Interpolate unmatched runs between matched neighbours, weighted by text length.
|
|
68
|
+
i = 0
|
|
69
|
+
while i < len(tokens):
|
|
70
|
+
if i in times:
|
|
71
|
+
i += 1
|
|
72
|
+
continue
|
|
73
|
+
run_start = i
|
|
74
|
+
while i < len(tokens) and i not in times:
|
|
75
|
+
i += 1
|
|
76
|
+
left = times[run_start - 1][1] if run_start - 1 in times else 0.0
|
|
77
|
+
right = times[i][0] if i in times else audio_end
|
|
78
|
+
run = tokens[run_start:i]
|
|
79
|
+
total = sum(len(t["text"]) for t in run) or 1
|
|
80
|
+
cursor = left
|
|
81
|
+
for offset, t in enumerate(run):
|
|
82
|
+
share = (right - left) * len(t["text"]) / total
|
|
83
|
+
times[run_start + offset] = [cursor, cursor + share]
|
|
84
|
+
cursor += share
|
|
85
|
+
|
|
86
|
+
out = []
|
|
87
|
+
for i, t in enumerate(tokens):
|
|
88
|
+
start, end = times[i]
|
|
89
|
+
out.append(
|
|
90
|
+
{
|
|
91
|
+
"text": t["text"],
|
|
92
|
+
"start": round(start, 3),
|
|
93
|
+
"end": round(end, 3),
|
|
94
|
+
"paragraph": t["paragraph"],
|
|
95
|
+
"matched": i in matched,
|
|
96
|
+
}
|
|
97
|
+
)
|
|
98
|
+
json.dump({"audioEnd": round(audio_end, 3), "words": out}, open(out_path, "w", encoding="utf-8"), indent=1, ensure_ascii=False)
|
|
99
|
+
|
|
100
|
+
unmatched = [(o["text"], o["start"]) for o in out if not o["matched"]]
|
|
101
|
+
print(f"script words: {len(out)} | matched to speech: {len(matched)} | interpolated: {len(unmatched)}")
|
|
102
|
+
print("interpolated:", " ".join(f"{t}@{s:.1f}" for t, s in unmatched))
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
if __name__ == "__main__":
|
|
106
|
+
main()
|
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Music-driven assembly: a beat map plus a pile of clips becomes a cut that breathes.
|
|
4
|
+
*
|
|
5
|
+
* npm run assemble -- --beats work/<n>/track.beats.json --clips <dir|file ...>
|
|
6
|
+
* [--project <name>] [--fps 30] [--style travel|gym|product|vlog]
|
|
7
|
+
* [--duration 30] [--preview]
|
|
8
|
+
*
|
|
9
|
+
* The shape, not just the timing: long shots in the intro, shots that shorten as the
|
|
10
|
+
* track builds, one shot per beat through the drop, then a held shot at the end. Where
|
|
11
|
+
* the drop is comes from the music itself — onset density per bar — not from a guess.
|
|
12
|
+
*
|
|
13
|
+
* Writes work/<project>/assembly.json + assembly.md:
|
|
14
|
+
* phases[] intro / build / drop / outro, with the bar each one starts on
|
|
15
|
+
* shots[] clip, in/out, timeline position, length in beats, transition in
|
|
16
|
+
*
|
|
17
|
+
* --preview renders a rough cut with ffmpeg (picture only + the track) so the rhythm
|
|
18
|
+
* can be judged at playback speed. It is a preview: the real edit is built in the
|
|
19
|
+
* composition from assembly.json, where the motion laws apply.
|
|
20
|
+
*/
|
|
21
|
+
import {existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync} from 'node:fs';
|
|
22
|
+
import {basename, extname, join} from 'node:path';
|
|
23
|
+
import {FFMPEG, fmt, probe, run, timecode} from './lib/media.mjs';
|
|
24
|
+
|
|
25
|
+
const args = process.argv.slice(2);
|
|
26
|
+
const flag = (name, fallback) => {
|
|
27
|
+
const i = args.indexOf(`--${name}`);
|
|
28
|
+
return i >= 0 && args[i + 1] && !args[i + 1].startsWith('--') ? args[i + 1] : fallback;
|
|
29
|
+
};
|
|
30
|
+
const flagList = (name) => {
|
|
31
|
+
const i = args.indexOf(`--${name}`);
|
|
32
|
+
if (i < 0) return [];
|
|
33
|
+
const out = [];
|
|
34
|
+
for (let j = i + 1; j < args.length && !args[j].startsWith('--'); j++) out.push(args[j]);
|
|
35
|
+
return out;
|
|
36
|
+
};
|
|
37
|
+
const has = (name) => args.includes(`--${name}`);
|
|
38
|
+
|
|
39
|
+
const beatsPath = flag('beats', null);
|
|
40
|
+
const clipArgs = flagList('clips');
|
|
41
|
+
const project = flag('project', null);
|
|
42
|
+
const fps = Number(flag('fps', 30));
|
|
43
|
+
const style = flag('style', 'travel');
|
|
44
|
+
const wantSeconds = has('duration') ? Number(flag('duration', 30)) : null;
|
|
45
|
+
|
|
46
|
+
if (!beatsPath || !existsSync(beatsPath) || !clipArgs.length) {
|
|
47
|
+
console.error('Usage: npm run assemble -- --beats <beats.json> --clips <dir|file ...> [--style travel] [--duration 30] [--preview]');
|
|
48
|
+
process.exit(2);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const beats = JSON.parse(readFileSync(beatsPath, 'utf8'));
|
|
52
|
+
const beatTimes = beats.beatSeconds || [];
|
|
53
|
+
const downbeats = new Set(beats.downbeatSeconds || []);
|
|
54
|
+
const onsets = beats.onsetSeconds || [];
|
|
55
|
+
if (beatTimes.length < 8) {
|
|
56
|
+
console.error('That beat map has too few beats to cut to. Run npm run beats on a longer track.');
|
|
57
|
+
process.exit(1);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/* ---------------------------------------------------------------- clips */
|
|
61
|
+
|
|
62
|
+
const VIDEO = /\.(mp4|mov|m4v|mkv|webm|avi)$/i;
|
|
63
|
+
const clipFiles = clipArgs.flatMap((p) => {
|
|
64
|
+
if (!existsSync(p)) return [];
|
|
65
|
+
if (statSync(p).isDirectory()) {
|
|
66
|
+
return readdirSync(p)
|
|
67
|
+
.filter((f) => VIDEO.test(f))
|
|
68
|
+
.sort()
|
|
69
|
+
.map((f) => join(p, f));
|
|
70
|
+
}
|
|
71
|
+
return VIDEO.test(p) ? [p] : [];
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
if (!clipFiles.length) {
|
|
75
|
+
console.error('No video clips found at that path.');
|
|
76
|
+
process.exit(1);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const clips = clipFiles
|
|
80
|
+
.map((path) => {
|
|
81
|
+
const info = probe(path);
|
|
82
|
+
return info && info.duration > 0.4 ? {path, name: basename(path), duration: info.duration, width: info.width, height: info.height, cursor: 0.3} : null;
|
|
83
|
+
})
|
|
84
|
+
.filter(Boolean);
|
|
85
|
+
|
|
86
|
+
if (!clips.length) {
|
|
87
|
+
console.error('Those clips could not be probed, or all of them are under half a second.');
|
|
88
|
+
process.exit(1);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/* ---------------------------------------------------------------- energy per bar */
|
|
92
|
+
|
|
93
|
+
const barStarts = beats.downbeatSeconds?.length ? beats.downbeatSeconds : beatTimes.filter((_, i) => i % 4 === 0);
|
|
94
|
+
const barEnergy = barStarts.map((t, i) => {
|
|
95
|
+
const next = barStarts[i + 1] ?? t + (barStarts[1] - barStarts[0] || 2);
|
|
96
|
+
const hits = onsets.filter((o) => o >= t && o < next).length;
|
|
97
|
+
return {bar: i, start: t, end: next, hits};
|
|
98
|
+
});
|
|
99
|
+
const maxHits = Math.max(1, ...barEnergy.map((b) => b.hits));
|
|
100
|
+
// Smooth over three bars so one busy fill does not read as the drop.
|
|
101
|
+
const energy = barEnergy.map((b, i) => {
|
|
102
|
+
const window = barEnergy.slice(Math.max(0, i - 1), Math.min(barEnergy.length, i + 2));
|
|
103
|
+
return {...b, level: window.reduce((s, w) => s + w.hits, 0) / window.length / maxHits};
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The drop is the loudest sustained stretch, ignoring the first bars (an intro fill is
|
|
108
|
+
* not a drop). The build is where the level first climbs past two thirds of it.
|
|
109
|
+
*/
|
|
110
|
+
const searchFrom = Math.max(1, Math.floor(energy.length * 0.12));
|
|
111
|
+
let dropBar = searchFrom;
|
|
112
|
+
for (let i = searchFrom; i < energy.length; i++) if (energy[i].level > energy[dropBar].level) dropBar = i;
|
|
113
|
+
const peak = energy[dropBar].level;
|
|
114
|
+
let buildBar = 0;
|
|
115
|
+
for (let i = dropBar; i >= 0; i--) {
|
|
116
|
+
if (energy[i].level < peak * 0.66) {
|
|
117
|
+
buildBar = i;
|
|
118
|
+
break;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
let outroBar = energy.length;
|
|
122
|
+
for (let i = dropBar + 1; i < energy.length; i++) {
|
|
123
|
+
if (energy[i].level < peak * 0.7) {
|
|
124
|
+
outroBar = i;
|
|
125
|
+
break;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const barAt = (t) => {
|
|
130
|
+
let b = 0;
|
|
131
|
+
for (let i = 0; i < barStarts.length; i++) if (barStarts[i] <= t) b = i;
|
|
132
|
+
return b;
|
|
133
|
+
};
|
|
134
|
+
const phaseAt = (t) => {
|
|
135
|
+
const b = barAt(t);
|
|
136
|
+
if (b < buildBar) return 'intro';
|
|
137
|
+
if (b < dropBar) return 'build';
|
|
138
|
+
if (b < outroBar) return 'drop';
|
|
139
|
+
return 'outro';
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
/* ---------------------------------------------------------------- shot lengths */
|
|
143
|
+
|
|
144
|
+
/** Beats per shot, per phase. A gym cut is tighter than a travel cut; a vlog breathes. */
|
|
145
|
+
const STYLE = {
|
|
146
|
+
travel: {intro: 8, build: [6, 2], drop: 2, outro: 8},
|
|
147
|
+
gym: {intro: 4, build: [4, 1], drop: 1, outro: 4},
|
|
148
|
+
product: {intro: 8, build: [4, 2], drop: 2, outro: 8},
|
|
149
|
+
vlog: {intro: 8, build: [8, 4], drop: 4, outro: 8},
|
|
150
|
+
};
|
|
151
|
+
const plan = STYLE[style] || STYLE.travel;
|
|
152
|
+
|
|
153
|
+
const endSeconds = Math.min(
|
|
154
|
+
beats.durationSeconds || beatTimes[beatTimes.length - 1],
|
|
155
|
+
wantSeconds ? beatTimes[0] + wantSeconds : Infinity,
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
const slots = [];
|
|
159
|
+
let bi = 0;
|
|
160
|
+
while (bi < beatTimes.length - 1) {
|
|
161
|
+
const t = beatTimes[bi];
|
|
162
|
+
if (t >= endSeconds) break;
|
|
163
|
+
const phase = phaseAt(t);
|
|
164
|
+
let step;
|
|
165
|
+
if (phase === 'build') {
|
|
166
|
+
// Accelerate smoothly across the build instead of stepping down once.
|
|
167
|
+
const span = Math.max(1, dropBar - buildBar);
|
|
168
|
+
const through = Math.min(1, Math.max(0, (barAt(t) - buildBar) / span));
|
|
169
|
+
const [from, to] = plan.build;
|
|
170
|
+
step = Math.max(1, Math.round(from + (to - from) * through));
|
|
171
|
+
} else {
|
|
172
|
+
step = plan[phase];
|
|
173
|
+
}
|
|
174
|
+
const endBeat = Math.min(beatTimes.length - 1, bi + step);
|
|
175
|
+
const end = Math.min(beatTimes[endBeat], endSeconds);
|
|
176
|
+
if (end - t < 0.12) break;
|
|
177
|
+
slots.push({start: t, end, beats: step, phase, onDownbeat: downbeats.has(t)});
|
|
178
|
+
bi = endBeat;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (!slots.length) {
|
|
182
|
+
console.error('No shot slots came out of that beat map — try a longer --duration.');
|
|
183
|
+
process.exit(1);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/* ---------------------------------------------------------------- clips into slots */
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Clips are used in order and cycled. Each clip keeps a cursor so the second time it
|
|
190
|
+
* comes round you get a different part of it, not the same three seconds again.
|
|
191
|
+
*/
|
|
192
|
+
const shots = slots.map((slot, i) => {
|
|
193
|
+
const need = slot.end - slot.start;
|
|
194
|
+
let chosen = null;
|
|
195
|
+
for (let k = 0; k < clips.length; k++) {
|
|
196
|
+
const c = clips[(i + k) % clips.length];
|
|
197
|
+
if (c.duration - c.cursor >= need) {
|
|
198
|
+
chosen = c;
|
|
199
|
+
break;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
let needsRamp = false;
|
|
203
|
+
let speed = 1;
|
|
204
|
+
if (!chosen) {
|
|
205
|
+
// Nothing long enough left: take the longest clip and slow it to fill the slot.
|
|
206
|
+
chosen = clips.reduce((a, b) => (b.duration > a.duration ? b : a), clips[0]);
|
|
207
|
+
chosen.cursor = 0.3;
|
|
208
|
+
const available = Math.max(0.3, chosen.duration - chosen.cursor);
|
|
209
|
+
if (available < need) {
|
|
210
|
+
needsRamp = true;
|
|
211
|
+
speed = Number((available / need).toFixed(3));
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
const inPoint = Math.min(chosen.cursor, Math.max(0, chosen.duration - need));
|
|
215
|
+
const outPoint = Math.min(chosen.duration, inPoint + need * (needsRamp ? speed : 1));
|
|
216
|
+
chosen.cursor = outPoint + 0.2;
|
|
217
|
+
if (chosen.cursor > chosen.duration - 0.4) chosen.cursor = 0.3;
|
|
218
|
+
|
|
219
|
+
const prev = slots[i - 1];
|
|
220
|
+
let transitionIn = 'cut';
|
|
221
|
+
if (i === 0) transitionIn = 'none';
|
|
222
|
+
else if (prev.phase !== slot.phase) transitionIn = slot.phase === 'drop' ? 'whipPan' : slot.phase === 'outro' ? 'dip' : 'push';
|
|
223
|
+
else if (slot.onDownbeat && slot.phase === 'drop' && i % 4 === 0) transitionIn = 'colourFlip';
|
|
224
|
+
|
|
225
|
+
return {
|
|
226
|
+
index: i,
|
|
227
|
+
clip: chosen.name,
|
|
228
|
+
path: chosen.path,
|
|
229
|
+
in: Number(inPoint.toFixed(3)),
|
|
230
|
+
out: Number(outPoint.toFixed(3)),
|
|
231
|
+
speed: needsRamp ? speed : 1,
|
|
232
|
+
needsRamp,
|
|
233
|
+
startSeconds: Number((slot.start - beatTimes[0]).toFixed(3)),
|
|
234
|
+
startFrame: Math.round((slot.start - beatTimes[0]) * fps),
|
|
235
|
+
durationFrames: Math.round(need * fps),
|
|
236
|
+
seconds: Number(need.toFixed(3)),
|
|
237
|
+
beats: slot.beats,
|
|
238
|
+
phase: slot.phase,
|
|
239
|
+
transitionIn,
|
|
240
|
+
};
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Whatever is left over when the track (or --duration) runs out lands on the final shot,
|
|
245
|
+
* which can leave a stub too short to register as anything but a mistake. Fold it back
|
|
246
|
+
* into the shot before it, so the edit ends on a held frame rather than a flinch.
|
|
247
|
+
*/
|
|
248
|
+
if (shots.length > 1) {
|
|
249
|
+
const tail = shots[shots.length - 1];
|
|
250
|
+
const prev = shots[shots.length - 2];
|
|
251
|
+
if (tail.seconds < 0.6 * prev.seconds) {
|
|
252
|
+
const clip = clips.find((c) => c.path === prev.path);
|
|
253
|
+
const wanted = prev.seconds + tail.seconds;
|
|
254
|
+
const available = clip ? clip.duration - prev.in : wanted;
|
|
255
|
+
prev.seconds = Number(wanted.toFixed(3));
|
|
256
|
+
prev.durationFrames = Math.round(wanted * fps);
|
|
257
|
+
prev.out = Number(Math.min(prev.in + wanted, clip ? clip.duration : prev.in + wanted).toFixed(3));
|
|
258
|
+
if (available < wanted) {
|
|
259
|
+
prev.needsRamp = true;
|
|
260
|
+
prev.speed = Number((available / wanted).toFixed(3));
|
|
261
|
+
}
|
|
262
|
+
shots.pop();
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// The last shot is the one people remember: let it hold, with a slow push instead of a cut.
|
|
267
|
+
const last = shots[shots.length - 1];
|
|
268
|
+
last.hold = true;
|
|
269
|
+
last.note = 'Hold. Slow push-in (DUR.drift) instead of another cut.';
|
|
270
|
+
|
|
271
|
+
/* ---------------------------------------------------------------- write */
|
|
272
|
+
|
|
273
|
+
const stem = basename(beatsPath, '.beats.json').replace(/\.json$/, '');
|
|
274
|
+
const dir = project ? join('work', project) : join('work', 'assembly');
|
|
275
|
+
mkdirSync(dir, {recursive: true});
|
|
276
|
+
|
|
277
|
+
const phases = [
|
|
278
|
+
{name: 'intro', fromBar: 0, fromSeconds: Number(barStarts[0].toFixed(3))},
|
|
279
|
+
{name: 'build', fromBar: buildBar, fromSeconds: Number((barStarts[buildBar] ?? 0).toFixed(3))},
|
|
280
|
+
{name: 'drop', fromBar: dropBar, fromSeconds: Number((barStarts[dropBar] ?? 0).toFixed(3))},
|
|
281
|
+
{name: 'outro', fromBar: outroBar, fromSeconds: Number((barStarts[outroBar] ?? barStarts[barStarts.length - 1]).toFixed(3))},
|
|
282
|
+
];
|
|
283
|
+
|
|
284
|
+
const totalSeconds = shots.reduce((s, x) => s + x.seconds, 0);
|
|
285
|
+
const out = {
|
|
286
|
+
music: beats.source,
|
|
287
|
+
beatsFile: beatsPath,
|
|
288
|
+
bpm: beats.bpm,
|
|
289
|
+
fps,
|
|
290
|
+
style,
|
|
291
|
+
startsAtSeconds: Number(beatTimes[0].toFixed(3)),
|
|
292
|
+
totalSeconds: Number(totalSeconds.toFixed(3)),
|
|
293
|
+
totalFrames: Math.round(totalSeconds * fps),
|
|
294
|
+
phases,
|
|
295
|
+
clips: clips.map((c) => ({name: c.name, seconds: Number(c.duration.toFixed(2))})),
|
|
296
|
+
shots,
|
|
297
|
+
};
|
|
298
|
+
const jsonPath = join(dir, 'assembly.json');
|
|
299
|
+
writeFileSync(jsonPath, JSON.stringify(out, null, 2));
|
|
300
|
+
|
|
301
|
+
const md = `# Assembly — ${stem}
|
|
302
|
+
|
|
303
|
+
- **Track:** ${basename(String(beats.source || ''))} · **${fmt(beats.bpm, 1)} BPM** · cut starts at ${timecode(beatTimes[0])}
|
|
304
|
+
- **Style:** ${style} · **${shots.length} shots** across ${fmt(totalSeconds, 1)}s (${Math.round(totalSeconds * fps)} frames @ ${fps}fps)
|
|
305
|
+
- **Clips used:** ${clips.length}
|
|
306
|
+
|
|
307
|
+
## Shape
|
|
308
|
+
|
|
309
|
+
| phase | starts | shot length |
|
|
310
|
+
|---|---|---|
|
|
311
|
+
| intro | ${timecode(phases[0].fromSeconds)} | ${plan.intro} beats — let it breathe |
|
|
312
|
+
| build | ${timecode(phases[1].fromSeconds)} | ${plan.build[0]} → ${plan.build[1]} beats, accelerating |
|
|
313
|
+
| drop | ${timecode(phases[2].fromSeconds)} | ${plan.drop} beat${plan.drop > 1 ? 's' : ''} — hardest cutting |
|
|
314
|
+
| outro | ${timecode(phases[3].fromSeconds)} | ${plan.outro} beats — hold the last shot |
|
|
315
|
+
|
|
316
|
+
The drop was found from the music: bar ${dropBar} has the highest sustained onset density.
|
|
317
|
+
|
|
318
|
+
## Edit list
|
|
319
|
+
|
|
320
|
+
| # | at | len | clip | in → out | phase | transition in |
|
|
321
|
+
|---|---|---|---|---|---|---|
|
|
322
|
+
${shots.map((s) => `| ${String(s.index + 1).padStart(2, '0')} | ${timecode(s.startSeconds)} (f${s.startFrame}) | ${fmt(s.seconds, 2)}s | ${s.clip} | ${fmt(s.in, 2)} → ${fmt(s.out, 2)} | ${s.phase} | ${s.transitionIn} |`).join('\n')}
|
|
323
|
+
|
|
324
|
+
${shots.filter((s) => s.needsRamp).length ? `## Needs a ramp\n\n${shots.filter((s) => s.needsRamp).map((s) => `- Shot ${s.index + 1} (${s.clip}) is shorter than its slot. Slow it to ×${fmt(s.speed, 2)}:\n \`npm run ramp -- "${s.path}" --ramp "${fmt(s.in, 2)}-${fmt(s.out, 2)}:1->${fmt(s.speed, 2)}" --ease inOut\`` ).join('\n')}\n` : ''}
|
|
325
|
+
## Notes
|
|
326
|
+
|
|
327
|
+
- Cuts land on beats; phase changes get a named transition, everything else is a straight cut.
|
|
328
|
+
- ${last.note}
|
|
329
|
+
- Run \`npm run sound\` on this file next — it places whooshes, impacts and the riser off these cuts.
|
|
330
|
+
`;
|
|
331
|
+
writeFileSync(join(dir, 'assembly.md'), md);
|
|
332
|
+
|
|
333
|
+
/* ---------------------------------------------------------------- preview */
|
|
334
|
+
|
|
335
|
+
if (has('preview')) {
|
|
336
|
+
const previewPath = join(dir, 'assembly-preview.mp4');
|
|
337
|
+
const inputs = [];
|
|
338
|
+
const parts = [];
|
|
339
|
+
shots.forEach((s, i) => {
|
|
340
|
+
inputs.push('-ss', String(s.in), '-t', String(Math.max(0.05, s.out - s.in)), '-i', s.path);
|
|
341
|
+
const speedFilter = s.needsRamp ? `,setpts=${fmt(1 / Math.max(0.05, s.speed), 4)}*PTS` : '';
|
|
342
|
+
parts.push(
|
|
343
|
+
`[${i}:v]scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920,fps=${fps},setsar=1${speedFilter}[v${i}]`,
|
|
344
|
+
);
|
|
345
|
+
});
|
|
346
|
+
const music = beats.source && existsSync(beats.source) ? beats.source : null;
|
|
347
|
+
if (music) inputs.push('-ss', String(beatTimes[0]), '-i', music);
|
|
348
|
+
const concat = `${shots.map((_, i) => `[v${i}]`).join('')}concat=n=${shots.length}:v=1:a=0[vout]`;
|
|
349
|
+
const filter = `${parts.join(';')};${concat}`;
|
|
350
|
+
const cmd = ['-y', '-v', 'error', ...inputs, '-filter_complex', filter, '-map', '[vout]'];
|
|
351
|
+
if (music) cmd.push('-map', `${shots.length}:a`, '-c:a', 'aac', '-b:a', '256k', '-shortest');
|
|
352
|
+
cmd.push('-c:v', 'libx264', '-crf', '20', '-preset', 'veryfast', '-pix_fmt', 'yuv420p', previewPath);
|
|
353
|
+
|
|
354
|
+
console.log(`Rendering preview (${shots.length} shots)...`);
|
|
355
|
+
const r = run(FFMPEG, cmd);
|
|
356
|
+
if (!r.ok) console.error(`Preview failed: ${r.err.trim().split('\n').slice(-3).join(' ')}`);
|
|
357
|
+
else console.log(`Preview ${previewPath}`);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
console.log(`Track ${fmt(beats.bpm, 1)} BPM, drop at bar ${dropBar} (${timecode(barStarts[dropBar] ?? 0)})`);
|
|
361
|
+
console.log(`Shape intro ${buildBar} bars · build ${dropBar - buildBar} · drop ${outroBar - dropBar} · outro ${Math.max(0, energy.length - outroBar)}`);
|
|
362
|
+
console.log(`Shots ${shots.length} over ${fmt(totalSeconds, 1)}s from ${clips.length} clip(s)`);
|
|
363
|
+
console.log(`Written ${jsonPath}`);
|
|
364
|
+
console.log('\nCuts land on beats. Feed assembly.json to the composition, then run npm run sound on it.');
|