@nodaro/shared 3.11.0 → 3.12.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.
Files changed (52) hide show
  1. package/dist/index.cjs +2047 -84
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +1556 -27
  4. package/dist/index.d.ts +1556 -27
  5. package/dist/index.js +1861 -85
  6. package/dist/index.js.map +1 -1
  7. package/package.json +1 -1
  8. package/src/__tests__/caption-styles.test.ts +207 -0
  9. package/src/__tests__/edl-multicam.test.ts +304 -0
  10. package/src/__tests__/edl.test.ts +822 -0
  11. package/src/__tests__/fan-out-rows.test.ts +208 -0
  12. package/src/__tests__/instagram-scrape.test.ts +66 -0
  13. package/src/__tests__/llm-models.test.ts +48 -11
  14. package/src/__tests__/meta-ads-scrape.test.ts +284 -0
  15. package/src/__tests__/node-runtime-keys.test.ts +15 -0
  16. package/src/__tests__/presentation-utils.test.ts +67 -0
  17. package/src/__tests__/producer-types.test.ts +19 -0
  18. package/src/__tests__/schedule-rules.test.ts +265 -0
  19. package/src/__tests__/speaker-layouts.test.ts +203 -0
  20. package/src/__tests__/transcribe-capabilities.test.ts +104 -0
  21. package/src/__tests__/transcribe-preflight.test.ts +60 -0
  22. package/src/__tests__/trigger-feeds.test.ts +39 -0
  23. package/src/__tests__/video-duration-auto.test.ts +65 -0
  24. package/src/__tests__/video-duration.test.ts +56 -0
  25. package/src/__tests__/video-link.test.ts +137 -0
  26. package/src/__tests__/workflow-export-strip.test.ts +59 -1
  27. package/src/caption-styles.ts +240 -0
  28. package/src/credit-identifiers.ts +31 -0
  29. package/src/edit-plan-contract.ts +96 -0
  30. package/src/edl-multicam.ts +185 -0
  31. package/src/edl.ts +747 -0
  32. package/src/entity-image-handle.ts +24 -1
  33. package/src/fan-out-rows.ts +213 -0
  34. package/src/index.ts +206 -3
  35. package/src/instagram-scrape.ts +204 -0
  36. package/src/llm-models.ts +80 -3
  37. package/src/meta-ads-scrape.ts +463 -0
  38. package/src/model-catalog.ts +48 -5
  39. package/src/model-constants.ts +148 -5
  40. package/src/node-mappable-fields.ts +2 -0
  41. package/src/node-runtime-keys.ts +28 -0
  42. package/src/presentation-utils.ts +49 -0
  43. package/src/producer-types.ts +20 -0
  44. package/src/schedule-rules.ts +484 -0
  45. package/src/speaker-layouts.ts +220 -0
  46. package/src/transcribe-preflight.ts +101 -0
  47. package/src/trigger-feeds.ts +59 -0
  48. package/src/trigger-node-types.ts +20 -0
  49. package/src/video-duration-auto.ts +18 -0
  50. package/src/video-duration.ts +32 -0
  51. package/src/video-link.ts +167 -0
  52. package/src/workflow-export.ts +37 -1
package/src/edl.ts ADDED
@@ -0,0 +1,747 @@
1
+ import { speakerSwitchOverlaps, speakerPresentationWarnings } from "./speaker-layouts.js"
2
+ import type { EdlTargetAspect } from "./speaker-layouts.js"
3
+
4
+ /**
5
+ * EDL — the edit decision list contract.
6
+ *
7
+ * WHY THIS EXISTS
8
+ * The podcast-editing primitives (transcript-driven cut, clip finding,
9
+ * multicam, speaker view) compose because they share ONE data shape: every
10
+ * analysis node produces an EDL, every render node consumes one. This module
11
+ * is that shape plus the pure functions that read it. It is structural
12
+ * vocabulary — no prompts, no heuristics, no editorial judgment — which is why
13
+ * it lives in the Apache-licensed `@nodaro/shared` (the wire shape of
14
+ * `/v1/edl/*`, an SDK type, an MCP output). Every published version is an
15
+ * irrevocable grant, so the three resolved decisions (see below) all land in
16
+ * version 1: renaming a field, or adding a REQUIRED one, later would be a
17
+ * breaking major bump — so every later field is optional and additive.
18
+ *
19
+ * This module is PURE — no I/O, no ffmpeg, no network. The executors
20
+ * (`apply-edl`, `speaker-view`) turn an EDL into pixels; they live in the app
21
+ * and the plugins.
22
+ *
23
+ * DESIGN DECISIONS baked into v1 (so multicam and speaker view extend the
24
+ * contract additively rather than with a breaking bump):
25
+ * - D17 OVERLAP: a crossfade consumes time from the outgoing segment (ffmpeg
26
+ * `xfade`, as combine-videos already does), so the rendered timeline is
27
+ * SHORTER than the sum of segment durations. `edlDurationMs` subtracts the
28
+ * overlap transitions; cut/pan/zoom consume no time (pan: a geometry tween
29
+ * inside ONE source; zoom: a tween inside each segment); only the `xfade:*`
30
+ * family and a segment `crossfade` overlap.
31
+ * - D19 CLOCK/SOURCE/SIGN: `Edl.clock` says whether `segments` are on the
32
+ * source master clock or an output clock; `Transcript.sourceId` says which
33
+ * source a transcript came from; the offset sign is `masterMs = sourceMs +
34
+ * offsetMs(source)`, applied by the remap functions.
35
+ * - D20 PRECEDENCE/RENAME: region precedence
36
+ * `slot.region ▷ segment.region (single-slot only) ▷ a caller's per-slot
37
+ * resolver (v3 tracks) ▷ regions[(source, speaker)] ▷ source.region ▷ full
38
+ * frame`, implemented ONCE by `resolveEdlSegmentSlots` (edl-multicam.ts);
39
+ * `segment.region` is invalid when the segment's layout has more than one
40
+ * slot; `slots[].weight` (0..1, active = 1) replaces the design's
41
+ * `slots[].emphasis` so it no longer collides with `layout.emphasis`
42
+ * ({ style, durationMs }).
43
+ *
44
+ * Time is INTEGER MILLISECONDS everywhere. There is no seconds→ms guessing
45
+ * (`normalizeEdl` never reinterprets a unit — an implausible value is a
46
+ * validation error, not a silent 1000× edit).
47
+ */
48
+
49
+ export const EDL_VERSION = 1 as const
50
+
51
+ /** A crop region, as fractions 0..1 of the source frame. */
52
+ export interface EdlRegion {
53
+ readonly x: number
54
+ readonly y: number
55
+ readonly w: number
56
+ readonly h: number
57
+ }
58
+
59
+ export const EDL_SOURCE_ROLES = ["master-audio", "camera", "wide", "screen"] as const
60
+ export type EdlSourceRole = (typeof EDL_SOURCE_ROLES)[number]
61
+
62
+ const KNOWN_SOURCE_ROLES: ReadonlySet<string> = new Set(EDL_SOURCE_ROLES)
63
+
64
+ /** A media input to the edit. Ids are minted once (by `edit-plan`) and never
65
+ * re-derived, so downstream nodes resolve media from `url` in the data, not
66
+ * from canvas handle order. Keep the `url` key: the cloud relay re-hosts
67
+ * private media by walking `url`-suffixed fields. */
68
+ export interface EdlSource {
69
+ readonly id: string
70
+ readonly url: string
71
+ readonly kind: "video" | "audio"
72
+ /** This source's origin on the master clock. `masterMs = sourceMs + offsetMs`. Default 0. */
73
+ readonly offsetMs?: number
74
+ /** Known roles: EDL_SOURCE_ROLES. Open (`string & {}`) so an EDL written by a newer producer still type-checks
75
+ * and still validates — an unknown role is a WARNING (validateEdl); an executor that cannot honour it refuses it. */
76
+ readonly role?: EdlSourceRole | (string & {})
77
+ /** Speaker labels this source frames (multicam). Empty = unknown. */
78
+ readonly speakers?: readonly string[]
79
+ /** A static crop for this source (speaker-view v1 framing). */
80
+ readonly region?: EdlRegion
81
+ }
82
+
83
+ /** How a segment is presented on screen (speaker-view, phase 2). `mode` and
84
+ * `transition.type` are ids from the `SPEAKER_LAYOUTS` / `SPEAKER_SWITCHES`
85
+ * registries (speaker-layouts.ts); `emphasis.style` is a `+`-joined set of
86
+ * atomic `SPEAKER_EMPHASIS_STYLES` (e.g. "scale+border"). Unknown ids are
87
+ * validation WARNINGS, and `mode` stays an open string on purpose. v1 leaves
88
+ * layout undefined (single camera). */
89
+ export interface EdlLayout {
90
+ /** "single" | "side-by-side" | "stacked" | "grid" | "pip" | … */
91
+ readonly mode: string
92
+ readonly slots?: ReadonlyArray<{
93
+ readonly source: string
94
+ readonly region?: EdlRegion
95
+ readonly speaker?: string
96
+ /** D20: 0..1, the active slot = 1. (Was `emphasis`; renamed to avoid
97
+ * colliding with `layout.emphasis`.) */
98
+ readonly weight?: number
99
+ }>
100
+ /** A "+"-joined set of "none" | "scale" | "border" | "dim" | …, eased over durationMs. */
101
+ readonly emphasis?: { readonly style: string; readonly durationMs: number }
102
+ /** Into THIS segment. "cut" | "pan" | "zoom" consume no time; "xfade:<id>" overlaps (D17).
103
+ * `durationMs` never consumes timeline time for the non-overlap types: it
104
+ * is the tween length for `pan` / `zoom` and is ignored for `cut`. */
105
+ readonly transition?: { readonly type: string; readonly durationMs?: number }
106
+ }
107
+
108
+ export interface EdlSegment {
109
+ readonly id: string
110
+ /** On the MASTER clock. */
111
+ readonly inMs: number
112
+ /** Exclusive, > inMs. */
113
+ readonly outMs: number
114
+ /** `EdlSource.id` supplying picture. Omit = audio-only EDL. */
115
+ readonly video?: string
116
+ /** `EdlSource.id` supplying sound. Default: the unique `role:"master-audio"` source, else `video`. */
117
+ readonly audio?: string
118
+ /** Dominant speaker label (informational). */
119
+ readonly speaker?: string
120
+ /** Into THIS segment. Only "crossfade" consumes time (D17); never on segments[0].
121
+ * `durationMs` is optional and inert for "cut". */
122
+ readonly transition?: { readonly type: "cut" | "crossfade"; readonly durationMs?: number }
123
+ /** Per-segment crop override — SINGLE-slot only (D20). */
124
+ readonly region?: EdlRegion
125
+ readonly layout?: EdlLayout
126
+ /** Free tags: "hook", "chapter:2", … (removed spans live in `Edl.dropped`). */
127
+ readonly labels?: readonly string[]
128
+ }
129
+
130
+ export interface EdlDropped {
131
+ readonly inMs: number
132
+ readonly outMs: number
133
+ readonly reason: "silence" | "filler" | "false-start" | "tangent" | "manual" | string
134
+ }
135
+
136
+ export interface Edl {
137
+ readonly version: 1
138
+ /** D19: are `segments` on the source master clock, or an already-rendered output clock? */
139
+ readonly clock: "master" | "output"
140
+ readonly sources: readonly EdlSource[]
141
+ /** The ordered output timeline. Output time = cumulative segment durations, less overlap transitions. */
142
+ readonly segments: readonly EdlSegment[]
143
+ readonly dropped?: readonly EdlDropped[]
144
+ /** Set when this EDL was re-cut from a rendered output (speaker-view after apply-edl). */
145
+ readonly derivedFrom?: { readonly edlId: string; readonly clock: "output" }
146
+ readonly meta?: {
147
+ readonly title?: string
148
+ readonly hook?: string
149
+ /** Known aspects: `EDL_TARGET_ASPECTS`. Open (`string & {}`) so an EDL
150
+ * written by a newer producer still type-checks — an unknown aspect is a
151
+ * validation WARNING (validateEdl), never an issue. */
152
+ readonly targetAspect?: EdlTargetAspect | (string & {})
153
+ readonly platform?: string
154
+ readonly notes?: string
155
+ }
156
+ }
157
+
158
+ /** The `clips` mode's response/SDK shape. On the canvas the node emits a bare
159
+ * `Edl[]` (T5: the `list` fan-out reads a top-level JSON array), and the
160
+ * cloud relay writes `output_data` as this object — the unwrap to the bare
161
+ * array happens in the output extractors, never in `output_data`. */
162
+ export interface EdlClipSet {
163
+ readonly version: 1
164
+ readonly clips: readonly Edl[]
165
+ }
166
+
167
+ /** The `chapters` mode's `output_data` shape — a plain `data` list. */
168
+ export interface ChapterSet {
169
+ readonly version: 1
170
+ readonly chapters: ReadonlyArray<{ readonly startMs: number; readonly title: string }>
171
+ }
172
+
173
+ /** The normalized JSON form of a transcribe result. */
174
+ export interface Transcript {
175
+ readonly version: 1
176
+ /** D19: which `EdlSource` this transcript was made from (drives the offset in remap). */
177
+ readonly sourceId?: string
178
+ readonly language?: string
179
+ readonly words: ReadonlyArray<{
180
+ readonly text: string
181
+ readonly startMs: number
182
+ readonly endMs: number
183
+ readonly speaker?: string
184
+ readonly confidence?: number
185
+ }>
186
+ readonly segments?: ReadonlyArray<{
187
+ readonly startMs: number
188
+ readonly endMs: number
189
+ readonly text: string
190
+ readonly speaker?: string
191
+ }>
192
+ }
193
+
194
+ /** Duration (seconds) implied by a transcript — the LATEST word/segment `endMs`
195
+ * across the whole transcript, in seconds. The edit-plan reserve's duration
196
+ * fallback BENEATH the master-source ffprobe (`computeEditPlanReserveId`): the
197
+ * authoritative reserve basis is a probe of the master media, exactly like the
198
+ * plugin route; this transcript clock is used only in `buildPayload` (which
199
+ * cannot ffprobe) and when that probe can't run, for a MASTER source node that
200
+ * exposes no length of its own (a `reference-audio`/youtube or direct-URL
201
+ * master carries its length in neither `data.duration` nor
202
+ * `metadata.durationSeconds` — see `editPlanSourceDurationSec` — and its live
203
+ * orchestrator output is a bare URL). The transcript is a REQUIRED edit-plan
204
+ * input and is the timing map of that same master, so its last word's `endMs`
205
+ * is a lower bound on the source's own clock.
206
+ *
207
+ * Accepts `unknown` because the cloud plugin's Zod is the transcript's schema
208
+ * authority; this reads defensively and returns `undefined` for any shape it
209
+ * can't measure (so the caller falls back to the ceiling bucket — the safe
210
+ * over-reserve direction). Takes the MAX endMs rather than the last element so
211
+ * an out-of-order words array can't under-report. NOTE the direction: a
212
+ * transcript's last spoken word ends at or before the true media end (trailing
213
+ * music/silence is not transcribed), so this can UNDER-estimate; bucket
214
+ * round-up is the headroom, and the cloud re-probe money-gate refuses (never
215
+ * overcharges) if the probed master still exceeds the reserved bucket. */
216
+ export function transcriptDurationSec(transcript: unknown): number | undefined {
217
+ if (!transcript || typeof transcript !== "object") return undefined
218
+ const t = transcript as { words?: unknown; segments?: unknown }
219
+ let maxEndMs = 0
220
+ const scan = (rows: unknown): void => {
221
+ if (!Array.isArray(rows)) return
222
+ for (const row of rows) {
223
+ const endMs = (row as { endMs?: unknown } | null)?.endMs
224
+ if (typeof endMs === "number" && Number.isFinite(endMs) && endMs > maxEndMs) {
225
+ maxEndMs = endMs
226
+ }
227
+ }
228
+ }
229
+ // Max over BOTH words AND segments — a segment tail can extend past the last
230
+ // word (mirrors the plugin's own `transcriptDurationMs`, which maxes both), so
231
+ // scanning segments only when words is empty would under-report.
232
+ scan(t.words)
233
+ scan(t.segments)
234
+ return maxEndMs > 0 ? maxEndMs / 1000 : undefined
235
+ }
236
+
237
+ // ─────────────────────────────────────────────────────────────────────────
238
+ // Pure functions
239
+ // ─────────────────────────────────────────────────────────────────────────
240
+
241
+ /** Is this segment-transition an OVERLAP (time-consuming) one? Only a
242
+ * crossfade is. */
243
+ function segmentTransitionOverlaps(t: EdlSegment["transition"]): boolean {
244
+ return !!t && t.type === "crossfade" && (t.durationMs ?? 0) > 0
245
+ }
246
+
247
+ /** Is this layout-transition an OVERLAP one? Only the `xfade:*` family
248
+ * (a real cross-source blend) — THE one rule, `speakerSwitchOverlaps`;
249
+ * `cut`/`pan`/`zoom` consume no output time. */
250
+ function layoutTransitionOverlaps(t: EdlLayout["transition"]): boolean {
251
+ return !!t && speakerSwitchOverlaps(t.type) && (t.durationMs ?? 0) > 0
252
+ }
253
+
254
+ /** The overlap duration consumed at the boundary INTO this segment (D17).
255
+ * A segment may carry at most one of the two transition fields
256
+ * (validated), so we take whichever is present. */
257
+ function overlapMsInto(seg: EdlSegment): number {
258
+ if (segmentTransitionOverlaps(seg.transition)) return seg.transition!.durationMs ?? 0
259
+ if (layoutTransitionOverlaps(seg.layout?.transition)) return seg.layout!.transition!.durationMs ?? 0
260
+ return 0
261
+ }
262
+
263
+ /** Rendered duration of the edit, in ms. Σ segment durations minus the
264
+ * overlap transitions (D17: an `xfade`/`crossfade` compresses the timeline
265
+ * by its duration per boundary; `cut`/`pan`/`zoom` do not). Total over BOTH
266
+ * the segment- and layout-transition fields.
267
+ *
268
+ * Derived FROM `segmentOutputStarts` so the invariant
269
+ * `outputStart(last) + dur(last) === edlDurationMs` holds by construction on
270
+ * every input — including a normalized-but-not-yet-validated EDL (the reserve
271
+ * runs this before validate). The two must never be two independent overlap
272
+ * implementations that can drift. */
273
+ export function edlDurationMs(edl: Edl): number {
274
+ const starts = segmentOutputStarts(edl)
275
+ if (starts.length === 0) return 0
276
+ const last = edl.segments[edl.segments.length - 1]
277
+ return Math.max(0, Math.round(starts[starts.length - 1] + Math.max(0, last.outMs - last.inMs)))
278
+ }
279
+
280
+ /** The output-clock start time of each segment (index-aligned to
281
+ * `edl.segments`), accounting for D17 overlap: the transition INTO segment k
282
+ * pulls its duration back from the boundary. `outputStart(0) = 0`;
283
+ * `outputStart(n) + dur(n) === edlDurationMs`. */
284
+ function segmentOutputStarts(edl: Edl): number[] {
285
+ const starts: number[] = []
286
+ let cursor = 0
287
+ edl.segments.forEach((seg, i) => {
288
+ // The transition into THIS segment overlaps the previous one.
289
+ if (i > 0) cursor -= overlapMsInto(seg)
290
+ starts.push(Math.max(0, Math.round(cursor)))
291
+ cursor += Math.max(0, seg.outMs - seg.inMs)
292
+ })
293
+ return starts
294
+ }
295
+
296
+ const inRange = (v: number, lo = 0, hi = 1) => v >= lo && v <= hi
297
+
298
+ function regionIssues(r: EdlRegion, where: string): string[] {
299
+ const out: string[] = []
300
+ for (const [k, v] of Object.entries(r) as Array<[keyof EdlRegion, number]>) {
301
+ if (!Number.isFinite(v) || !inRange(v)) out.push(`${where}: region.${k}=${v} out of 0..1`)
302
+ }
303
+ if (r.w <= 0 || r.h <= 0) out.push(`${where}: region has non-positive w/h`)
304
+ if (r.x + r.w > 1 + 1e-9) out.push(`${where}: region extends past right edge (x+w>1)`)
305
+ if (r.y + r.h > 1 + 1e-9) out.push(`${where}: region extends past bottom edge (y+h>1)`)
306
+ return out
307
+ }
308
+
309
+ /** Library-produced (never construct one yourself — fields may be added). `ok` is `issues.length === 0`;
310
+ * warnings never flip it. Issues = facts intrinsic to the EDL. Warnings = judgements against a REGISTRY
311
+ * (source roles, speaker layouts/switches/emphasis, target aspects) that a newer version may widen — so an
312
+ * older validator never rejects a newer EDL. */
313
+ export interface EdlValidation {
314
+ readonly ok: boolean
315
+ readonly issues: readonly string[]
316
+ readonly warnings: readonly string[]
317
+ }
318
+
319
+ /** Structural validation. Coercion (defaults, clamping) is `normalizeEdl`'s
320
+ * job; this reports what is still wrong after normalization. Segment ORDER is
321
+ * the output timeline order and is NOT required to be monotonic on the master
322
+ * clock — a legitimate multicam/clips EDL revisits earlier source time — so
323
+ * the only per-segment ordering rule is `outMs > inMs`.
324
+ *
325
+ * The transition duration bound is the per-boundary ffmpeg-`xfade` limit: the
326
+ * blend must be shorter than the adjacent material, so we validate at
327
+ * `0.9 · min(the two adjacent segments)`. (combine-videos uses a more
328
+ * conservative GLOBAL `0.9 · min(all clips)`; the per-boundary bound here is
329
+ * the correct one, and the `apply-edl` executor must clamp per-boundary too —
330
+ * copying combine's global-min would let validate pass a transition the
331
+ * renderer then silently shortens, the R5 silent-edit this bound prevents.)
332
+ * It applies only to overlap transitions; `cut`/`pan`/`zoom` are unbounded. */
333
+ export function validateEdl(edl: Edl): EdlValidation {
334
+ const issues: string[] = []
335
+ const warnings: string[] = []
336
+
337
+ // A public validator reports, it does not throw: guard a raw object that
338
+ // skipped normalizeEdl (missing/typed-wrong sources/segments).
339
+ if (!Array.isArray(edl.segments) || !Array.isArray(edl.sources)) {
340
+ edl = {
341
+ ...edl,
342
+ sources: Array.isArray(edl.sources) ? edl.sources : [],
343
+ segments: Array.isArray(edl.segments) ? edl.segments : [],
344
+ }
345
+ }
346
+
347
+ if (edl.version !== EDL_VERSION) issues.push(`version must be ${EDL_VERSION}`)
348
+ if (edl.clock !== "master" && edl.clock !== "output") issues.push(`clock must be "master" or "output"`)
349
+
350
+ const sourceIds = new Set<string>()
351
+ let masterAudioCount = 0
352
+ for (const s of edl.sources) {
353
+ if (sourceIds.has(s.id)) issues.push(`duplicate source id "${s.id}"`)
354
+ sourceIds.add(s.id)
355
+ if (!s.url || !s.url.trim()) issues.push(`source "${s.id}": url is empty (media resolves from url)`)
356
+ if (s.role === "master-audio") masterAudioCount++
357
+ else if (s.role != null && !KNOWN_SOURCE_ROLES.has(s.role)) {
358
+ warnings.push(`source "${s.id}": unknown role "${s.role}" (known: ${EDL_SOURCE_ROLES.join(", ")})`)
359
+ }
360
+ if (s.region) issues.push(...regionIssues(s.region, `source "${s.id}"`))
361
+ if (s.offsetMs !== undefined && !Number.isFinite(s.offsetMs)) issues.push(`source "${s.id}": offsetMs not finite`)
362
+ }
363
+ if (masterAudioCount > 1) issues.push(`more than one source has role:"master-audio" (${masterAudioCount})`)
364
+
365
+ if (edl.segments.length === 0) issues.push("segments is empty")
366
+
367
+ edl.segments.forEach((seg, i) => {
368
+ const at = `segment[${i}] "${seg.id}"`
369
+ if (!(seg.outMs > seg.inMs)) issues.push(`${at}: outMs (${seg.outMs}) must be > inMs (${seg.inMs})`)
370
+ if (seg.inMs < 0) issues.push(`${at}: inMs negative`)
371
+ if (seg.video && !sourceIds.has(seg.video)) issues.push(`${at}: video source "${seg.video}" not in sources`)
372
+ else if (seg.video) {
373
+ const vs = edl.sources.find(s => s.id === seg.video)
374
+ if (vs && vs.kind !== "video") issues.push(`${at}: video source "${seg.video}" is kind:"${vs.kind}", must be video`)
375
+ }
376
+ if (seg.audio && !sourceIds.has(seg.audio)) issues.push(`${at}: audio source "${seg.audio}" not in sources`)
377
+ // A segment with no explicit audio must have a fallback: a master-audio source or its own video.
378
+ if (!seg.audio && masterAudioCount === 0 && !seg.video) {
379
+ issues.push(`${at}: no audio source and no master-audio/video fallback`)
380
+ }
381
+
382
+ // Transition rules.
383
+ if (i === 0 && (seg.transition || seg.layout?.transition)) {
384
+ issues.push(`${at}: segments[0] cannot have a transition ("into this segment" has no predecessor)`)
385
+ }
386
+ if (seg.transition && seg.layout?.transition) {
387
+ issues.push(`${at}: both EdlSegment.transition and EdlLayout.transition set (pick one)`)
388
+ }
389
+ // The 0.9·min(adjacent) bound is the ffmpeg-xfade constraint and applies
390
+ // ONLY to overlap transitions (crossfade / xfade:*). `cut`/`pan`/`zoom`
391
+ // consume no time (overlapMsInto === 0) and their durationMs is inert, so
392
+ // they are never bounded here.
393
+ const ov = overlapMsInto(seg)
394
+ if (ov > 0 && i > 0) {
395
+ const prev = edl.segments[i - 1]
396
+ const minAdj = Math.min(seg.outMs - seg.inMs, prev.outMs - prev.inMs)
397
+ if (ov > 0.9 * minAdj + 1e-9) {
398
+ issues.push(`${at}: overlap transition durationMs (${ov}) exceeds 0.9·min(adjacent segment)=${(0.9 * minAdj).toFixed(1)} — ffmpeg xfade would error / be clamped`)
399
+ }
400
+ }
401
+
402
+ // Region rules (D20).
403
+ if (seg.region) {
404
+ issues.push(...regionIssues(seg.region, at))
405
+ if (seg.layout?.slots && seg.layout.slots.length > 1) {
406
+ issues.push(`${at}: segment.region is invalid when the layout has >1 slot (put the region on the slot)`)
407
+ }
408
+ }
409
+
410
+ // Layout slot rules.
411
+ for (const slot of seg.layout?.slots ?? []) {
412
+ if (!sourceIds.has(slot.source)) issues.push(`${at}: slot source "${slot.source}" not in sources`)
413
+ else {
414
+ const src = edl.sources.find(s => s.id === slot.source)
415
+ if (src && src.kind !== "video") issues.push(`${at}: slot source "${slot.source}" is kind:"${src.kind}", slots must be video`)
416
+ }
417
+ if (slot.weight !== undefined && !inRange(slot.weight)) issues.push(`${at}: slot.weight=${slot.weight} out of 0..1`)
418
+ if (slot.region) issues.push(...regionIssues(slot.region, `${at} slot "${slot.source}"`))
419
+ }
420
+ })
421
+
422
+ // segments ∩ dropped = ∅ (on the master clock).
423
+ for (const d of edl.dropped ?? []) {
424
+ if (!(d.outMs > d.inMs)) issues.push(`dropped range [${d.inMs},${d.outMs}) is not positive`)
425
+ for (const seg of edl.segments) {
426
+ if (seg.inMs < d.outMs && d.inMs < seg.outMs) {
427
+ issues.push(`dropped range [${d.inMs},${d.outMs}) overlaps kept segment "${seg.id}" [${seg.inMs},${seg.outMs})`)
428
+ break
429
+ }
430
+ }
431
+ }
432
+
433
+ // Registry-class findings (layouts, switches, emphasis, target aspect).
434
+ warnings.push(...speakerPresentationWarnings(edl))
435
+
436
+ return { ok: issues.length === 0, issues, warnings }
437
+ }
438
+
439
+ /** Validate a clip set (the `clips` mode output). */
440
+ export function validateEdlClipSet(set: EdlClipSet): EdlValidation {
441
+ const issues: string[] = []
442
+ const warnings: string[] = []
443
+ if (set.version !== EDL_VERSION) issues.push(`clipset version must be ${EDL_VERSION}`)
444
+ if (set.clips.length === 0) issues.push("clipset has no clips")
445
+ set.clips.forEach((clip, i) => {
446
+ const r = validateEdl(clip)
447
+ if (!r.ok) issues.push(...r.issues.map(m => `clip[${i}]: ${m}`))
448
+ warnings.push(...r.warnings.map(m => `clip[${i}]: ${m}`))
449
+ })
450
+ return { ok: issues.length === 0, issues, warnings }
451
+ }
452
+
453
+ function offsetFor(edl: Edl, sourceId: string | undefined): number {
454
+ if (!sourceId) return 0
455
+ return edl.sources.find(s => s.id === sourceId)?.offsetMs ?? 0
456
+ }
457
+
458
+ /**
459
+ * Map an instant on `sourceId`'s clock to the rendered output clock, or `null`
460
+ * if that instant was dropped (falls in no kept segment). Applies the D19
461
+ * offset (`masterMs = sourceMs + offsetMs`) and the D17 overlap compression.
462
+ * `sourceId` omitted ⇒ the instant is already on the master clock.
463
+ */
464
+ export function remapMsThroughEdl(edl: Edl, sourceMs: number, sourceId?: string): number | null {
465
+ const masterMs = sourceMs + offsetFor(edl, sourceId)
466
+ const starts = segmentOutputStarts(edl)
467
+ for (let i = 0; i < edl.segments.length; i++) {
468
+ const seg = edl.segments[i]
469
+ if (masterMs >= seg.inMs && masterMs < seg.outMs) {
470
+ return Math.round(starts[i] + (masterMs - seg.inMs))
471
+ }
472
+ }
473
+ return null
474
+ }
475
+
476
+ /**
477
+ * Remap a transcript onto the rendered output: drop words that fall entirely
478
+ * in removed material, clip a word that straddles a cut to its kept part, and
479
+ * apply the source offset (via `transcript.sourceId`). Captions, chapters and
480
+ * clip offsets all depend on this one function.
481
+ */
482
+ export function remapTranscriptThroughEdl(edl: Edl, transcript: Transcript): Transcript {
483
+ const off = offsetFor(edl, transcript.sourceId)
484
+ const starts = segmentOutputStarts(edl)
485
+
486
+ const mapWord = (w: Transcript["words"][number]): (Transcript["words"][number]) | null => {
487
+ const startMaster = w.startMs + off
488
+ const endMaster = w.endMs + off
489
+ // A zero-width word is a point: keep it if the instant is kept (consistent
490
+ // with remapMsThroughEdl, which returns non-null for a kept instant).
491
+ if (startMaster === endMaster) {
492
+ for (let i = 0; i < edl.segments.length; i++) {
493
+ const seg = edl.segments[i]
494
+ if (startMaster >= seg.inMs && startMaster < seg.outMs) {
495
+ const out = Math.round(starts[i] + (startMaster - seg.inMs))
496
+ return { ...w, startMs: out, endMs: out }
497
+ }
498
+ }
499
+ return null
500
+ }
501
+ // Find the first kept segment that intersects [startMaster, endMaster) and
502
+ // clip the word to that kept part.
503
+ for (let i = 0; i < edl.segments.length; i++) {
504
+ const seg = edl.segments[i]
505
+ const lo = Math.max(startMaster, seg.inMs)
506
+ const hi = Math.min(endMaster, seg.outMs)
507
+ if (lo < hi) {
508
+ return {
509
+ ...w,
510
+ startMs: Math.round(starts[i] + (lo - seg.inMs)),
511
+ endMs: Math.round(starts[i] + (hi - seg.inMs)),
512
+ }
513
+ }
514
+ }
515
+ return null
516
+ }
517
+
518
+ const words: Transcript["words"][number][] = []
519
+ for (const w of transcript.words) {
520
+ const mapped = mapWord(w)
521
+ if (mapped) words.push(mapped)
522
+ }
523
+
524
+ // A transcript SEGMENT (chapter-ish span) can straddle a cut and/or several
525
+ // kept ranges. Take the ENVELOPE of every kept intersection: the earliest
526
+ // kept output start to the latest kept output end. Endpoint-probing (map
527
+ // start and end independently) inverts across a crossfade and collapses a
528
+ // straddler to ~1ms; the envelope avoids both.
529
+ const mapSegment = (s: NonNullable<Transcript["segments"]>[number]) => {
530
+ const startMaster = s.startMs + off
531
+ const endMaster = s.endMs + off
532
+ let outLo: number | null = null
533
+ let outHi: number | null = null
534
+ for (let i = 0; i < edl.segments.length; i++) {
535
+ const seg = edl.segments[i]
536
+ const lo = Math.max(startMaster, seg.inMs)
537
+ const hi = Math.min(endMaster, seg.outMs)
538
+ if (lo < hi) {
539
+ const a = Math.round(starts[i] + (lo - seg.inMs))
540
+ const b = Math.round(starts[i] + (hi - seg.inMs))
541
+ if (outLo === null || a < outLo) outLo = a
542
+ if (outHi === null || b > outHi) outHi = b
543
+ }
544
+ }
545
+ if (outLo === null || outHi === null) return null
546
+ return { ...s, startMs: outLo, endMs: outHi }
547
+ }
548
+
549
+ const segments = transcript.segments
550
+ ?.map(mapSegment)
551
+ .filter((s): s is NonNullable<typeof s> => s !== null)
552
+
553
+ return { ...transcript, words, ...(segments ? { segments } : {}) }
554
+ }
555
+
556
+ /** Speaker turns: consecutive same-speaker words merged across gaps shorter
557
+ * than `mergeGapMs`, then turns shorter than `minTurnMs` dropped. */
558
+ export function speakerTurns(
559
+ transcript: Transcript,
560
+ opts: { minTurnMs: number; mergeGapMs: number },
561
+ ): Array<{ speaker: string; startMs: number; endMs: number }> {
562
+ const turns: Array<{ speaker: string; startMs: number; endMs: number }> = []
563
+ for (const w of transcript.words) {
564
+ const speaker = w.speaker ?? "spk"
565
+ const last = turns[turns.length - 1]
566
+ if (last && last.speaker === speaker && w.startMs - last.endMs <= opts.mergeGapMs) {
567
+ last.endMs = Math.max(last.endMs, w.endMs)
568
+ } else {
569
+ turns.push({ speaker, startMs: w.startMs, endMs: w.endMs })
570
+ }
571
+ }
572
+ return turns.filter(t => t.endMs - t.startMs >= opts.minTurnMs)
573
+ }
574
+
575
+ // ─────────────────────────────────────────────────────────────────────────
576
+ // Normalization (coerce, never reject; runs at every write site)
577
+ // ─────────────────────────────────────────────────────────────────────────
578
+
579
+ const num = (v: unknown, fallback = 0): number => (typeof v === "number" && Number.isFinite(v) ? v : fallback)
580
+ const clamp01 = (v: number) => Math.min(1, Math.max(0, v))
581
+ const str = (v: unknown): string | undefined => (typeof v === "string" ? v : undefined)
582
+
583
+ function normalizeRegion(r: unknown): EdlRegion | undefined {
584
+ if (!r || typeof r !== "object") return undefined
585
+ const o = r as Record<string, unknown>
586
+ if (["x", "y", "w", "h"].some(k => typeof o[k] !== "number")) return undefined
587
+ const x = clamp01(num(o.x))
588
+ const y = clamp01(num(o.y))
589
+ // Fit the box inside the frame so the result always passes validateEdl
590
+ // (per-axis clamping alone can leave x+w>1). A degenerate box is dropped.
591
+ const w = Math.min(clamp01(num(o.w)), 1 - x)
592
+ const h = Math.min(clamp01(num(o.h)), 1 - y)
593
+ if (w <= 0 || h <= 0) return undefined
594
+ return { x, y, w, h }
595
+ }
596
+
597
+ /**
598
+ * Coerce an unknown value into a well-formed `Edl`: fill defaults, drop unknown
599
+ * fields, clamp regions to 0..1. All times are integer ms — there is NO
600
+ * seconds→ms guessing (an integer-seconds EDL would silently become a
601
+ * 1000×-longer edit; the contract is ms-only and a value that cannot be a
602
+ * plausible ms EDL is a validation error, not a reinterpretation).
603
+ *
604
+ * Segment ORDER is preserved verbatim: it is the output timeline order, and a
605
+ * multicam/clips EDL legitimately revisits earlier source time, so re-sorting
606
+ * would corrupt it. (`dropped` has no ordering semantics and is left as-is.)
607
+ */
608
+ export function normalizeEdl(input: unknown): Edl {
609
+ const o = (input && typeof input === "object" ? input : {}) as Record<string, unknown>
610
+
611
+ const sources: EdlSource[] = Array.isArray(o.sources)
612
+ ? o.sources.map((raw, i) => {
613
+ const s = (raw ?? {}) as Record<string, unknown>
614
+ const src: EdlSource = {
615
+ id: str(s.id) ?? `src-${i}`,
616
+ url: str(s.url) ?? "",
617
+ kind: s.kind === "audio" ? "audio" : "video",
618
+ ...(s.offsetMs !== undefined ? { offsetMs: Math.round(num(s.offsetMs)) } : {}),
619
+ ...(str(s.role) ? { role: s.role as EdlSource["role"] } : {}),
620
+ ...(Array.isArray(s.speakers) ? { speakers: s.speakers.filter((x): x is string => typeof x === "string") } : {}),
621
+ ...(normalizeRegion(s.region) ? { region: normalizeRegion(s.region) } : {}),
622
+ }
623
+ return src
624
+ })
625
+ : []
626
+
627
+ const segments: EdlSegment[] = Array.isArray(o.segments)
628
+ ? o.segments.map((raw, i) => {
629
+ const s = (raw ?? {}) as Record<string, unknown>
630
+ const region = normalizeRegion(s.region)
631
+ // A transition "into" segments[0] carries no information (no
632
+ // predecessor), so strip it here rather than letting validateEdl 400 a
633
+ // fixable EDL — LLM/hand-written EDLs routinely attach one to every
634
+ // segment. Same for a layout transition on the first segment.
635
+ const isFirst = i === 0
636
+ const t = isFirst ? undefined : (s.transition as Record<string, unknown> | undefined)
637
+ const layout = normalizeLayout(s.layout, isFirst)
638
+ const seg: EdlSegment = {
639
+ id: str(s.id) ?? `seg-${i}`,
640
+ inMs: Math.round(num(s.inMs)),
641
+ outMs: Math.round(num(s.outMs)),
642
+ ...(str(s.video) ? { video: str(s.video) } : {}),
643
+ ...(str(s.audio) ? { audio: str(s.audio) } : {}),
644
+ ...(str(s.speaker) ? { speaker: str(s.speaker) } : {}),
645
+ ...(t && (t.type === "cut" || t.type === "crossfade")
646
+ ? { transition: { type: t.type as "cut" | "crossfade", durationMs: t.type === "cut" ? 0 : Math.round(num(t.durationMs)) } }
647
+ : {}),
648
+ ...(region ? { region } : {}),
649
+ ...(layout ? { layout } : {}),
650
+ ...(Array.isArray(s.labels) ? { labels: s.labels.filter((x): x is string => typeof x === "string") } : {}),
651
+ }
652
+ return seg
653
+ })
654
+ : []
655
+
656
+ const dropped: EdlDropped[] | undefined = Array.isArray(o.dropped)
657
+ ? o.dropped.map(raw => {
658
+ const d = (raw ?? {}) as Record<string, unknown>
659
+ return { inMs: Math.round(num(d.inMs)), outMs: Math.round(num(d.outMs)), reason: str(d.reason) ?? "manual" }
660
+ })
661
+ : undefined
662
+
663
+ const meta = o.meta && typeof o.meta === "object" ? (o.meta as Edl["meta"]) : undefined
664
+
665
+ return {
666
+ version: EDL_VERSION,
667
+ clock: o.clock === "output" ? "output" : "master",
668
+ sources,
669
+ segments,
670
+ ...(dropped ? { dropped } : {}),
671
+ ...(o.derivedFrom && typeof o.derivedFrom === "object"
672
+ ? { derivedFrom: { edlId: str((o.derivedFrom as Record<string, unknown>).edlId) ?? "", clock: "output" } }
673
+ : {}),
674
+ ...(meta ? { meta } : {}),
675
+ }
676
+ }
677
+
678
+ function normalizeLayout(input: unknown, dropTransition = false): EdlLayout | undefined {
679
+ if (!input || typeof input !== "object") return undefined
680
+ const o = input as Record<string, unknown>
681
+ // Default the mode to "single" rather than dropping the whole layout — a
682
+ // layout with slots/transition but no mode would otherwise lose them (and an
683
+ // omitted xfade would silently lengthen the timeline).
684
+ const mode = str(o.mode) || "single"
685
+ const slots = Array.isArray(o.slots)
686
+ ? o.slots
687
+ .map(raw => {
688
+ const s = (raw ?? {}) as Record<string, unknown>
689
+ const source = str(s.source)
690
+ if (!source) return null
691
+ const region = normalizeRegion(s.region)
692
+ return {
693
+ source,
694
+ ...(region ? { region } : {}),
695
+ ...(str(s.speaker) ? { speaker: str(s.speaker) } : {}),
696
+ ...(s.weight !== undefined ? { weight: clamp01(num(s.weight)) } : {}),
697
+ }
698
+ })
699
+ .filter((x): x is NonNullable<typeof x> => x !== null)
700
+ : undefined
701
+ const emphasis = o.emphasis && typeof o.emphasis === "object"
702
+ ? { style: str((o.emphasis as Record<string, unknown>).style) ?? "none", durationMs: Math.round(num((o.emphasis as Record<string, unknown>).durationMs)) }
703
+ : undefined
704
+ const transition = !dropTransition && o.transition && typeof o.transition === "object"
705
+ ? { type: str((o.transition as Record<string, unknown>).type) ?? "cut", durationMs: Math.round(num((o.transition as Record<string, unknown>).durationMs)) }
706
+ : undefined
707
+ return {
708
+ mode,
709
+ ...(slots ? { slots } : {}),
710
+ ...(emphasis ? { emphasis } : {}),
711
+ ...(transition ? { transition } : {}),
712
+ }
713
+ }
714
+
715
+ /** Coerce an unknown value into a `Transcript`. ms-only, same rule as `normalizeEdl`. */
716
+ export function normalizeTranscript(input: unknown): Transcript {
717
+ const o = (input && typeof input === "object" ? input : {}) as Record<string, unknown>
718
+ const words: Transcript["words"][number][] = Array.isArray(o.words)
719
+ ? o.words.map(raw => {
720
+ const w = (raw ?? {}) as Record<string, unknown>
721
+ const startMs = Math.round(num(w.startMs))
722
+ return {
723
+ text: str(w.text) ?? "",
724
+ startMs,
725
+ // Never inverted: an endMs < startMs (garbage upstream) would be
726
+ // silently dropped at remap; clamp it to a non-negative width.
727
+ endMs: Math.max(startMs, Math.round(num(w.endMs))),
728
+ ...(str(w.speaker) ? { speaker: str(w.speaker) } : {}),
729
+ ...(typeof w.confidence === "number" ? { confidence: w.confidence } : {}),
730
+ }
731
+ })
732
+ : []
733
+ const segments = Array.isArray(o.segments)
734
+ ? o.segments.map(raw => {
735
+ const s = (raw ?? {}) as Record<string, unknown>
736
+ const startMs = Math.round(num(s.startMs))
737
+ return { startMs, endMs: Math.max(startMs, Math.round(num(s.endMs))), text: str(s.text) ?? "", ...(str(s.speaker) ? { speaker: str(s.speaker) } : {}) }
738
+ })
739
+ : undefined
740
+ return {
741
+ version: EDL_VERSION,
742
+ ...(str(o.sourceId) ? { sourceId: str(o.sourceId) } : {}),
743
+ ...(str(o.language) ? { language: str(o.language) } : {}),
744
+ words,
745
+ ...(segments ? { segments } : {}),
746
+ }
747
+ }