@nodaro/shared 3.10.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.
- package/dist/index.cjs +2242 -86
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1779 -32
- package/dist/index.d.ts +1779 -32
- package/dist/index.js +2041 -87
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/caption-styles.test.ts +207 -0
- package/src/__tests__/edl-multicam.test.ts +304 -0
- package/src/__tests__/edl.test.ts +822 -0
- package/src/__tests__/fan-out-rows.test.ts +208 -0
- package/src/__tests__/instagram-scrape.test.ts +66 -0
- package/src/__tests__/llm-models.test.ts +48 -11
- package/src/__tests__/meta-ads-scrape.test.ts +284 -0
- package/src/__tests__/node-runtime-keys.test.ts +15 -0
- package/src/__tests__/parameter-node-value.test.ts +13 -1
- package/src/__tests__/presentation-utils.test.ts +67 -0
- package/src/__tests__/producer-types.test.ts +19 -0
- package/src/__tests__/schedule-rules.test.ts +265 -0
- package/src/__tests__/speaker-layouts.test.ts +203 -0
- package/src/__tests__/transcribe-capabilities.test.ts +104 -0
- package/src/__tests__/transcribe-preflight.test.ts +60 -0
- package/src/__tests__/trigger-feeds.test.ts +39 -0
- package/src/__tests__/video-analysis.test.ts +15 -0
- package/src/__tests__/video-duration-auto.test.ts +65 -0
- package/src/__tests__/video-duration.test.ts +56 -0
- package/src/__tests__/video-frame-fit.test.ts +189 -0
- package/src/__tests__/video-link.test.ts +137 -0
- package/src/__tests__/workflow-export-strip.test.ts +59 -1
- package/src/caption-styles.ts +240 -0
- package/src/catalog-projection.ts +3 -0
- package/src/character-motion-metadata.ts +19 -0
- package/src/credit-identifiers.ts +31 -0
- package/src/edit-plan-contract.ts +96 -0
- package/src/edl-multicam.ts +185 -0
- package/src/edl.ts +747 -0
- package/src/entity-image-handle.ts +24 -1
- package/src/fan-out-rows.ts +213 -0
- package/src/i18n/character-motion.ar.ts +126 -75
- package/src/i18n/character-motion.de.ts +126 -75
- package/src/i18n/character-motion.es.ts +126 -75
- package/src/i18n/character-motion.fr.ts +126 -75
- package/src/i18n/character-motion.he.ts +126 -75
- package/src/i18n/character-motion.hi.ts +126 -75
- package/src/i18n/character-motion.ja.ts +126 -75
- package/src/i18n/character-motion.ko.ts +126 -75
- package/src/i18n/character-motion.pt-BR.ts +126 -75
- package/src/i18n/character-motion.ru.ts +126 -75
- package/src/i18n/character-motion.zh-CN.ts +126 -75
- package/src/index.ts +211 -3
- package/src/instagram-scrape.ts +204 -0
- package/src/llm-models.ts +80 -3
- package/src/meta-ads-scrape.ts +463 -0
- package/src/model-catalog.ts +48 -5
- package/src/model-constants.ts +148 -5
- package/src/node-mappable-fields.ts +2 -0
- package/src/node-runtime-keys.ts +28 -0
- package/src/parameter-node-value.ts +31 -5
- package/src/presentation-utils.ts +49 -0
- package/src/producer-types.ts +20 -0
- package/src/schedule-rules.ts +484 -0
- package/src/speaker-layouts.ts +220 -0
- package/src/transcribe-preflight.ts +101 -0
- package/src/trigger-feeds.ts +59 -0
- package/src/trigger-node-types.ts +20 -0
- package/src/video-analysis.ts +15 -0
- package/src/video-duration-auto.ts +18 -0
- package/src/video-duration.ts +32 -0
- package/src/video-frame-fit.ts +228 -0
- package/src/video-link.ts +167 -0
- package/src/video-output-canvas.ts +119 -0
- 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
|
+
}
|