@anvpx/motion-build 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +67 -2
- package/dist/.out/demo/boot.js +90 -0
- package/dist/.out/demo/runtime/easing.js +45 -0
- package/dist/.out/demo/runtime/mount.js +153 -0
- package/dist/.out/demo/runtime/timeline.js +199 -0
- package/dist/.out/demo/scenes/count/scene.js +6 -0
- package/dist/.out/demo/scenes/intro/scene.js +6 -0
- package/dist/.out/demo/scenes/outro/scene.js +6 -0
- package/dist/.out/demo/scenes/path/scene.js +6 -0
- package/dist/.out/demo/scenes/signals/scene.js +6 -0
- package/dist/.out/demo/shared/format.js +59 -0
- package/dist/.out/demo/shared/motion.js +117 -0
- package/dist/.out/demo-overlays/boot.js +95 -0
- package/dist/.out/demo-overlays/runtime/easing.js +45 -0
- package/dist/.out/demo-overlays/runtime/mount.js +153 -0
- package/dist/.out/demo-overlays/runtime/timeline.js +199 -0
- package/dist/.out/demo-overlays/scenes/chapter-signals/scene.js +6 -0
- package/dist/.out/demo-overlays/scenes/count/scene.js +6 -0
- package/dist/.out/demo-overlays/scenes/flow-progress/scene.js +17 -0
- package/dist/.out/demo-overlays/scenes/intro/scene.js +6 -0
- package/dist/.out/demo-overlays/scenes/mark/scene.js +6 -0
- package/dist/.out/demo-overlays/scenes/outro/scene.js +6 -0
- package/dist/.out/demo-overlays/scenes/path/scene.js +6 -0
- package/dist/.out/demo-overlays/scenes/presenter/scene.js +6 -0
- package/dist/.out/demo-overlays/scenes/signals/scene.js +6 -0
- package/dist/.out/demo-overlays/scenes/tick/scene.js +21 -0
- package/dist/.out/demo-overlays/shared/format.js +59 -0
- package/dist/.out/demo-overlays/shared/motion.js +117 -0
- package/dist/assets.d.ts +36 -0
- package/dist/assets.js +95 -0
- package/dist/audio/approval.d.ts +45 -0
- package/dist/audio/approval.js +82 -0
- package/dist/audio/mix.d.ts +231 -0
- package/dist/audio/mix.js +491 -0
- package/dist/audio/music.d.ts +82 -0
- package/dist/audio/music.js +134 -0
- package/dist/audio/preview.d.ts +31 -0
- package/dist/audio/preview.js +31 -0
- package/dist/build.d.ts +65 -0
- package/dist/build.js +260 -0
- package/dist/cache.d.ts +59 -0
- package/dist/cache.js +249 -0
- package/dist/emit/boot.d.ts +16 -0
- package/dist/emit/boot.js +32 -0
- package/dist/emit/boot.template.js +85 -0
- package/dist/emit/fragment-guard.d.ts +5 -0
- package/dist/emit/fragment-guard.js +15 -0
- package/dist/emit/index-html.d.ts +26 -0
- package/dist/emit/index-html.js +63 -0
- package/dist/emit/safe-json.d.ts +2 -0
- package/dist/emit/safe-json.js +7 -0
- package/dist/emit/vendor.d.ts +7 -0
- package/dist/emit/vendor.js +57 -0
- package/dist/fonts.d.ts +39 -0
- package/dist/fonts.js +112 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +19 -0
- package/dist/integration/render.integration.d.ts +1 -0
- package/dist/integration/render.integration.js +162 -0
- package/dist/lint/custom-source.d.ts +12 -0
- package/dist/lint/custom-source.js +130 -0
- package/dist/lint/fixtures.d.ts +24 -0
- package/dist/lint/fixtures.js +42 -0
- package/dist/lint/flow.d.ts +12 -0
- package/dist/lint/flow.js +38 -0
- package/dist/lint/frame.d.ts +3 -0
- package/dist/lint/frame.js +2 -0
- package/dist/lint/hook-first.d.ts +6 -0
- package/dist/lint/hook-first.js +27 -0
- package/dist/lint/index.d.ts +25 -0
- package/dist/lint/index.js +55 -0
- package/dist/lint/layout-estimate.d.ts +18 -0
- package/dist/lint/layout-estimate.js +165 -0
- package/dist/lint/linear-ease.d.ts +2 -0
- package/dist/lint/linear-ease.js +14 -0
- package/dist/lint/long-hold.d.ts +9 -0
- package/dist/lint/long-hold.js +52 -0
- package/dist/lint/min-hold.d.ts +4 -0
- package/dist/lint/min-hold.js +10 -0
- package/dist/lint/motion-craft.d.ts +18 -0
- package/dist/lint/motion-craft.js +112 -0
- package/dist/lint/narration-pace.d.ts +6 -0
- package/dist/lint/narration-pace.js +35 -0
- package/dist/lint/overlays.d.ts +9 -0
- package/dist/lint/overlays.js +64 -0
- package/dist/lint/reading-time.d.ts +5 -0
- package/dist/lint/reading-time.js +17 -0
- package/dist/lint/safe-zone-9x16.d.ts +11 -0
- package/dist/lint/safe-zone-9x16.js +101 -0
- package/dist/lint/text-floor.d.ts +8 -0
- package/dist/lint/text-floor.js +34 -0
- package/dist/lint/transition-implemented.d.ts +3 -0
- package/dist/lint/transition-implemented.js +15 -0
- package/dist/lint/transition-length.d.ts +4 -0
- package/dist/lint/transition-length.js +10 -0
- package/dist/lint/type-grid.d.ts +30 -0
- package/dist/lint/type-grid.js +147 -0
- package/dist/lint/types.d.ts +41 -0
- package/dist/lint/types.js +1 -0
- package/dist/lint/words-on-screen.d.ts +5 -0
- package/dist/lint/words-on-screen.js +24 -0
- package/dist/manifest.d.ts +53 -0
- package/dist/manifest.js +55 -0
- package/dist/render-manifest.d.ts +21 -0
- package/dist/render-manifest.js +7 -0
- package/dist/review/review.d.ts +15 -0
- package/dist/review/review.js +56 -0
- package/dist/templates/discover-check.d.ts +11 -0
- package/dist/templates/discover-check.js +36 -0
- package/dist/templates/discover.d.ts +37 -0
- package/dist/templates/discover.js +63 -0
- package/dist/templates/expand.d.ts +17 -0
- package/dist/templates/expand.js +184 -0
- package/dist/templates/fixtures.d.ts +4 -0
- package/dist/templates/fixtures.js +63 -0
- package/dist/templates/index.d.ts +8 -0
- package/dist/templates/index.js +6 -0
- package/dist/templates/instantiate.d.ts +43 -0
- package/dist/templates/instantiate.js +160 -0
- package/dist/templates/load.d.ts +16 -0
- package/dist/templates/load.js +122 -0
- package/dist/templates/pacing.d.ts +21 -0
- package/dist/templates/pacing.js +61 -0
- package/dist/templates/search.d.ts +64 -0
- package/dist/templates/search.js +129 -0
- package/dist/templates/semver.d.ts +2 -0
- package/dist/templates/semver.js +12 -0
- package/dist/templates/slots.d.ts +8 -0
- package/dist/templates/slots.js +116 -0
- package/dist/templates/style.d.ts +25 -0
- package/dist/templates/style.js +56 -0
- package/dist/templates/types.d.ts +29 -0
- package/dist/templates/types.js +1 -0
- package/dist/templates/validate.d.ts +17 -0
- package/dist/templates/validate.js +203 -0
- package/dist/timing.d.ts +38 -0
- package/dist/timing.js +88 -0
- package/dist/validate-full.d.ts +3 -0
- package/dist/validate-full.js +132 -0
- package/dist/verify.d.ts +32 -0
- package/dist/verify.js +180 -0
- package/dist/versions.d.ts +26 -0
- package/dist/versions.js +74 -0
- package/package.json +40 -3
|
@@ -0,0 +1,491 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Narration mixdown: a voice TimingManifest's absolute scene starts become an
|
|
3
|
+
* ffmpeg filter graph that lays per-scene wavs under a silent render.
|
|
4
|
+
*
|
|
5
|
+
* Each scene wav lives at `<audioDir>/<id>.wav` (the `mk voice` layout: the
|
|
6
|
+
* manifest's sibling `audio/` directory). Gapped scenes mix with one `adelay`
|
|
7
|
+
* per clip into `amix`; exactly contiguous scenes (the voice-manifest norm)
|
|
8
|
+
* concatenate without delay. One clip needs no mixer at all.
|
|
9
|
+
*
|
|
10
|
+
* All builders are pure and deterministic: the same clips always produce the
|
|
11
|
+
* same bytes modulo the encoder. Nothing here spawns ffmpeg; `mixAtDeliveryLevel`
|
|
12
|
+
* takes an injected runner (the CLI passes `session.probe("ffmpeg", …)`) and
|
|
13
|
+
* then normalizes the mixed file to delivery level. Missing wavs fail closed
|
|
14
|
+
* via `missingClips` — never a silent fallback when mixing was requested.
|
|
15
|
+
*/
|
|
16
|
+
import { basename, isAbsolute, join, relative, sep } from "node:path";
|
|
17
|
+
import { parseMusicBed, TimingManifestSchema } from "@anvpx/motion-schema";
|
|
18
|
+
import { MIX_TRUE_PEAK_DBTP, SIDECHAIN_ATTACK_MS, SIDECHAIN_RATIO, SIDECHAIN_RELEASE_MS, SIDECHAIN_THRESHOLD, seamlessLoopFor, verifyMusicIdentity, } from "./music.js";
|
|
19
|
+
/** Sibling directory of a voice manifest holding `<scene-id>.wav` files. */
|
|
20
|
+
export const AUDIO_DIR_NAME = "audio";
|
|
21
|
+
/** Wav path for one scene id inside the manifest's audio directory. */
|
|
22
|
+
export const wavPathFor = (audioDir, id) => join(audioDir, `${id}.wav`);
|
|
23
|
+
/** Manifest scenes in order, each resolved to its wav path. */
|
|
24
|
+
export const clipsFromManifest = (manifest, audioDir) => manifest.scenes.map((scene) => ({
|
|
25
|
+
id: scene.id,
|
|
26
|
+
start: scene.start,
|
|
27
|
+
duration: scene.duration,
|
|
28
|
+
wav: wavPathFor(audioDir, scene.id),
|
|
29
|
+
}));
|
|
30
|
+
/** Clips whose wav is absent; non-empty means refuse to mix (fail closed). */
|
|
31
|
+
export const missingClips = (clips, exists) => clips.filter((clip) => !exists(clip.wav));
|
|
32
|
+
/** Whole seconds to integer ffmpeg milliseconds; refuses nonsense. */
|
|
33
|
+
export const toDelayMs = (seconds) => {
|
|
34
|
+
if (!Number.isFinite(seconds) || seconds < 0) {
|
|
35
|
+
throw new Error(`invalid narration start ${seconds}: must be finite and >= 0`);
|
|
36
|
+
}
|
|
37
|
+
return Math.round(seconds * 1000);
|
|
38
|
+
};
|
|
39
|
+
/** Millisecond tolerance: float starts that survived rounding still join. */
|
|
40
|
+
const CONTIGUITY_EPSILON_MS = 1;
|
|
41
|
+
/** True when every scene starts where the previous one ends (voice norm). */
|
|
42
|
+
export const isContiguous = (scenes) => scenes.every((scene, index) => {
|
|
43
|
+
if (index === 0)
|
|
44
|
+
return true;
|
|
45
|
+
const prev = scenes[index - 1];
|
|
46
|
+
if (prev === undefined)
|
|
47
|
+
return false;
|
|
48
|
+
return (Math.abs(toDelayMs(scene.start) - toDelayMs(prev.start + prev.duration)) <=
|
|
49
|
+
CONTIGUITY_EPSILON_MS);
|
|
50
|
+
});
|
|
51
|
+
const delayed = (input, ms, label) => `[${input}:a]adelay=${ms}:all=1[${label}]`;
|
|
52
|
+
/**
|
|
53
|
+
* Deterministic `-filter_complex` graph mixing the clips onto `[aout]`.
|
|
54
|
+
* `adelay` takes `ms:all=1` so mono and stereo wavs delay every channel alike;
|
|
55
|
+
* `amix` runs `normalize=0` because narrated scenes never overlap, so levels
|
|
56
|
+
* stay exactly as synthesized instead of being divided by the input count.
|
|
57
|
+
*/
|
|
58
|
+
export const buildFilterGraph = (clips) => narrationGraph(clips, "aout");
|
|
59
|
+
const narrationGraph = (clips, outLabel) => {
|
|
60
|
+
if (clips.length === 0)
|
|
61
|
+
throw new Error("mixdown needs at least one narration clip");
|
|
62
|
+
if (clips.length === 1) {
|
|
63
|
+
const only = clips[0];
|
|
64
|
+
if (only === undefined)
|
|
65
|
+
throw new Error("mixdown needs at least one narration clip");
|
|
66
|
+
return `${delayed(1, toDelayMs(only.start), outLabel)}`;
|
|
67
|
+
}
|
|
68
|
+
if (isContiguous(clips)) {
|
|
69
|
+
const inputs = clips.map((_, index) => `[${index + 1}:a]`).join("");
|
|
70
|
+
return `${inputs}concat=n=${clips.length}:v=0:a=1[${outLabel}]`;
|
|
71
|
+
}
|
|
72
|
+
const labels = clips.map((_, index) => `a${index + 1}`);
|
|
73
|
+
const delays = clips
|
|
74
|
+
.map((clip, index) => delayed(index + 1, toDelayMs(clip.start), labels[index]))
|
|
75
|
+
.join(";");
|
|
76
|
+
const mixed = labels.map((label) => `[${label}]`).join("");
|
|
77
|
+
return `${delays};${mixed}amix=inputs=${clips.length}:normalize=0[${outLabel}]`;
|
|
78
|
+
};
|
|
79
|
+
/**
|
|
80
|
+
* Deterministic ffmpeg argv: silent video plus one `-i` per wav, video passed
|
|
81
|
+
* through (`-c:v copy`), narration mixed to aac, `-shortest` trimming any
|
|
82
|
+
* sub-frame tail so the output never outruns the picture.
|
|
83
|
+
*/
|
|
84
|
+
export const buildMixArgs = (videoIn, clips, out) => [
|
|
85
|
+
"-y",
|
|
86
|
+
"-i",
|
|
87
|
+
videoIn,
|
|
88
|
+
...clips.flatMap((clip) => ["-i", clip.wav]),
|
|
89
|
+
"-filter_complex",
|
|
90
|
+
buildFilterGraph(clips),
|
|
91
|
+
"-map",
|
|
92
|
+
"0:v:0",
|
|
93
|
+
"-map",
|
|
94
|
+
"[aout]",
|
|
95
|
+
"-c:v",
|
|
96
|
+
"copy",
|
|
97
|
+
"-c:a",
|
|
98
|
+
"aac",
|
|
99
|
+
"-movflags",
|
|
100
|
+
"+faststart",
|
|
101
|
+
"-shortest",
|
|
102
|
+
out,
|
|
103
|
+
];
|
|
104
|
+
/** Validates an unknown document as a mixable TimingManifest (fail closed). */
|
|
105
|
+
export const parseAudioManifest = (value) => {
|
|
106
|
+
const parsed = TimingManifestSchema.safeParse(value);
|
|
107
|
+
if (parsed.success)
|
|
108
|
+
return { ok: true, manifest: parsed.data };
|
|
109
|
+
return {
|
|
110
|
+
ok: false,
|
|
111
|
+
problems: parsed.error.issues.map((issue) => `${issue.path.join("/")}: ${issue.message}`),
|
|
112
|
+
};
|
|
113
|
+
};
|
|
114
|
+
/**
|
|
115
|
+
* Ducked bed level, re-exported from the pinned music single source
|
|
116
|
+
* (`./music.ts`, itself from the schema); narration stays intelligible
|
|
117
|
+
* over the bed at the qc-lufs pinned depth of -6.0206 dB.
|
|
118
|
+
*/
|
|
119
|
+
export { DEFAULT_MUSIC_VOLUME } from "./music.js";
|
|
120
|
+
/** Resolve the bed level through the schema guard; anything else fails closed. */
|
|
121
|
+
export const resolveMusicVolume = (music) => {
|
|
122
|
+
const parsed = parseMusicBed(music);
|
|
123
|
+
if (!parsed.ok) {
|
|
124
|
+
throw new Error(`invalid music bed: ${parsed.hint}`);
|
|
125
|
+
}
|
|
126
|
+
return parsed.bed.volume;
|
|
127
|
+
};
|
|
128
|
+
/**
|
|
129
|
+
* Bed wavs that are absent; non-empty means refuse to mix (fail closed).
|
|
130
|
+
* An absent bed (`undefined`) means narration-only — never a silent fallback.
|
|
131
|
+
*/
|
|
132
|
+
export const missingMusic = (music, exists) => {
|
|
133
|
+
if (music === undefined)
|
|
134
|
+
return [];
|
|
135
|
+
return exists(music.wav) ? [] : [music.wav];
|
|
136
|
+
};
|
|
137
|
+
/** Reject a bed that never passed the schema guard (fail closed, never silently mix). */
|
|
138
|
+
const requireValidBed = (music) => {
|
|
139
|
+
const parsed = parseMusicBed(music);
|
|
140
|
+
if (!parsed.ok) {
|
|
141
|
+
throw new Error(`invalid music bed: ${parsed.hint}`);
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
/** Timeline length covered by narration; the bed loops (`aloop`) then trims to it. */
|
|
145
|
+
const narrationTotal = (clips) => clips.reduce((max, clip) => Math.max(max, clip.start + clip.duration), 0);
|
|
146
|
+
/**
|
|
147
|
+
* Deterministic `-filter_complex` graph ducking a user CC0 bed under narration.
|
|
148
|
+
* Narration mixes onto `[narr]` (same rules as {@link buildFilterGraph}), splits
|
|
149
|
+
* via `asplit` into `[nkey]` (sidechain key) and `[nmix]` (audible mix input);
|
|
150
|
+
* the bed loops to the narration length, ducks under `[nkey]` via
|
|
151
|
+
* `sidechaincompress`, then joins `[nmix]` with `amix … normalize=0` onto
|
|
152
|
+
* `[aout]`. Each label is defined once and consumed once as input, so ffmpeg
|
|
153
|
+
* never sees a doubly-consumed `[narr]`.
|
|
154
|
+
*
|
|
155
|
+
* The bed is re-validated through the schema single source first: a raw
|
|
156
|
+
* `{ wav }` that never passed `parseMusicBed`/`validateMusicBed` (traversal,
|
|
157
|
+
* non-wav, bad level) throws here instead of reaching ffmpeg.
|
|
158
|
+
*/
|
|
159
|
+
export const buildMusicFilterGraph = (clips, music) => {
|
|
160
|
+
if (clips.length === 0)
|
|
161
|
+
throw new Error("mixdown needs at least one narration clip");
|
|
162
|
+
requireValidBed(music);
|
|
163
|
+
const volume = resolveMusicVolume(music);
|
|
164
|
+
const musicIndex = clips.length + 1;
|
|
165
|
+
const narr = narrationGraph(clips, "narr");
|
|
166
|
+
const total = toDelayMs(narrationTotal(clips)) / 1000;
|
|
167
|
+
const sidechain = `sidechaincompress=threshold=${SIDECHAIN_THRESHOLD}:ratio=${SIDECHAIN_RATIO}` +
|
|
168
|
+
`:attack=${SIDECHAIN_ATTACK_MS}:release=${SIDECHAIN_RELEASE_MS}`;
|
|
169
|
+
return (`[${musicIndex}:a]volume=${volume},aloop=loop=-1:size=2e9,atrim=duration=${total}[mbed];` +
|
|
170
|
+
`${narr};[narr]asplit[nkey][nmix];` +
|
|
171
|
+
`[mbed][nkey]${sidechain}[ducked];` +
|
|
172
|
+
`[nmix][ducked]amix=inputs=2:normalize=0[aout]`);
|
|
173
|
+
};
|
|
174
|
+
/**
|
|
175
|
+
* AU-01 frozen approved mix layout (single authority for preview + render).
|
|
176
|
+
* After the approval entry-gate, the CLI copies every narration wav plus the
|
|
177
|
+
* bed wav into one frozen directory and mixes from those copies, so a
|
|
178
|
+
* mutation of the mutable sources mid-run (even restored before the post-check,
|
|
179
|
+
* ABA) cannot change the bytes ffmpeg consumes.
|
|
180
|
+
*
|
|
181
|
+
* The frozen name is `<role>-<1-based ordinal>-<source basename>`
|
|
182
|
+
* (`narration-2-intro.wav`, `music-1-intro.wav`). Role + ordinal make the map
|
|
183
|
+
* injective, so two approved sources that share a basename - a narration wav
|
|
184
|
+
* and the bed, two narration directories, Windows case aliases - never share
|
|
185
|
+
* one destination (ADR-0008 D1). The name holds no machine path, timestamp or
|
|
186
|
+
* absolute path, and the ordinal is the approved `mix.clips` order, which is
|
|
187
|
+
* itself hashed, so a current approval always maps to the same names.
|
|
188
|
+
*
|
|
189
|
+
* `frozenWavFor` maps one source wav to its frozen copy;
|
|
190
|
+
* `frozenClips`/`frozenMusic` rewrite clip/bed inputs to the frozen copies
|
|
191
|
+
* while preserving id/start/duration/volume.
|
|
192
|
+
*/
|
|
193
|
+
export const frozenWavFor = (frozenDir, role, ordinal, wav) => {
|
|
194
|
+
if (!Number.isInteger(ordinal) || ordinal < 1) {
|
|
195
|
+
throw new Error(`invalid frozen ordinal ${ordinal}: must be an integer >= 1`);
|
|
196
|
+
}
|
|
197
|
+
return join(frozenDir, `${role}-${ordinal}-${basename(wav)}`);
|
|
198
|
+
};
|
|
199
|
+
export const frozenClips = (clips, frozenDir) => clips.map((clip, index) => ({
|
|
200
|
+
...clip,
|
|
201
|
+
wav: frozenWavFor(frozenDir, "narration", index + 1, clip.wav),
|
|
202
|
+
}));
|
|
203
|
+
export const frozenMusic = (music, frozenDir) => ({
|
|
204
|
+
...music,
|
|
205
|
+
wav: frozenWavFor(frozenDir, "music", 1, music.wav),
|
|
206
|
+
});
|
|
207
|
+
/**
|
|
208
|
+
* T-AU-01a current-only identity bed: maps a pinned `MusicIdentity`
|
|
209
|
+
* (`relativePath`, `sha256`, `volume`, `licenceEvidenceSha256`) to the
|
|
210
|
+
* absolute wav the mix graph consumes. The identity itself stays
|
|
211
|
+
* project-relative with forward slashes (no machine paths, no timestamps);
|
|
212
|
+
* only the resolved `wav` is absolute and only the frozen copy is consumed
|
|
213
|
+
* downstream. Rejects absolute/escaping/non-wav refs, malformed hashes, and
|
|
214
|
+
* out-of-range volumes (fail closed, never a silent fallback). Defense in
|
|
215
|
+
* depth: `wav` must live inside `frozenDir` (prefix + relative assert), so an
|
|
216
|
+
* absolute path outside the frozen layout never reaches ffmpeg.
|
|
217
|
+
*/
|
|
218
|
+
export const musicBedFromIdentity = (identity, frozenDir, wav) => {
|
|
219
|
+
const problems = verifyMusicIdentity(identity, identity.sha256, identity.licenceEvidenceSha256);
|
|
220
|
+
const shape = problems.filter((problem) => !problem.includes("unreadable"));
|
|
221
|
+
// Self-consistency uses the identity digests, so only shape problems apply
|
|
222
|
+
// here; content drift vs frozen bytes is checked by `verifyFrozenMusic`.
|
|
223
|
+
const mapping = shape.filter((problem) => problem.includes("project-local") || problem.includes("64 hex") || problem.includes("volume"));
|
|
224
|
+
if (mapping.length > 0) {
|
|
225
|
+
throw new Error(`invalid music identity: ${mapping.join("; ")}`);
|
|
226
|
+
}
|
|
227
|
+
if (typeof wav !== "string" || !wav.endsWith(".wav") || wav.includes("..")) {
|
|
228
|
+
throw new Error("invalid music identity: resolved wav must be a local .wav without traversal");
|
|
229
|
+
}
|
|
230
|
+
if (typeof frozenDir !== "string" || frozenDir.length === 0 || !isAbsolute(frozenDir)) {
|
|
231
|
+
throw new Error("invalid music identity: frozenDir must be an absolute directory");
|
|
232
|
+
}
|
|
233
|
+
if (!isAbsolute(wav)) {
|
|
234
|
+
throw new Error("invalid music identity: resolved wav must be absolute inside frozenDir");
|
|
235
|
+
}
|
|
236
|
+
// Defense in depth: relative escape check plus explicit frozenDir prefix assert.
|
|
237
|
+
const rel = relative(frozenDir, wav);
|
|
238
|
+
if (rel === "" || rel.startsWith("..") || isAbsolute(rel)) {
|
|
239
|
+
throw new Error("invalid music identity: resolved wav must be inside frozenDir");
|
|
240
|
+
}
|
|
241
|
+
const prefix = frozenDir.endsWith(sep) ? frozenDir : frozenDir + sep;
|
|
242
|
+
if (!wav.startsWith(prefix)) {
|
|
243
|
+
throw new Error("invalid music identity: resolved wav must be inside frozenDir");
|
|
244
|
+
}
|
|
245
|
+
return { wav, volume: identity.volume };
|
|
246
|
+
};
|
|
247
|
+
/**
|
|
248
|
+
* Verifies frozen bytes against the pinned identity. Non-empty means refuse
|
|
249
|
+
* to mix (fail closed): a mutated bed sha — including an ABA restore of the
|
|
250
|
+
* mutable source after the freeze — still fails unless the frozen copy
|
|
251
|
+
* consumed by ffmpeg matches the identity.
|
|
252
|
+
*/
|
|
253
|
+
export const verifyFrozenMusic = (identity, frozenBedSha256, frozenLicenceSha256) => verifyMusicIdentity(identity, frozenBedSha256, frozenLicenceSha256);
|
|
254
|
+
/**
|
|
255
|
+
* Seamless-loop trim for a bed of `bedDuration` seconds over narration
|
|
256
|
+
* `clips` (phase math in `./music.ts`): repeats via `aloop` then trims to
|
|
257
|
+
* the narration length, reporting the loop-boundary phase error.
|
|
258
|
+
*/
|
|
259
|
+
export const musicLoopFor = (bedDuration, clips) => {
|
|
260
|
+
if (clips.length === 0)
|
|
261
|
+
throw new Error("mixdown needs at least one narration clip");
|
|
262
|
+
return seamlessLoopFor(bedDuration, narrationTotal(clips));
|
|
263
|
+
};
|
|
264
|
+
/**
|
|
265
|
+
* Deterministic ffmpeg argv with a ducked bed: silent video plus one `-i`
|
|
266
|
+
* per narration wav plus the bed wav last, video passed through
|
|
267
|
+
* (`-c:v copy`), bed ducked under narration and mixed to aac, `-shortest`.
|
|
268
|
+
*/
|
|
269
|
+
export const buildMusicMixArgs = (videoIn, clips, music, out) => [
|
|
270
|
+
"-y",
|
|
271
|
+
"-i",
|
|
272
|
+
videoIn,
|
|
273
|
+
...clips.flatMap((clip) => ["-i", clip.wav]),
|
|
274
|
+
"-i",
|
|
275
|
+
music.wav,
|
|
276
|
+
"-filter_complex",
|
|
277
|
+
buildMusicFilterGraph(clips, music),
|
|
278
|
+
"-map",
|
|
279
|
+
"0:v:0",
|
|
280
|
+
"-map",
|
|
281
|
+
"[aout]",
|
|
282
|
+
"-c:v",
|
|
283
|
+
"copy",
|
|
284
|
+
"-c:a",
|
|
285
|
+
"aac",
|
|
286
|
+
"-movflags",
|
|
287
|
+
"+faststart",
|
|
288
|
+
"-shortest",
|
|
289
|
+
out,
|
|
290
|
+
];
|
|
291
|
+
/**
|
|
292
|
+
* Delivery loudness (fixed, no CLI flag). Kokoro narration lands near -26 LUFS,
|
|
293
|
+
* so the mix would otherwise fail every verified platform policy; the mixed
|
|
294
|
+
* bed+voice file is normalized once here so preview, draft and render all
|
|
295
|
+
* deliver at the same level.
|
|
296
|
+
*
|
|
297
|
+
* `TARGET_TRUE_PEAK_DB` is loudnorm's *aim*, 0.5 dB under the platform ceiling
|
|
298
|
+
* so the aac encode keeps its headroom; the delivery ceiling itself stays the
|
|
299
|
+
* verified policy's -1 dBTP (`MIX_TRUE_PEAK_DBTP`), which is what gates a pass.
|
|
300
|
+
*/
|
|
301
|
+
export const TARGET_LUFS = -14;
|
|
302
|
+
export const TARGET_TRUE_PEAK_DB = -1.5;
|
|
303
|
+
export const TARGET_LRA = 11;
|
|
304
|
+
/** Within this drift the mix already sits at delivery level (idempotence guard). */
|
|
305
|
+
export const AT_TARGET_TOLERANCE_LU = 0.5;
|
|
306
|
+
/** Normalizing passes per mix: the first, plus at most one corrective pass. */
|
|
307
|
+
export const MAX_NORMALIZE_PASSES = 2;
|
|
308
|
+
/** Pass 1 filter: prints the measurement json on stderr and passes audio through. */
|
|
309
|
+
export const LOUDNORM_MEASURE_FILTER = "loudnorm=print_format=json";
|
|
310
|
+
/** Parses one numeric loudnorm field; `-inf`/`-nan`/absent are not numbers. */
|
|
311
|
+
const numeric = (value) => {
|
|
312
|
+
if (typeof value === "number")
|
|
313
|
+
return Number.isFinite(value) ? value : undefined;
|
|
314
|
+
if (typeof value !== "string")
|
|
315
|
+
return undefined;
|
|
316
|
+
const parsed = Number.parseFloat(value);
|
|
317
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
318
|
+
};
|
|
319
|
+
/**
|
|
320
|
+
* Reads the last `{…}` block loudnorm prints. Unreadable or silent input
|
|
321
|
+
* (integrated `-inf`) yields undefined: an unmeasurable mix is never
|
|
322
|
+
* "already at target".
|
|
323
|
+
*/
|
|
324
|
+
export const parseLoudnessMeasurement = (output) => {
|
|
325
|
+
const blocks = output.match(/\{[^{}]*\}/g);
|
|
326
|
+
const block = blocks?.[blocks.length - 1];
|
|
327
|
+
if (block === undefined)
|
|
328
|
+
return undefined;
|
|
329
|
+
let raw;
|
|
330
|
+
try {
|
|
331
|
+
raw = JSON.parse(block);
|
|
332
|
+
}
|
|
333
|
+
catch {
|
|
334
|
+
return undefined;
|
|
335
|
+
}
|
|
336
|
+
if (typeof raw !== "object" || raw === null)
|
|
337
|
+
return undefined;
|
|
338
|
+
const record = raw;
|
|
339
|
+
const integratedLufs = numeric(record.input_i);
|
|
340
|
+
const truePeakDb = numeric(record.input_tp);
|
|
341
|
+
const lra = numeric(record.input_lra);
|
|
342
|
+
const threshold = numeric(record.input_thresh);
|
|
343
|
+
const offset = numeric(record.target_offset);
|
|
344
|
+
if (integratedLufs === undefined ||
|
|
345
|
+
truePeakDb === undefined ||
|
|
346
|
+
lra === undefined ||
|
|
347
|
+
threshold === undefined ||
|
|
348
|
+
offset === undefined) {
|
|
349
|
+
return undefined;
|
|
350
|
+
}
|
|
351
|
+
return { integratedLufs, truePeakDb, lra, threshold, offset };
|
|
352
|
+
};
|
|
353
|
+
/**
|
|
354
|
+
* True when the mix still needs a normalizing pass: the level is off by more
|
|
355
|
+
* than the 0.5 LU guard, or the peaks sit above the delivery ceiling (-1 dBTP,
|
|
356
|
+
* the verified platform policy). loudnorm *aims* at {@link TARGET_TRUE_PEAK_DB}
|
|
357
|
+
* to leave aac headroom, but its dynamic fallback can settle slightly above the
|
|
358
|
+
* aim while holding the level; that is delivered, not re-normalized, which is
|
|
359
|
+
* what keeps a second run a no-op.
|
|
360
|
+
*/
|
|
361
|
+
export const needsNormalization = (measurement) => Math.abs(measurement.integratedLufs - TARGET_LUFS) > AT_TARGET_TOLERANCE_LU ||
|
|
362
|
+
measurement.truePeakDb > MIX_TRUE_PEAK_DBTP;
|
|
363
|
+
/** Pass 2 filter: one linear gain from the pass-1 measurements plus the target. */
|
|
364
|
+
export const buildLoudnessNormalizeFilter = (measurement) => {
|
|
365
|
+
const fixed = (value) => value.toFixed(2);
|
|
366
|
+
return (`loudnorm=linear=true:I=${TARGET_LUFS}:TP=${TARGET_TRUE_PEAK_DB}:LRA=${TARGET_LRA}` +
|
|
367
|
+
`:measured_I=${fixed(measurement.integratedLufs)}` +
|
|
368
|
+
`:measured_TP=${fixed(measurement.truePeakDb)}` +
|
|
369
|
+
`:measured_LRA=${fixed(measurement.lra)}` +
|
|
370
|
+
`:measured_thresh=${fixed(measurement.threshold)}` +
|
|
371
|
+
`:offset=${fixed(measurement.offset)}`);
|
|
372
|
+
};
|
|
373
|
+
/** Pass 1 argv: measure the mixed file; `-vn` keeps the video stream undecoded. */
|
|
374
|
+
export const buildLoudnessMeasureArgs = (input) => [
|
|
375
|
+
"-hide_banner",
|
|
376
|
+
"-nostats",
|
|
377
|
+
"-v",
|
|
378
|
+
"info",
|
|
379
|
+
"-vn",
|
|
380
|
+
"-i",
|
|
381
|
+
input,
|
|
382
|
+
"-filter:a",
|
|
383
|
+
LOUDNORM_MEASURE_FILTER,
|
|
384
|
+
"-f",
|
|
385
|
+
"null",
|
|
386
|
+
"-",
|
|
387
|
+
];
|
|
388
|
+
/**
|
|
389
|
+
* Pass 2 argv: rewrite `input` at the target into `out`, copying the video
|
|
390
|
+
* stream (`-c:v copy`, never decoded). loudnorm's own `linear` fallback keeps
|
|
391
|
+
* peaks under the ceiling when a single gain would overshoot.
|
|
392
|
+
*/
|
|
393
|
+
export const buildLoudnessNormalizeArgs = (input, measurement, out) => [
|
|
394
|
+
"-hide_banner",
|
|
395
|
+
"-nostats",
|
|
396
|
+
"-y",
|
|
397
|
+
"-v",
|
|
398
|
+
"error",
|
|
399
|
+
"-i",
|
|
400
|
+
input,
|
|
401
|
+
"-filter:a",
|
|
402
|
+
buildLoudnessNormalizeFilter(measurement),
|
|
403
|
+
"-map",
|
|
404
|
+
"0:v:0",
|
|
405
|
+
"-map",
|
|
406
|
+
"0:a:0",
|
|
407
|
+
"-c:v",
|
|
408
|
+
"copy",
|
|
409
|
+
"-c:a",
|
|
410
|
+
"aac",
|
|
411
|
+
"-b:a",
|
|
412
|
+
"192k",
|
|
413
|
+
"-movflags",
|
|
414
|
+
"+faststart",
|
|
415
|
+
out,
|
|
416
|
+
];
|
|
417
|
+
const firstLine = (text) => text.split(/\r?\n/).find((line) => line.trim() !== "") ?? "";
|
|
418
|
+
/**
|
|
419
|
+
* Mixes to `plan.out`, then normalizes that same file to delivery level.
|
|
420
|
+
*
|
|
421
|
+
* Silent films stay silent (no clips → no ffmpeg at all) and an already-levelled
|
|
422
|
+
* mix skips pass 2, so normalizing twice never moves the level. One linear gain
|
|
423
|
+
* cannot always reach the target under the peak ceiling (loudnorm then runs its
|
|
424
|
+
* dynamic fallback and can land ~0.8 LU short), so the level is re-measured
|
|
425
|
+
* after each pass and one corrective pass may follow: the delivered file sits
|
|
426
|
+
* inside the 0.5 LU guard whenever the material allows it, which is what keeps
|
|
427
|
+
* a second normalizing run a no-op. Unmeasurable audio is left exactly as the
|
|
428
|
+
* mix produced it and reported in `problems`; a failed mix or a failed loudnorm
|
|
429
|
+
* pass fails delivery.
|
|
430
|
+
*/
|
|
431
|
+
export const mixAtDeliveryLevel = async (run, plan) => {
|
|
432
|
+
const skipped = (problems = [], measurement = null) => ({ ok: true, out: plan.out, normalized: false, measurement, problems });
|
|
433
|
+
const failed = (problems, measurement = null) => ({ ok: false, out: plan.out, normalized: false, measurement, problems });
|
|
434
|
+
const measure = async () => {
|
|
435
|
+
const probed = await run(buildLoudnessMeasureArgs(plan.out));
|
|
436
|
+
return probed.code === 0
|
|
437
|
+
? parseLoudnessMeasurement(`${probed.stdout}\n${probed.stderr}`)
|
|
438
|
+
: undefined;
|
|
439
|
+
};
|
|
440
|
+
if (plan.clips.length === 0)
|
|
441
|
+
return skipped();
|
|
442
|
+
const mixArgs = plan.music === undefined
|
|
443
|
+
? buildMixArgs(plan.videoIn, plan.clips, plan.out)
|
|
444
|
+
: buildMusicMixArgs(plan.videoIn, plan.clips, plan.music, plan.out);
|
|
445
|
+
const mixed = await run(mixArgs);
|
|
446
|
+
if (mixed.code !== 0)
|
|
447
|
+
return failed([`mix failed: ${firstLine(mixed.stderr)}`]);
|
|
448
|
+
let measurement = await measure();
|
|
449
|
+
if (measurement === undefined)
|
|
450
|
+
return skipped(["loudness unmeasured: no loudnorm json"]);
|
|
451
|
+
if (!needsNormalization(measurement))
|
|
452
|
+
return skipped([], measurement);
|
|
453
|
+
const normalized = `${plan.out}.loudnorm.mp4`;
|
|
454
|
+
for (let pass = 0; pass < MAX_NORMALIZE_PASSES; pass += 1) {
|
|
455
|
+
const current = measurement;
|
|
456
|
+
const encoded = await run(buildLoudnessNormalizeArgs(plan.out, current, normalized));
|
|
457
|
+
if (encoded.code !== 0) {
|
|
458
|
+
return failed([`loudnorm failed: ${firstLine(encoded.stderr) || "non-zero exit"}`], current);
|
|
459
|
+
}
|
|
460
|
+
try {
|
|
461
|
+
await plan.swap(normalized, plan.out);
|
|
462
|
+
}
|
|
463
|
+
catch (error) {
|
|
464
|
+
return failed([`cannot replace the mix: ${error.message}`], current);
|
|
465
|
+
}
|
|
466
|
+
const confirmed = await measure();
|
|
467
|
+
if (confirmed === undefined) {
|
|
468
|
+
return {
|
|
469
|
+
ok: true,
|
|
470
|
+
out: plan.out,
|
|
471
|
+
normalized: true,
|
|
472
|
+
measurement: current,
|
|
473
|
+
problems: ["loudness unconfirmed after normalizing"],
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
measurement = confirmed;
|
|
477
|
+
if (!needsNormalization(measurement)) {
|
|
478
|
+
return { ok: true, out: plan.out, normalized: true, measurement, problems: [] };
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
return {
|
|
482
|
+
ok: true,
|
|
483
|
+
out: plan.out,
|
|
484
|
+
normalized: true,
|
|
485
|
+
measurement,
|
|
486
|
+
problems: [
|
|
487
|
+
`loudness ${measurement.integratedLufs.toFixed(2)} LUFS after ` +
|
|
488
|
+
`${MAX_NORMALIZE_PASSES} normalizing passes (target ${TARGET_LUFS})`,
|
|
489
|
+
],
|
|
490
|
+
};
|
|
491
|
+
};
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pinned music-bed mix constants (T-AU-01a, current-only).
|
|
3
|
+
*
|
|
4
|
+
* Single measurement source: the qc-lufs pack proof.
|
|
5
|
+
* LUFS target comes from the verified `youtube` platform policy
|
|
6
|
+
* (`lufsTarget: -14`, `truePeakDb: -1`, `verified: true`); the ducked bed
|
|
7
|
+
* depth is the static schema level `DEFAULT_MUSIC_VOLUME = 0.5`, measured
|
|
8
|
+
* on this machine as `20*log10(0.5) = -6.0206 dB`, before the frozen
|
|
9
|
+
* sidechain stage. Tolerances mirror the `qc-lufs` measurement seam
|
|
10
|
+
* (`LOUDNESS_TOLERANCE_LU = 1`, `TRUE_PEAK_TOLERANCE_DB = 0.5`).
|
|
11
|
+
* Nothing here runs ffmpeg; measurement stays in the verify chain (LC-04).
|
|
12
|
+
*/
|
|
13
|
+
/** Ducked bed level, re-exported from the schema single source. */
|
|
14
|
+
export declare const DEFAULT_MUSIC_VOLUME: number;
|
|
15
|
+
/** Pinned integrated-loudness target for the mixed output (qc-lufs). */
|
|
16
|
+
export declare const MIX_LUFS_TARGET = -14;
|
|
17
|
+
/** Pinned true-peak ceiling for the mixed output (qc-lufs). */
|
|
18
|
+
export declare const MIX_TRUE_PEAK_DBTP = -1;
|
|
19
|
+
/** Accepted drift for integrated loudness vs the pinned target. */
|
|
20
|
+
export declare const MIX_LOUDNESS_TOLERANCE_LU = 1;
|
|
21
|
+
/** Accepted excess for true peak above the pinned ceiling. */
|
|
22
|
+
export declare const MIX_TRUE_PEAK_TOLERANCE_DB = 0.5;
|
|
23
|
+
/** Static bed gain of the default ducked level, dB (measured receipt). */
|
|
24
|
+
export declare const DUCKING_DB = -6.0206;
|
|
25
|
+
/** Frozen sidechain stage (pinned by mix.test.ts, qc-lufs §Ducking dB). */
|
|
26
|
+
export declare const SIDECHAIN_THRESHOLD = 0.03;
|
|
27
|
+
export declare const SIDECHAIN_RATIO = 4;
|
|
28
|
+
export declare const SIDECHAIN_ATTACK_MS = 200;
|
|
29
|
+
export declare const SIDECHAIN_RELEASE_MS = 500;
|
|
30
|
+
/** Only CC0 beds are supported (licence gate, current-only). */
|
|
31
|
+
export declare const MUSIC_LICENCE = "CC0-1.0";
|
|
32
|
+
/** sha256 of zero bytes: a silent/empty wav can never qualify as a bed. */
|
|
33
|
+
export declare const EMPTY_SHA256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
|
|
34
|
+
/**
|
|
35
|
+
* Current-only music identity: project-relative bed path plus the frozen
|
|
36
|
+
* content hashes the approval gate hashed. `relativePath` is always
|
|
37
|
+
* forward-slash project-relative (never absolute, never a machine path);
|
|
38
|
+
* `sha256` is the bed wav bytes; `licenceEvidenceSha256` is the CC0
|
|
39
|
+
* evidence receipt bytes (null only when no receipt was supplied).
|
|
40
|
+
*/
|
|
41
|
+
export interface MusicIdentity {
|
|
42
|
+
readonly relativePath: string;
|
|
43
|
+
readonly sha256: string;
|
|
44
|
+
readonly volume: number;
|
|
45
|
+
readonly licenceEvidenceSha256: string | null;
|
|
46
|
+
}
|
|
47
|
+
/** Static gain of a bed level in dB; refuses nonsense (fail closed). */
|
|
48
|
+
export declare const musicGainDb: (volume: number) => number;
|
|
49
|
+
/** True when a level sits at the pinned ducking depth within 0.001 dB. */
|
|
50
|
+
export declare const isPinnedDuckingDepth: (volume: number) => boolean;
|
|
51
|
+
export interface SeamlessLoop {
|
|
52
|
+
readonly loops: number;
|
|
53
|
+
readonly trimDuration: number;
|
|
54
|
+
readonly phaseError: number;
|
|
55
|
+
readonly isSeamless: boolean;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Seamless-loop phase math: how many bed repeats cover `totalDuration`
|
|
59
|
+
* seconds and where the trim lands in the bed cycle. `phaseError` is the
|
|
60
|
+
* fractional cycle cut by the trim (0 = trim lands on the loop boundary,
|
|
61
|
+
* seamless); `loops` is `ceil(total / bed)`. Refuses nonsense (fail closed).
|
|
62
|
+
*/
|
|
63
|
+
export declare const seamlessLoopFor: (bedDuration: number, totalDuration: number) => SeamlessLoop;
|
|
64
|
+
/**
|
|
65
|
+
* Fail-closed identity check (current-only): malformed identity fields,
|
|
66
|
+
* a missing/unreadable bed digest, a mutated bed sha, or a drifted licence
|
|
67
|
+
* receipt each yield a problem. Empty means the frozen bytes match the
|
|
68
|
+
* identity. An ABA restore (mutate then restore the source) still passes
|
|
69
|
+
* only when the frozen copy consumed by the mix matches — the check reads
|
|
70
|
+
* the frozen digest, never the mutable source.
|
|
71
|
+
*/
|
|
72
|
+
export declare const verifyMusicIdentity: (identity: MusicIdentity, actualBedSha256: string | undefined, actualLicenceSha256: string | null | undefined) => string[];
|
|
73
|
+
export interface MixLoudnessAssessment {
|
|
74
|
+
readonly ok: boolean;
|
|
75
|
+
readonly problems: string[];
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Judges one integrated-loudness + true-peak measurement against the pinned
|
|
79
|
+
* qc-lufs constants. Mirrors the verify-chain tolerances; measurement itself
|
|
80
|
+
* stays in the CLI verify seam (LC-04).
|
|
81
|
+
*/
|
|
82
|
+
export declare const assessMixLoudness: (integratedLufs: number, truePeakDb: number) => MixLoudnessAssessment;
|