@kheleshikhi/studio-agent 0.8.0 → 0.9.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.
@@ -0,0 +1,648 @@
1
+ /**
2
+ * Shot-based episode production ("Level C").
3
+ *
4
+ * An episode is a small, versioned manifest (`kheleshikhi.episode.v1`): an
5
+ * ordered list of shots, each one presentation render of a Scene or an
6
+ * Experience, plus episode narration, music, timed sound and subtitles. The
7
+ * runner renders each shot under a content-addressed key, reuses every shot
8
+ * whose key is unchanged and whose cached MP4 still verifies, and assembles
9
+ * the shots into one MP4 with one episode audio mix (narration over a music
10
+ * bed ducked under it, plus each shot's own sounds), loudness normalised to a
11
+ * target, an SRT file and a QC report.
12
+ *
13
+ * What a shot key covers (and nothing else): the shot's render recipe, the
14
+ * subject's source hash(es), every dependency-record digest (exact Component,
15
+ * Behavior and Audio definitions), the runtime fingerprint, the sha256 of
16
+ * every sound the subject names, the output profile and the encoder
17
+ * settings. File modification times are never consulted: a cached shot is
18
+ * valid when its key matches and its MP4 hashes and probes as recorded.
19
+ *
20
+ * Everything here is a derivative (`canonical: false`): an episode never
21
+ * changes a Scene, an Experience or a Component, and never certifies one.
22
+ */
23
+ import { createHash } from "node:crypto";
24
+ import { CliError } from "./types.js";
25
+ import { canonicalJson, digestOf } from "./shot-provenance.js";
26
+ import { frameCountFor, MIX_SAMPLE_RATE, parseRecipe, RECIPE_KIND, RENDER_PROFILES } from "./video.js";
27
+ export const EPISODE_SCHEMA = "kheleshikhi.episode.v1";
28
+ export const EPISODE_MANIFEST_KIND = "kheleshikhi.episode-render.v1";
29
+ export const SHOT_CACHE_KIND = "kheleshikhi.episode-shot-cache.v1";
30
+ /** Bump when the shot render or its encode changes meaning: every key moves. */
31
+ export const SHOT_KEY_VERSION = 1;
32
+ export const EPISODE_LIMITS = { maxShots: 200, maxSeconds: 3600, maxAudioItems: 128, maxCues: 2000, maxText: 400 };
33
+ const IDENTIFIER = /^[A-Za-z][A-Za-z0-9_.-]{0,79}$/;
34
+ /** Server public ids (asset, revision): URL-safe base64 alphabet. */
35
+ const PUBLIC_ID = /^[A-Za-z0-9_-]{1,80}$/;
36
+ function publicId(value, name) {
37
+ if (typeof value !== "string" || !PUBLIC_ID.test(value))
38
+ invalid(`${name} must be a public id (letters, digits, _ -).`);
39
+ return value;
40
+ }
41
+ function invalid(message) {
42
+ throw new CliError(message, "EPISODE_INVALID", 2);
43
+ }
44
+ function object(value, name) {
45
+ if (!value || typeof value !== "object" || Array.isArray(value))
46
+ invalid(`${name} must be an object.`);
47
+ return value;
48
+ }
49
+ function only(value, name, allowed) {
50
+ const unknown = Object.keys(value).filter((key) => !allowed.includes(key));
51
+ if (unknown.length)
52
+ invalid(`${name} has unknown fields: ${unknown.sort().join(", ")}.`);
53
+ }
54
+ function number(value, name, minimum, maximum, fallback) {
55
+ if (value === undefined && fallback !== undefined)
56
+ return fallback;
57
+ if (typeof value !== "number" || !Number.isFinite(value) || value < minimum || value > maximum) {
58
+ invalid(`${name} must be a number from ${minimum} to ${maximum}.`);
59
+ }
60
+ return value;
61
+ }
62
+ function text(value, name, maximum = EPISODE_LIMITS.maxText) {
63
+ if (typeof value !== "string" || !value.trim() || value.length > maximum)
64
+ invalid(`${name} must be text of at most ${maximum} characters.`);
65
+ return value;
66
+ }
67
+ function identifier(value, name) {
68
+ if (typeof value !== "string" || !IDENTIFIER.test(value))
69
+ invalid(`${name} must be an identifier (letters, digits, _ . -).`);
70
+ return value;
71
+ }
72
+ /** Seconds that are a whole number of frames at `fps` (within a billionth of a frame). */
73
+ function wholeFrames(seconds, fps, name) {
74
+ const frames = seconds * fps;
75
+ if (Math.abs(frames - Math.round(frames)) > 1e-6)
76
+ invalid(`${name} must be a whole number of frames at ${fps} fps.`);
77
+ return Math.round(frames);
78
+ }
79
+ function relativePath(value, name) {
80
+ const file = text(value, name, 512);
81
+ if (file.includes("\0"))
82
+ invalid(`${name} is not a path.`);
83
+ return file;
84
+ }
85
+ function captions(value, name, allowShot) {
86
+ if (value === undefined)
87
+ return [];
88
+ if (!Array.isArray(value) || value.length > EPISODE_LIMITS.maxCues)
89
+ invalid(`${name} must be a list of at most ${EPISODE_LIMITS.maxCues} cues.`);
90
+ return value.map((raw, index) => {
91
+ const cue = object(raw, `${name}[${index}]`);
92
+ only(cue, `${name}[${index}]`, allowShot ? ["start", "end", "text", "shot"] : ["start", "end", "text"]);
93
+ const start = number(cue.start, `${name}[${index}].start`, 0, EPISODE_LIMITS.maxSeconds);
94
+ const end = number(cue.end, `${name}[${index}].end`, start + 0.001, EPISODE_LIMITS.maxSeconds);
95
+ return { start, end, text: text(cue.text, `${name}[${index}].text`), ...(cue.shot !== undefined ? { shot: identifier(cue.shot, `${name}[${index}].shot`) } : {}) };
96
+ });
97
+ }
98
+ function timedAudio(value, name) {
99
+ if (value === undefined)
100
+ return [];
101
+ if (!Array.isArray(value) || value.length > EPISODE_LIMITS.maxAudioItems)
102
+ invalid(`${name} must be a list of at most ${EPISODE_LIMITS.maxAudioItems}.`);
103
+ return value.map((raw, index) => {
104
+ const item = object(raw, `${name}[${index}]`);
105
+ only(item, `${name}[${index}]`, ["id", "file", "at", "shot", "volume"]);
106
+ return {
107
+ id: identifier(item.id, `${name}[${index}].id`),
108
+ file: relativePath(item.file, `${name}[${index}].file`),
109
+ at: number(item.at, `${name}[${index}].at`, 0, EPISODE_LIMITS.maxSeconds, 0),
110
+ ...(item.shot !== undefined ? { shot: identifier(item.shot, `${name}[${index}].shot`) } : {}),
111
+ volume: number(item.volume, `${name}[${index}].volume`, 0, 4, 1),
112
+ };
113
+ });
114
+ }
115
+ /** Parse and bound an episode manifest. Unknown fields are refused, never ignored. */
116
+ export function parseEpisode(value) {
117
+ const document = object(value, "Episode");
118
+ only(document, "Episode", ["schema", "id", "title", "context", "output", "shots", "audio", "subtitles"]);
119
+ if (document.schema !== EPISODE_SCHEMA)
120
+ invalid(`schema must be ${EPISODE_SCHEMA}.`);
121
+ const id = identifier(document.id, "id");
122
+ const title = text(document.title, "title");
123
+ const contextRaw = document.context === undefined ? {} : object(document.context, "context");
124
+ only(contextRaw, "context", ["project", "library"]);
125
+ const context = {
126
+ project: contextRaw.project === undefined ? null : text(contextRaw.project, "context.project", 120),
127
+ library: contextRaw.library === undefined ? null : text(contextRaw.library, "context.library", 120),
128
+ };
129
+ const outputRaw = object(document.output ?? {}, "output");
130
+ only(outputRaw, "output", ["profile", "fps", "crf", "audio_bitrate", "loudness"]);
131
+ const profile = (outputRaw.profile ?? "landscape-720p");
132
+ if (typeof profile !== "string" || !Object.hasOwn(RENDER_PROFILES, profile))
133
+ invalid(`output.profile must be one of ${Object.keys(RENDER_PROFILES).join(", ")}.`);
134
+ const fps = number(outputRaw.fps, "output.fps", 1, 60, 30);
135
+ if (!Number.isInteger(fps))
136
+ invalid("output.fps must be a whole number.");
137
+ const crf = number(outputRaw.crf, "output.crf", 0, 51, 18);
138
+ const bitrate = outputRaw.audio_bitrate ?? "192k";
139
+ if (typeof bitrate !== "string" || !/^(96|128|160|192|256|320)k$/.test(bitrate))
140
+ invalid("output.audio_bitrate must be one of 96k, 128k, 160k, 192k, 256k, 320k.");
141
+ const loudnessRaw = object(outputRaw.loudness ?? {}, "output.loudness");
142
+ only(loudnessRaw, "output.loudness", ["integrated", "true_peak", "lra"]);
143
+ const loudness = {
144
+ integrated: number(loudnessRaw.integrated, "output.loudness.integrated", -70, -5, -16),
145
+ true_peak: number(loudnessRaw.true_peak, "output.loudness.true_peak", -9, 0, -1.5),
146
+ lra: number(loudnessRaw.lra, "output.loudness.lra", 1, 50, 11),
147
+ };
148
+ const output = { profile, ...RENDER_PROFILES[profile], fps, crf, audio_bitrate: bitrate, loudness };
149
+ if (!Array.isArray(document.shots) || !document.shots.length || document.shots.length > EPISODE_LIMITS.maxShots) {
150
+ invalid(`shots must be a list of 1 to ${EPISODE_LIMITS.maxShots} shots.`);
151
+ }
152
+ const seen = new Set();
153
+ const shots = document.shots.map((raw, index) => {
154
+ const name = `shots[${index}]`;
155
+ const shot = object(raw, name);
156
+ only(shot, name, ["id", "title", "scene", "experience", "start", "duration", "actions", "audio", "sound_volume", "transition", "captions"]);
157
+ const shotId = identifier(shot.id, `${name}.id`);
158
+ if (seen.has(shotId))
159
+ invalid(`${name}.id ${shotId} is used twice.`);
160
+ seen.add(shotId);
161
+ if ((shot.scene === undefined) === (shot.experience === undefined))
162
+ invalid(`${name} needs exactly one of scene or experience.`);
163
+ let subject;
164
+ let scene;
165
+ let experience;
166
+ if (shot.scene !== undefined) {
167
+ subject = "scene";
168
+ const value = object(shot.scene, `${name}.scene`);
169
+ only(value, `${name}.scene`, ["asset", "source", "revision"]);
170
+ const source = value.source ?? "local";
171
+ if (source !== "local" && source !== "approved")
172
+ invalid(`${name}.scene.source must be local or approved.`);
173
+ if (value.revision !== undefined && source !== "approved")
174
+ invalid(`${name}.scene.revision needs source approved.`);
175
+ scene = { asset: publicId(value.asset, `${name}.scene.asset`), source, ...(value.revision !== undefined ? { revision: publicId(value.revision, `${name}.scene.revision`) } : {}) };
176
+ }
177
+ else {
178
+ subject = "experience";
179
+ const value = object(shot.experience, `${name}.experience`);
180
+ only(value, `${name}.experience`, ["id", "source"]);
181
+ const source = value.source ?? "local";
182
+ if (source !== "local" && source !== "saved")
183
+ invalid(`${name}.experience.source must be local or saved.`);
184
+ experience = { id: identifier(value.id, `${name}.experience.id`), source };
185
+ }
186
+ const start = number(shot.start, `${name}.start`, 0, 3600, 0);
187
+ const duration = number(shot.duration, `${name}.duration`, 1 / fps, 120);
188
+ if (wholeFrames(duration, fps, `${name}.duration`) < 1)
189
+ invalid(`${name}.duration is shorter than a frame.`);
190
+ const audio = shot.audio ?? "scene";
191
+ if (audio !== "scene" && audio !== "silent")
192
+ invalid(`${name}.audio must be scene or silent.`);
193
+ // The shot's recipe is the render's own validation: reuse it.
194
+ let recipe;
195
+ try {
196
+ recipe = parseRecipe({ kind: RECIPE_KIND, fps, start, end: start + duration, profile, audio, crf, actions: shot.actions ?? [] }, subject);
197
+ }
198
+ catch (error) {
199
+ invalid(`${name}: ${error instanceof Error ? error.message : String(error)}`);
200
+ }
201
+ const transitionRaw = shot.transition === undefined ? { type: "cut" } : object(shot.transition, `${name}.transition`);
202
+ only(transitionRaw, `${name}.transition`, ["type", "duration"]);
203
+ let transition;
204
+ if (transitionRaw.type === "cut") {
205
+ if (transitionRaw.duration !== undefined)
206
+ invalid(`${name}.transition cut takes no duration.`);
207
+ transition = { type: "cut" };
208
+ }
209
+ else if (transitionRaw.type === "crossfade") {
210
+ if (index === 0)
211
+ invalid(`${name}: the first shot cannot crossfade from nothing.`);
212
+ const length = number(transitionRaw.duration, `${name}.transition.duration`, 1 / fps, 5);
213
+ wholeFrames(length, fps, `${name}.transition.duration`);
214
+ transition = { type: "crossfade", duration: length };
215
+ }
216
+ else {
217
+ invalid(`${name}.transition.type must be cut or crossfade.`);
218
+ }
219
+ const shotCaptions = captions(shot.captions, `${name}.captions`, false);
220
+ for (const cue of shotCaptions)
221
+ if (cue.end > duration + 1e-9)
222
+ invalid(`${name}.captions end after the shot.`);
223
+ return {
224
+ id: shotId, ...(shot.title !== undefined ? { title: text(shot.title, `${name}.title`) } : {}),
225
+ subject, ...(scene ? { scene } : {}), ...(experience ? { experience } : {}),
226
+ start, duration, actions: recipe.actions, audio, sound_volume: number(shot.sound_volume, `${name}.sound_volume`, 0, 4, 1),
227
+ transition, captions: shotCaptions,
228
+ };
229
+ });
230
+ // A crossfade overlaps both shots: it must fit inside each.
231
+ shots.forEach((shot, index) => {
232
+ if (shot.transition.type !== "crossfade")
233
+ return;
234
+ const length = shot.transition.duration;
235
+ if (length >= shot.duration || length >= shots[index - 1].duration)
236
+ invalid(`shots[${index}].transition is longer than a shot it joins.`);
237
+ });
238
+ const audioRaw = object(document.audio ?? {}, "audio");
239
+ only(audioRaw, "audio", ["narration", "music", "sfx"]);
240
+ const narration = timedAudio(audioRaw.narration, "audio.narration");
241
+ const sfx = timedAudio(audioRaw.sfx, "audio.sfx");
242
+ const musicRaw = audioRaw.music ?? [];
243
+ if (!Array.isArray(musicRaw) || musicRaw.length > 8)
244
+ invalid("audio.music must be a list of at most 8 beds.");
245
+ const music = musicRaw.map((raw, index) => {
246
+ const name = `audio.music[${index}]`;
247
+ const item = object(raw, name);
248
+ only(item, name, ["id", "file", "at", "shot", "end", "loop", "volume", "duck"]);
249
+ const at = number(item.at, `${name}.at`, 0, EPISODE_LIMITS.maxSeconds, 0);
250
+ let duck = { threshold: 0.03, ratio: 8, attack_ms: 20, release_ms: 400 };
251
+ if (item.duck === false)
252
+ duck = false;
253
+ else if (item.duck !== undefined) {
254
+ const value = object(item.duck, `${name}.duck`);
255
+ only(value, `${name}.duck`, ["threshold", "ratio", "attack_ms", "release_ms"]);
256
+ duck = {
257
+ threshold: number(value.threshold, `${name}.duck.threshold`, 0.001, 1, 0.03),
258
+ ratio: number(value.ratio, `${name}.duck.ratio`, 1, 20, 8),
259
+ attack_ms: number(value.attack_ms, `${name}.duck.attack_ms`, 0.01, 2000, 20),
260
+ release_ms: number(value.release_ms, `${name}.duck.release_ms`, 0.01, 9000, 400),
261
+ };
262
+ }
263
+ if (item.loop !== undefined && typeof item.loop !== "boolean")
264
+ invalid(`${name}.loop must be boolean.`);
265
+ return {
266
+ id: identifier(item.id, `${name}.id`), file: relativePath(item.file, `${name}.file`), at,
267
+ ...(item.shot !== undefined ? { shot: identifier(item.shot, `${name}.shot`) } : {}),
268
+ end: item.end === undefined ? null : number(item.end, `${name}.end`, at + 0.001, EPISODE_LIMITS.maxSeconds),
269
+ loop: item.loop === true, volume: number(item.volume, `${name}.volume`, 0, 4, 0.5), duck,
270
+ };
271
+ });
272
+ const ids = new Set();
273
+ for (const item of [...narration, ...sfx, ...music]) {
274
+ if (ids.has(item.id))
275
+ invalid(`audio id ${item.id} is used twice.`);
276
+ ids.add(item.id);
277
+ if (item.shot !== undefined && !seen.has(item.shot))
278
+ invalid(`audio ${item.id} names unknown shot ${item.shot}.`);
279
+ }
280
+ const subtitlesRaw = object(document.subtitles ?? {}, "subtitles");
281
+ only(subtitlesRaw, "subtitles", ["cues", "include_shot_captions"]);
282
+ if (subtitlesRaw.include_shot_captions !== undefined && typeof subtitlesRaw.include_shot_captions !== "boolean")
283
+ invalid("subtitles.include_shot_captions must be boolean.");
284
+ const cues = captions(subtitlesRaw.cues, "subtitles.cues", true);
285
+ for (const cue of cues)
286
+ if (cue.shot !== undefined && !seen.has(cue.shot))
287
+ invalid(`subtitle cue names unknown shot ${cue.shot}.`);
288
+ return {
289
+ schema: EPISODE_SCHEMA, id, title, context, output, shots,
290
+ audio: { narration, music, sfx },
291
+ subtitles: { cues, include_shot_captions: subtitlesRaw.include_shot_captions !== false },
292
+ };
293
+ }
294
+ /** The render recipe of one shot: the same contract `render-video` takes. */
295
+ export function shotRecipe(episode, shot) {
296
+ return parseRecipe({
297
+ kind: RECIPE_KIND, fps: episode.output.fps, start: shot.start, end: shot.start + shot.duration,
298
+ profile: episode.output.profile, audio: shot.audio, crf: episode.output.crf, actions: shot.actions,
299
+ }, shot.subject);
300
+ }
301
+ /**
302
+ * Where every shot lands on the episode timeline, in frames. A crossfade
303
+ * into shot k overlaps it with the end of shot k-1 by its length.
304
+ */
305
+ export function episodeTimeline(episode) {
306
+ const { fps } = episode.output;
307
+ let cursor = 0;
308
+ const shots = episode.shots.map((shot, index) => {
309
+ const frames = frameCountFor(shot.start, shot.start + shot.duration, fps);
310
+ const overlap = shot.transition.type === "crossfade" ? Math.round(shot.transition.duration * fps) : 0;
311
+ const offset = cursor - overlap;
312
+ cursor = offset + frames;
313
+ return { id: shot.id, index, frames, offset_frames: offset, overlap_frames: overlap, offset_seconds: offset / fps, duration_seconds: frames / fps };
314
+ });
315
+ return { shots, total_frames: cursor, total_seconds: cursor / fps };
316
+ }
317
+ /** An item's episode second: absolute, or relative to its anchoring shot's first frame. */
318
+ export function anchoredSeconds(timeline, at, shot) {
319
+ if (shot === undefined)
320
+ return at;
321
+ const timing = timeline.shots.find((item) => item.id === shot);
322
+ if (!timing)
323
+ throw new CliError(`Unknown shot ${shot}.`, "EPISODE_INVALID", 2);
324
+ return timing.offset_seconds + at;
325
+ }
326
+ /** Every encode setting a shot's MP4 depends on (part of every key). */
327
+ export function encoderSettings(episode, encoderVersion) {
328
+ return { encoder: encoderVersion, video: "libx264 High preset medium yuv420p CFR", crf: episode.output.crf, audio: "aac 128k 48kHz stereo", key_version: SHOT_KEY_VERSION };
329
+ }
330
+ /** The content-addressed key of one shot: sha256 over its canonical inputs, nothing else. */
331
+ export function shotCacheKey(inputs) {
332
+ return createHash("sha256").update(canonicalJson({ v: SHOT_KEY_VERSION, ...inputs })).digest("hex");
333
+ }
334
+ /**
335
+ * A cached shot is reusable only when its record names this key, its MP4
336
+ * hashes to the recorded sha256 and probes to the expected frame count.
337
+ * Missing or corrupt means rerender. Modification times are not read.
338
+ */
339
+ export function checkCachedShot(record, key, expectedFrames, videoSha256, probedFrames) {
340
+ const value = record;
341
+ if (!value || value.kind !== SHOT_CACHE_KIND)
342
+ return { valid: false, reason: "no cache record" };
343
+ if (value.key !== key)
344
+ return { valid: false, reason: "cache record names another key" };
345
+ if (videoSha256 === null)
346
+ return { valid: false, reason: "cached MP4 missing" };
347
+ if (value.video_sha256 !== videoSha256)
348
+ return { valid: false, reason: "cached MP4 does not hash as recorded" };
349
+ if (probedFrames === null)
350
+ return { valid: false, reason: "cached MP4 does not probe" };
351
+ if (probedFrames !== expectedFrames || value.frames !== expectedFrames)
352
+ return { valid: false, reason: `cached MP4 has ${probedFrames} frames, expected ${expectedFrames}` };
353
+ return { valid: true };
354
+ }
355
+ /**
356
+ * Split the shots into segments: shots joined to a neighbour by a crossfade
357
+ * form one re-encoded segment; every other shot is stream-copied as it was
358
+ * rendered. Only crossfade boundaries are ever re-encoded.
359
+ */
360
+ export function videoSegments(episode) {
361
+ const segments = [];
362
+ episode.shots.forEach((shot, index) => {
363
+ const last = segments.at(-1);
364
+ if (shot.transition.type === "crossfade" && last) {
365
+ if (last.kind === "crossfade")
366
+ last.shots.push(index);
367
+ else {
368
+ last.shots.pop();
369
+ if (!last.shots.length)
370
+ segments.pop();
371
+ segments.push({ kind: "crossfade", shots: [index - 1, index] });
372
+ }
373
+ return;
374
+ }
375
+ if (last?.kind === "copy")
376
+ last.shots.push(index);
377
+ else
378
+ segments.push({ kind: "copy", shots: [index] });
379
+ });
380
+ return segments;
381
+ }
382
+ const samples = (seconds) => Math.round(seconds * MIX_SAMPLE_RATE);
383
+ /** FFmpeg arguments that crossfade consecutive shots into one segment (video only). */
384
+ export function crossfadeArguments(episode, timeline, segment, files, output) {
385
+ const { fps, crf } = episode.output;
386
+ const chains = [];
387
+ let label = "0:v";
388
+ let length = timeline.shots[segment[0]].frames;
389
+ segment.slice(1).forEach((shotIndex, position) => {
390
+ const timing = timeline.shots[shotIndex];
391
+ const overlap = timing.overlap_frames;
392
+ const next = `x${position + 1}`;
393
+ if (overlap > 0) {
394
+ chains.push(`[${label}][${position + 1}:v]xfade=transition=fade:duration=${(overlap / fps).toFixed(6)}:offset=${((length - overlap) / fps).toFixed(6)}[${next}]`);
395
+ }
396
+ else {
397
+ chains.push(`[${label}][${position + 1}:v]concat=n=2:v=1:a=0[${next}]`);
398
+ }
399
+ label = next;
400
+ length += timing.frames - overlap;
401
+ });
402
+ return [
403
+ "-hide_banner", "-loglevel", "error", "-y",
404
+ ...files.flatMap((file) => ["-i", file]),
405
+ "-filter_complex", `${chains.join(";")};[${label}]settb=1/${fps * 1000},setpts=N/(${fps}*TB),format=yuv420p[v]`,
406
+ "-map", "[v]", "-an", "-frames:v", String(length),
407
+ "-c:v", "libx264", "-preset", "medium", "-crf", String(crf), "-profile:v", "high", "-pix_fmt", "yuv420p",
408
+ "-r", String(fps), "-fps_mode", "cfr", output,
409
+ ];
410
+ }
411
+ /** FFmpeg arguments that copy one shot's picture unchanged (video only). */
412
+ export function copyVideoArguments(file, output) {
413
+ return ["-hide_banner", "-loglevel", "error", "-y", "-i", file, "-map", "0:v:0", "-an", "-c:v", "copy", output];
414
+ }
415
+ /** The concat-demuxer list for the segments, in order. */
416
+ export function concatList(files) {
417
+ return files.map((file) => `file '${file.replaceAll("'", "'\\''")}'`).join("\n") + "\n";
418
+ }
419
+ export function concatArguments(list, output, frames) {
420
+ return ["-hide_banner", "-loglevel", "error", "-y", "-f", "concat", "-safe", "0", "-i", list,
421
+ "-map", "0:v:0", "-an", "-c:v", "copy", "-frames:v", String(frames), output];
422
+ }
423
+ /**
424
+ * The episode audio mix as FFmpeg arguments: three buses summed without
425
+ * normalisation into exactly `total_samples` of 48 kHz stereo float PCM.
426
+ *
427
+ * - shots: each shot's own sounds at its episode offset (faded across a
428
+ * crossfade), plus episode-level sound effects;
429
+ * - narration: every narration item at its second;
430
+ * - music: each bed (looped when asked), ducked under the narration bus by
431
+ * `sidechaincompress` keyed on the narration.
432
+ *
433
+ * Every placement is counted in samples. Returns the arguments and the
434
+ * label of the narration-only bus written to `narrationOutput` (QC uses it
435
+ * to find narration onsets without the music under it).
436
+ */
437
+ export function episodeMixArguments(input) {
438
+ const inputs = [];
439
+ const chains = [];
440
+ let index = 0;
441
+ const total = input.total_samples;
442
+ const chain = (item, label) => {
443
+ if (item.loop)
444
+ inputs.push("-stream_loop", "-1");
445
+ inputs.push("-i", item.file);
446
+ const from = samples(item.trim_seconds ?? 0);
447
+ const length = item.length_seconds === null || item.length_seconds === undefined ? null : samples(item.length_seconds);
448
+ const fades = [
449
+ item.fade_in ? `afade=t=in:ss=0:d=${item.fade_in.toFixed(6)}` : null,
450
+ item.fade_out && length !== null ? `afade=t=out:st=${(length / MIX_SAMPLE_RATE - item.fade_out).toFixed(6)}:d=${item.fade_out.toFixed(6)}` : null,
451
+ ].filter(Boolean);
452
+ chains.push(`[${index}:a]aresample=${MIX_SAMPLE_RATE},aformat=sample_fmts=fltp:channel_layouts=stereo,asetpts=N/SR/TB,` +
453
+ `atrim=start_sample=${from}${length !== null ? `:end_sample=${from + length}` : ""},asetpts=N/SR/TB,` +
454
+ `${fades.length ? `${fades.join(",")},` : ""}volume=${item.volume.toFixed(4)},adelay=delays=${samples(item.at_seconds)}S:all=1[${label}]`);
455
+ index += 1;
456
+ };
457
+ const bus = (labels, name) => {
458
+ if (!labels.length) {
459
+ chains.push(`anullsrc=channel_layout=stereo:sample_rate=${MIX_SAMPLE_RATE},atrim=end_sample=${total}[${name}]`);
460
+ return;
461
+ }
462
+ chains.push(`${labels.map((label) => `[${label}]`).join("")}amix=inputs=${labels.length}:normalize=0:dropout_transition=0,` +
463
+ `asetpts=N/SR/TB,apad=whole_len=${total},atrim=end_sample=${total}[${name}]`);
464
+ };
465
+ input.shots.forEach((item, position) => chain(item, `s${position}`));
466
+ input.narration.forEach((item, position) => chain(item, `n${position}`));
467
+ input.music.forEach((item, position) => chain(item, `m${position}`));
468
+ bus(input.shots.map((_, position) => `s${position}`), "shots");
469
+ bus(input.narration.map((_, position) => `n${position}`), "narration");
470
+ // The narration bus feeds the mix, one ducking key per ducked bed and
471
+ // (optionally) the QC copy.
472
+ const ducked = input.narration.length ? input.music.map((item, position) => item.duck ? position : -1).filter((position) => position >= 0) : [];
473
+ const copies = ["[nmix]", ...ducked.map((position) => `[nk${position}]`), ...(input.narrationOutput ? ["[nqc]"] : [])];
474
+ chains.push(copies.length === 1 ? "[narration]anull[nmix]" : `[narration]asplit=${copies.length}${copies.join("")}`);
475
+ for (const position of ducked) {
476
+ const { threshold, ratio, attack_ms, release_ms } = input.music[position].duck;
477
+ chains.push(`[m${position}][nk${position}]sidechaincompress=threshold=${threshold}:ratio=${ratio}:attack=${attack_ms}:release=${release_ms}:makeup=1[d${position}]`);
478
+ }
479
+ const musicLabels = input.music.map((_, position) => ducked.includes(position) ? `d${position}` : `m${position}`);
480
+ bus(musicLabels, "music");
481
+ chains.push(`[shots][nmix][music]amix=inputs=3:normalize=0:dropout_transition=0,asetpts=N/SR/TB,apad=whole_len=${total},atrim=end_sample=${total}[mix]`);
482
+ return [
483
+ "-hide_banner", "-loglevel", "error", "-y",
484
+ ...inputs,
485
+ "-filter_complex", chains.join(";"),
486
+ "-map", "[mix]", "-ar", String(MIX_SAMPLE_RATE), "-ac", "2", "-c:a", "pcm_f32le", input.output,
487
+ ...(input.narrationOutput ? ["-map", "[nqc]", "-ar", String(MIX_SAMPLE_RATE), "-ac", "2", "-c:a", "pcm_s16le", input.narrationOutput] : []),
488
+ ];
489
+ }
490
+ /** Pass one of two-pass `loudnorm`: measure only. */
491
+ export function loudnessMeasureArguments(file, target) {
492
+ return ["-hide_banner", "-nostats", "-i", file, "-af",
493
+ `loudnorm=I=${target.integrated}:TP=${target.true_peak}:LRA=${target.lra}:print_format=json`, "-f", "null", "-"];
494
+ }
495
+ /** The JSON block `loudnorm` prints at the end of stderr. */
496
+ export function parseLoudnorm(stderr) {
497
+ const start = stderr.lastIndexOf("{");
498
+ const end = stderr.lastIndexOf("}");
499
+ if (start < 0 || end < start)
500
+ throw new CliError("loudnorm printed no measurement.", "EPISODE_LOUDNESS_FAILED", 1, { stderr: stderr.slice(-600) });
501
+ const value = JSON.parse(stderr.slice(start, end + 1));
502
+ for (const key of ["input_i", "input_tp", "input_lra", "input_thresh", "target_offset"]) {
503
+ if (typeof value[key] !== "string")
504
+ throw new CliError(`loudnorm measurement lacks ${key}.`, "EPISODE_LOUDNESS_FAILED", 1);
505
+ }
506
+ return value;
507
+ }
508
+ /**
509
+ * Pass two: normalise with the measured values (linear when possible), back
510
+ * to 48 kHz, padded and trimmed to exactly `total_samples`.
511
+ */
512
+ /**
513
+ * The loudness range `loudnorm` is given in pass two: the target, or the
514
+ * mix's own range when that is wider. loudnorm normalises linearly (one
515
+ * gain, dynamics kept) only when the input range fits the target range;
516
+ * otherwise it compresses dynamically, which undershoots the integrated
517
+ * target on short programmes (a 7 s episode measured -17.2 LUFS for a
518
+ * -16 target). Keeping the mix's own range keeps it linear.
519
+ */
520
+ export function loudnormRange(target, measured) {
521
+ const range = Number(measured.input_lra);
522
+ return Number.isFinite(range) ? Math.min(50, Math.max(target.lra, Math.ceil(range * 10) / 10)) : target.lra;
523
+ }
524
+ export function loudnessApplyArguments(file, target, measured, totalSamples, output) {
525
+ // A silent mix measures -inf; there is nothing to normalise.
526
+ const finite = Number.isFinite(Number(measured.input_i)) && Number(measured.input_i) > -70;
527
+ const filter = finite
528
+ ? `loudnorm=I=${target.integrated}:TP=${target.true_peak}:LRA=${loudnormRange(target, measured)}:measured_I=${measured.input_i}:measured_TP=${measured.input_tp}` +
529
+ `:measured_LRA=${measured.input_lra}:measured_thresh=${measured.input_thresh}:offset=${measured.target_offset}:linear=true:print_format=json,`
530
+ : "";
531
+ return ["-hide_banner", "-nostats", "-y", "-i", file, "-af",
532
+ `${filter}aresample=${MIX_SAMPLE_RATE},asetpts=N/SR/TB,apad=whole_len=${totalSamples},atrim=end_sample=${totalSamples}`,
533
+ "-ar", String(MIX_SAMPLE_RATE), "-ac", "2", "-c:a", "pcm_f32le", output];
534
+ }
535
+ /**
536
+ * The safety pass after normalisation: when the result is still more than
537
+ * 0.5 LU from target (loudnorm could not stay linear under the true-peak
538
+ * ceiling), one gain to the target and a peak limiter 0.5 dB under the
539
+ * ceiling, at the exact sample count. Null when no correction is needed.
540
+ */
541
+ export function loudnessCorrectionArguments(file, target, measuredIntegrated, totalSamples, output) {
542
+ if (measuredIntegrated === null || !Number.isFinite(measuredIntegrated) || measuredIntegrated < -70)
543
+ return null;
544
+ const gain = target.integrated - measuredIntegrated;
545
+ if (Math.abs(gain) <= 0.5)
546
+ return null;
547
+ const limit = Math.pow(10, (target.true_peak - 0.5) / 20);
548
+ return ["-hide_banner", "-loglevel", "error", "-y", "-i", file, "-af",
549
+ `volume=${gain.toFixed(2)}dB,alimiter=limit=${limit.toFixed(4)}:attack=1:release=50:level=false,` +
550
+ `asetpts=N/SR/TB,apad=whole_len=${totalSamples},atrim=end_sample=${totalSamples}`,
551
+ "-ar", String(MIX_SAMPLE_RATE), "-ac", "2", "-c:a", "pcm_f32le", output];
552
+ }
553
+ /** Mux the assembled picture and the episode mix: the picture is copied, the audio encoded once. */
554
+ export function muxArguments(video, audio, episode, totalFrames, output) {
555
+ return ["-hide_banner", "-loglevel", "error", "-y", "-i", video, "-i", audio,
556
+ "-map", "0:v:0", "-map", "1:a:0", "-c:v", "copy", "-frames:v", String(totalFrames),
557
+ "-c:a", "aac", "-b:a", episode.output.audio_bitrate, "-ar", String(MIX_SAMPLE_RATE),
558
+ "-movflags", "+faststart",
559
+ "-metadata", `title=${episode.title}`,
560
+ "-metadata", "comment=KheleShikhi episode derivative; not canonical content",
561
+ output];
562
+ }
563
+ /** Every subtitle cue in episode seconds: episode cues (absolute or shot-anchored) and shot captions. */
564
+ export function episodeCues(episode, timeline = episodeTimeline(episode)) {
565
+ const cues = episode.subtitles.cues.map((cue) => {
566
+ const offset = anchoredSeconds(timeline, 0, cue.shot);
567
+ return { start: offset + cue.start, end: offset + cue.end, text: cue.text, source: cue.shot ? `shot:${cue.shot}` : "episode" };
568
+ });
569
+ if (episode.subtitles.include_shot_captions) {
570
+ episode.shots.forEach((shot, index) => {
571
+ const offset = timeline.shots[index].offset_seconds;
572
+ for (const cue of shot.captions)
573
+ cues.push({ start: offset + cue.start, end: offset + cue.end, text: cue.text, source: `shot:${shot.id}` });
574
+ });
575
+ }
576
+ return cues
577
+ .map((cue) => ({ ...cue, end: Math.min(cue.end, timeline.total_seconds) }))
578
+ .filter((cue) => cue.end > cue.start)
579
+ .sort((a, b) => a.start - b.start || a.end - b.end);
580
+ }
581
+ export function srtTimestamp(seconds) {
582
+ const total = Math.max(0, Math.round(seconds * 1000));
583
+ const ms = total % 1000;
584
+ const s = Math.floor(total / 1000) % 60;
585
+ const m = Math.floor(total / 60000) % 60;
586
+ const h = Math.floor(total / 3600000);
587
+ const pad = (value, width = 2) => String(value).padStart(width, "0");
588
+ return `${pad(h)}:${pad(m)}:${pad(s)},${pad(ms, 3)}`;
589
+ }
590
+ /** SubRip text: numbered cues, CRLF-free, one blank line between cues. */
591
+ export function toSrt(cues) {
592
+ return cues.map((cue, index) => `${index + 1}\n${srtTimestamp(cue.start)} --> ${srtTimestamp(cue.end)}\n${cue.text.replace(/\r?\n/g, "\n").trim()}\n`).join("\n");
593
+ }
594
+ /** The episode QC verdict: frames, size, codecs, duration, A/V sync, loudness, narration placement. */
595
+ export function qcChecks(input) {
596
+ const frame = 1 / input.fps;
597
+ const { probe } = input;
598
+ const checks = [
599
+ { check: "frame_count", pass: probe.frames === input.expected_frames, detail: `${probe.frames} of ${input.expected_frames}` },
600
+ { check: "size", pass: probe.width === input.width && probe.height === input.height, detail: `${probe.width}x${probe.height}` },
601
+ { check: "codecs", pass: probe.video_codec === "h264" && probe.audio_codec === "aac", detail: `${probe.video_codec}/${probe.audio_codec}` },
602
+ { check: "duration", pass: Math.abs((probe.video_duration ?? probe.duration) - input.expected_frames / input.fps) <= frame / 2 + 1e-6,
603
+ detail: `video ${probe.video_duration ?? probe.duration}s, expected ${(input.expected_frames / input.fps).toFixed(6)}s` },
604
+ { check: "av_sync_start", pass: Math.abs(probe.video_start ?? 0) <= 1e-3 && Math.abs(probe.audio_start ?? 0) <= frame,
605
+ detail: `video starts ${probe.video_start}s, audio ${probe.audio_start}s` },
606
+ { check: "av_sync_length", pass: probe.audio_duration !== null && Math.abs(probe.audio_duration - (probe.video_duration ?? probe.duration)) <= frame,
607
+ detail: `audio ${probe.audio_duration}s vs video ${probe.video_duration ?? probe.duration}s` },
608
+ { check: "loudness_integrated", pass: input.loudness.integrated !== null && Math.abs(input.loudness.integrated - input.target.integrated) <= 1,
609
+ detail: `${input.loudness.integrated} LUFS, target ${input.target.integrated}` },
610
+ { check: "true_peak", pass: input.loudness.true_peak !== null && input.loudness.true_peak <= input.target.true_peak + 0.5,
611
+ detail: `${input.loudness.true_peak} dBTP, ceiling ${input.target.true_peak}` },
612
+ ];
613
+ for (const onset of input.narration_onsets ?? []) {
614
+ checks.push({ check: `narration_onset:${onset.id}`, pass: onset.observed !== null && Math.abs(onset.observed - onset.expected) <= 0.025,
615
+ detail: `expected ${onset.expected.toFixed(3)}s, observed ${onset.observed === null ? "none" : onset.observed.toFixed(3)}s` });
616
+ }
617
+ return checks;
618
+ }
619
+ /** Integrated loudness, LRA and true peak from FFmpeg `ebur128=peak=true` stderr. */
620
+ export function parseEbur128(stderr) {
621
+ const summary = stderr.slice(stderr.lastIndexOf("Summary:"));
622
+ const read = (pattern) => { const match = pattern.exec(summary); return match ? Number(match[1]) : null; };
623
+ return { integrated: read(/I:\s*(-?[0-9.]+) LUFS/), lra: read(/LRA:\s*(-?[0-9.]+) LU/), true_peak: read(/Peak:\s*(-?[0-9.]+) dBFS/) };
624
+ }
625
+ /**
626
+ * Where sound begins, from FFmpeg `silencedetect` stderr: 0 when the file
627
+ * does not open silent, then every `silence_end`.
628
+ */
629
+ export function onsets(stderr) {
630
+ const starts = [...stderr.matchAll(/silence_start:\s*(-?[0-9.]+)/g)].map((match) => Number(match[1]));
631
+ const ends = [...stderr.matchAll(/silence_end:\s*(-?[0-9.]+)/g)].map((match) => Number(match[1]));
632
+ return [...(starts.length && starts[0] <= 0.001 ? [] : [0]), ...ends].sort((a, b) => a - b);
633
+ }
634
+ /** The onset nearest `expected`, or null when there is none within a second. */
635
+ export function nearestOnset(values, expected) {
636
+ let best = null;
637
+ for (const value of values)
638
+ if (best === null || Math.abs(value - expected) < Math.abs(best - expected))
639
+ best = value;
640
+ return best !== null && Math.abs(best - expected) <= 1 ? best : null;
641
+ }
642
+ /** The identity of a whole assembly: shot keys, timeline, audio and subtitle inputs, loudness and output settings. */
643
+ export function assemblyKey(episode, shotKeys, audioSha256) {
644
+ return digestOf({ shots: shotKeys, transitions: episode.shots.map((shot) => shot.transition), sound_volume: episode.shots.map((shot) => shot.sound_volume),
645
+ audio: { narration: episode.audio.narration, music: episode.audio.music, sfx: episode.audio.sfx }, audio_sha256: audioSha256,
646
+ subtitles: episodeCues(episode), output: episode.output, key_version: SHOT_KEY_VERSION });
647
+ }
648
+ //# sourceMappingURL=episode.js.map