@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.
- package/dist/index.cjs +2047 -84
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1556 -27
- package/dist/index.d.ts +1556 -27
- package/dist/index.js +1861 -85
- 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__/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-duration-auto.test.ts +65 -0
- package/src/__tests__/video-duration.test.ts +56 -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/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/index.ts +206 -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/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-duration-auto.ts +18 -0
- package/src/video-duration.ts +32 -0
- package/src/video-link.ts +167 -0
- package/src/workflow-export.ts +37 -1
|
@@ -0,0 +1,822 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest"
|
|
2
|
+
import {
|
|
3
|
+
EDL_VERSION,
|
|
4
|
+
edlDurationMs,
|
|
5
|
+
validateEdl,
|
|
6
|
+
validateEdlClipSet,
|
|
7
|
+
remapMsThroughEdl,
|
|
8
|
+
remapTranscriptThroughEdl,
|
|
9
|
+
speakerTurns,
|
|
10
|
+
normalizeEdl,
|
|
11
|
+
normalizeTranscript,
|
|
12
|
+
transcriptDurationSec,
|
|
13
|
+
type Edl,
|
|
14
|
+
type Transcript,
|
|
15
|
+
} from "../edl.js"
|
|
16
|
+
import {
|
|
17
|
+
unwrapEditPlanOutput,
|
|
18
|
+
buildEditPlanCreditId,
|
|
19
|
+
editPlanBucketMinutes,
|
|
20
|
+
clampEditPlanClipCount,
|
|
21
|
+
EDIT_PLAN_DEFAULT_CLIP_COUNT,
|
|
22
|
+
EDIT_PLAN_MAX_CLIP_COUNT,
|
|
23
|
+
} from "../edit-plan-contract.js"
|
|
24
|
+
import { editPlanSourceDurationSec } from "../video-duration.js"
|
|
25
|
+
|
|
26
|
+
/** A minimal valid single-camera tighten EDL: two kept spans of the master. */
|
|
27
|
+
function tightenEdl(): Edl {
|
|
28
|
+
return {
|
|
29
|
+
version: 1,
|
|
30
|
+
clock: "master",
|
|
31
|
+
sources: [{ id: "master", url: "https://x/master.mp4", kind: "video", role: "master-audio" }],
|
|
32
|
+
segments: [
|
|
33
|
+
{ id: "s0", inMs: 0, outMs: 5000, video: "master" },
|
|
34
|
+
{ id: "s1", inMs: 8000, outMs: 12000, video: "master" }, // 3000-8000 dropped
|
|
35
|
+
],
|
|
36
|
+
dropped: [{ inMs: 5000, outMs: 8000, reason: "silence" }],
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe("edlDurationMs (D17 overlap)", () => {
|
|
41
|
+
it("sums segment durations with no transitions", () => {
|
|
42
|
+
expect(edlDurationMs(tightenEdl())).toBe(9000) // 5000 + 4000
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it("subtracts a crossfade transition (overlap compresses the timeline)", () => {
|
|
46
|
+
const edl: Edl = {
|
|
47
|
+
...tightenEdl(),
|
|
48
|
+
segments: [
|
|
49
|
+
{ id: "s0", inMs: 0, outMs: 5000, video: "master" },
|
|
50
|
+
{ id: "s1", inMs: 8000, outMs: 12000, video: "master", transition: { type: "crossfade", durationMs: 1000 } },
|
|
51
|
+
],
|
|
52
|
+
}
|
|
53
|
+
expect(edlDurationMs(edl)).toBe(8000) // 9000 - 1000 overlap
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it("does NOT subtract a cut", () => {
|
|
57
|
+
const edl: Edl = {
|
|
58
|
+
...tightenEdl(),
|
|
59
|
+
segments: [
|
|
60
|
+
{ id: "s0", inMs: 0, outMs: 5000, video: "master" },
|
|
61
|
+
{ id: "s1", inMs: 8000, outMs: 12000, video: "master", transition: { type: "cut", durationMs: 0 } },
|
|
62
|
+
],
|
|
63
|
+
}
|
|
64
|
+
expect(edlDurationMs(edl)).toBe(9000)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it("subtracts an xfade:* layout transition but not pan/zoom", () => {
|
|
68
|
+
const base = tightenEdl()
|
|
69
|
+
const withXfade: Edl = {
|
|
70
|
+
...base,
|
|
71
|
+
sources: [
|
|
72
|
+
{ id: "a", url: "https://x/a.mp4", kind: "video", role: "master-audio" },
|
|
73
|
+
{ id: "b", url: "https://x/b.mp4", kind: "video" },
|
|
74
|
+
],
|
|
75
|
+
segments: [
|
|
76
|
+
{ id: "s0", inMs: 0, outMs: 5000, video: "a" },
|
|
77
|
+
{ id: "s1", inMs: 8000, outMs: 12000, video: "b", layout: { mode: "single", transition: { type: "xfade:slide-left", durationMs: 800 } } },
|
|
78
|
+
],
|
|
79
|
+
dropped: undefined,
|
|
80
|
+
}
|
|
81
|
+
expect(edlDurationMs(withXfade)).toBe(8200)
|
|
82
|
+
const withPan: Edl = {
|
|
83
|
+
...withXfade,
|
|
84
|
+
segments: [
|
|
85
|
+
withXfade.segments[0],
|
|
86
|
+
{ ...withXfade.segments[1], layout: { mode: "single", transition: { type: "pan", durationMs: 800 } } },
|
|
87
|
+
],
|
|
88
|
+
}
|
|
89
|
+
expect(edlDurationMs(withPan)).toBe(9000)
|
|
90
|
+
})
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
describe("remapMsThroughEdl", () => {
|
|
94
|
+
it("maps kept instants to the compacted output clock and drops removed ones", () => {
|
|
95
|
+
const edl = tightenEdl()
|
|
96
|
+
expect(remapMsThroughEdl(edl, 0)).toBe(0)
|
|
97
|
+
expect(remapMsThroughEdl(edl, 4999)).toBe(4999)
|
|
98
|
+
expect(remapMsThroughEdl(edl, 6000)).toBeNull() // dropped span
|
|
99
|
+
expect(remapMsThroughEdl(edl, 8000)).toBe(5000) // start of 2nd segment lands right after the 1st
|
|
100
|
+
expect(remapMsThroughEdl(edl, 11999)).toBe(8999)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it("is monotonic over kept instants", () => {
|
|
104
|
+
const edl = tightenEdl()
|
|
105
|
+
let prev = -1
|
|
106
|
+
for (const ms of [0, 1000, 2500, 4999, 8000, 9000, 11999]) {
|
|
107
|
+
const out = remapMsThroughEdl(edl, ms)
|
|
108
|
+
if (out === null) continue
|
|
109
|
+
expect(out).toBeGreaterThan(prev)
|
|
110
|
+
prev = out
|
|
111
|
+
}
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it("applies the D19 source offset (masterMs = sourceMs + offsetMs)", () => {
|
|
115
|
+
const edl: Edl = {
|
|
116
|
+
version: 1,
|
|
117
|
+
clock: "master",
|
|
118
|
+
sources: [
|
|
119
|
+
{ id: "master", url: "https://x/m.mp4", kind: "video", role: "master-audio" },
|
|
120
|
+
{ id: "cam", url: "https://x/c.mp4", kind: "video", offsetMs: 1200 },
|
|
121
|
+
],
|
|
122
|
+
segments: [{ id: "s0", inMs: 0, outMs: 10000, video: "master" }],
|
|
123
|
+
}
|
|
124
|
+
// A cam instant at 0 is at master 1200.
|
|
125
|
+
expect(remapMsThroughEdl(edl, 0, "cam")).toBe(1200)
|
|
126
|
+
// Without a sourceId the instant is already on the master clock.
|
|
127
|
+
expect(remapMsThroughEdl(edl, 0)).toBe(0)
|
|
128
|
+
})
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
describe("remapTranscriptThroughEdl", () => {
|
|
132
|
+
it("drops words in removed spans and clips straddlers; identity+offset round-trips", () => {
|
|
133
|
+
const edl = tightenEdl()
|
|
134
|
+
const t: Transcript = {
|
|
135
|
+
version: 1,
|
|
136
|
+
words: [
|
|
137
|
+
{ text: "keep", startMs: 100, endMs: 400 },
|
|
138
|
+
{ text: "gone", startMs: 6000, endMs: 6500 }, // dropped span
|
|
139
|
+
{ text: "back", startMs: 8100, endMs: 8500 },
|
|
140
|
+
],
|
|
141
|
+
}
|
|
142
|
+
const out = remapTranscriptThroughEdl(edl, t)
|
|
143
|
+
expect(out.words.map(w => w.text)).toEqual(["keep", "back"])
|
|
144
|
+
expect(out.words[0]).toMatchObject({ startMs: 100, endMs: 400 })
|
|
145
|
+
expect(out.words[1]).toMatchObject({ startMs: 5100, endMs: 5500 })
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it("applies the offset via transcript.sourceId (no silent drift)", () => {
|
|
149
|
+
const edl: Edl = {
|
|
150
|
+
version: 1,
|
|
151
|
+
clock: "master",
|
|
152
|
+
sources: [
|
|
153
|
+
{ id: "master", url: "https://x/m.mp4", kind: "video", role: "master-audio" },
|
|
154
|
+
{ id: "cam", url: "https://x/c.mp4", kind: "video", offsetMs: 1000 },
|
|
155
|
+
],
|
|
156
|
+
segments: [{ id: "s0", inMs: 0, outMs: 10000, video: "master" }],
|
|
157
|
+
}
|
|
158
|
+
// A word at cam-time 500 is at master 1500 → output 1500 (identity segment).
|
|
159
|
+
const t: Transcript = { version: 1, sourceId: "cam", words: [{ text: "hi", startMs: 500, endMs: 900 }] }
|
|
160
|
+
const out = remapTranscriptThroughEdl(edl, t)
|
|
161
|
+
expect(out.words[0]).toMatchObject({ startMs: 1500, endMs: 1900 })
|
|
162
|
+
})
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
describe("validateEdl", () => {
|
|
166
|
+
it("accepts a well-formed tighten EDL", () => {
|
|
167
|
+
expect(validateEdl(tightenEdl())).toEqual({ ok: true, issues: [], warnings: [] })
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
it("rejects outMs <= inMs, unknown source ids, and dropped∩segments", () => {
|
|
171
|
+
const bad: Edl = {
|
|
172
|
+
version: 1,
|
|
173
|
+
clock: "master",
|
|
174
|
+
sources: [{ id: "m", url: "x", kind: "video", role: "master-audio" }],
|
|
175
|
+
segments: [
|
|
176
|
+
{ id: "s0", inMs: 0, outMs: 0, video: "m" },
|
|
177
|
+
{ id: "s1", inMs: 100, outMs: 200, video: "ghost" },
|
|
178
|
+
],
|
|
179
|
+
dropped: [{ inMs: 150, outMs: 180, reason: "silence" }],
|
|
180
|
+
}
|
|
181
|
+
const r = validateEdl(bad)
|
|
182
|
+
expect(r.ok).toBe(false)
|
|
183
|
+
expect(r.issues.join("\n")).toMatch(/outMs .* must be > inMs/)
|
|
184
|
+
expect(r.issues.join("\n")).toMatch(/video source "ghost" not in sources/)
|
|
185
|
+
expect(r.issues.join("\n")).toMatch(/overlaps kept segment/)
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
it("forbids a transition on segments[0] and both transition fields on one segment", () => {
|
|
189
|
+
const e1: Edl = { ...tightenEdl(), segments: [{ id: "s0", inMs: 0, outMs: 5000, video: "master", transition: { type: "crossfade", durationMs: 100 } }, { id: "s1", inMs: 8000, outMs: 12000, video: "master" }], dropped: undefined }
|
|
190
|
+
expect(validateEdl(e1).issues.join("\n")).toMatch(/segments\[0\] cannot have a transition/)
|
|
191
|
+
const e2: Edl = { ...tightenEdl(), segments: [{ id: "s0", inMs: 0, outMs: 5000, video: "master" }, { id: "s1", inMs: 8000, outMs: 12000, video: "master", transition: { type: "crossfade", durationMs: 100 }, layout: { mode: "single", transition: { type: "xfade:x", durationMs: 100 } } }], dropped: undefined }
|
|
192
|
+
expect(validateEdl(e2).issues.join("\n")).toMatch(/both EdlSegment.transition and EdlLayout.transition/)
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
it("enforces the 0.9*min(adjacent) transition bound (the executor's real clamp)", () => {
|
|
196
|
+
// adjacent min duration is 4000 → bound is 3600. 3800 must fail; 3600 must pass.
|
|
197
|
+
const over: Edl = { ...tightenEdl(), segments: [{ id: "s0", inMs: 0, outMs: 5000, video: "master" }, { id: "s1", inMs: 8000, outMs: 12000, video: "master", transition: { type: "crossfade", durationMs: 3800 } }], dropped: undefined }
|
|
198
|
+
expect(validateEdl(over).ok).toBe(false)
|
|
199
|
+
const okEdl: Edl = { ...over, segments: [over.segments[0], { ...over.segments[1], transition: { type: "crossfade", durationMs: 3600 } }] }
|
|
200
|
+
expect(validateEdl(okEdl).ok).toBe(true)
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
it("rejects segment.region when the layout has >1 slot (D20)", () => {
|
|
204
|
+
const e: Edl = {
|
|
205
|
+
version: 1,
|
|
206
|
+
clock: "master",
|
|
207
|
+
sources: [{ id: "a", url: "x", kind: "video", role: "master-audio" }, { id: "b", url: "y", kind: "video" }],
|
|
208
|
+
segments: [{ id: "s0", inMs: 0, outMs: 1000, video: "a", region: { x: 0, y: 0, w: 0.5, h: 0.5 }, layout: { mode: "side-by-side", slots: [{ source: "a" }, { source: "b" }] } }],
|
|
209
|
+
}
|
|
210
|
+
expect(validateEdl(e).issues.join("\n")).toMatch(/segment.region is invalid when the layout has >1 slot/)
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
it("rejects >1 master-audio and a non-video slot source", () => {
|
|
214
|
+
const e: Edl = {
|
|
215
|
+
version: 1,
|
|
216
|
+
clock: "master",
|
|
217
|
+
sources: [
|
|
218
|
+
{ id: "a", url: "x", kind: "video", role: "master-audio" },
|
|
219
|
+
{ id: "b", url: "y", kind: "audio", role: "master-audio" },
|
|
220
|
+
],
|
|
221
|
+
segments: [{ id: "s0", inMs: 0, outMs: 1000, video: "a", layout: { mode: "pip", slots: [{ source: "b" }] } }],
|
|
222
|
+
}
|
|
223
|
+
const r = validateEdl(e)
|
|
224
|
+
expect(r.issues.join("\n")).toMatch(/more than one source has role:"master-audio"/)
|
|
225
|
+
expect(r.issues.join("\n")).toMatch(/slots must be video/)
|
|
226
|
+
})
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
describe("validateEdlClipSet", () => {
|
|
230
|
+
it("accepts a set of valid clips and reports a bad one", () => {
|
|
231
|
+
const good = { version: 1 as const, clips: [tightenEdl(), tightenEdl()] }
|
|
232
|
+
expect(validateEdlClipSet(good).ok).toBe(true)
|
|
233
|
+
const bad = { version: 1 as const, clips: [tightenEdl(), { ...tightenEdl(), segments: [] }] }
|
|
234
|
+
const r = validateEdlClipSet(bad)
|
|
235
|
+
expect(r.ok).toBe(false)
|
|
236
|
+
expect(r.issues.join("\n")).toMatch(/clip\[1\]: segments is empty/)
|
|
237
|
+
})
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
describe("speakerTurns", () => {
|
|
241
|
+
it("merges within the gap and drops short turns", () => {
|
|
242
|
+
const t: Transcript = {
|
|
243
|
+
version: 1,
|
|
244
|
+
words: [
|
|
245
|
+
{ text: "a", startMs: 0, endMs: 300, speaker: "host" },
|
|
246
|
+
{ text: "b", startMs: 400, endMs: 900, speaker: "host" }, // gap 100 < merge
|
|
247
|
+
{ text: "c", startMs: 2000, endMs: 2100, speaker: "guest" }, // 100ms turn, dropped
|
|
248
|
+
{ text: "d", startMs: 3000, endMs: 5000, speaker: "guest" },
|
|
249
|
+
],
|
|
250
|
+
}
|
|
251
|
+
const turns = speakerTurns(t, { minTurnMs: 500, mergeGapMs: 200 })
|
|
252
|
+
expect(turns).toEqual([
|
|
253
|
+
{ speaker: "host", startMs: 0, endMs: 900 },
|
|
254
|
+
{ speaker: "guest", startMs: 3000, endMs: 5000 },
|
|
255
|
+
])
|
|
256
|
+
})
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
describe("normalizeEdl", () => {
|
|
260
|
+
it("fills defaults, clamps regions, and is ms-only (no unit guessing)", () => {
|
|
261
|
+
const n = normalizeEdl({
|
|
262
|
+
segments: [{ inMs: 5, outMs: 12, region: { x: -1, y: 0.5, w: 2, h: 0.5 } }],
|
|
263
|
+
sources: [{ id: "m", url: "u", kind: "video" }],
|
|
264
|
+
})
|
|
265
|
+
expect(n.version).toBe(EDL_VERSION)
|
|
266
|
+
expect(n.clock).toBe("master")
|
|
267
|
+
// ms preserved verbatim — an integer-seconds-looking value is NOT rescaled.
|
|
268
|
+
expect(n.segments[0]).toMatchObject({ inMs: 5, outMs: 12 })
|
|
269
|
+
expect(n.segments[0].region).toEqual({ x: 0, y: 0.5, w: 1, h: 0.5 })
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
it("preserves segment order verbatim (no destructive re-sort)", () => {
|
|
273
|
+
const n = normalizeEdl({ segments: [{ id: "b", inMs: 900, outMs: 1000 }, { id: "a", inMs: 0, outMs: 100 }] })
|
|
274
|
+
expect(n.segments.map(s => s.id)).toEqual(["b", "a"])
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
it("renames nothing but carries slots[].weight (D20)", () => {
|
|
278
|
+
const n = normalizeEdl({ segments: [{ inMs: 0, outMs: 100, layout: { mode: "grid", slots: [{ source: "a", weight: 1 }, { source: "b", weight: 2 }] } }] })
|
|
279
|
+
expect(n.segments[0].layout?.slots).toEqual([{ source: "a", weight: 1 }, { source: "b", weight: 1 }])
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
it("round-trips a normalized EDL through validateEdl cleanly", () => {
|
|
283
|
+
const n = normalizeEdl(tightenEdl())
|
|
284
|
+
expect(validateEdl(n).ok).toBe(true)
|
|
285
|
+
})
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
describe("normalizeTranscript", () => {
|
|
289
|
+
it("coerces words to ms and carries sourceId", () => {
|
|
290
|
+
const t = normalizeTranscript({ sourceId: "cam", words: [{ text: "hi", startMs: 1.6, endMs: 2.4 }] })
|
|
291
|
+
expect(t.version).toBe(EDL_VERSION)
|
|
292
|
+
expect(t.sourceId).toBe("cam")
|
|
293
|
+
expect(t.words[0]).toEqual({ text: "hi", startMs: 2, endMs: 2 })
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
it("never produces an inverted word (endMs >= startMs)", () => {
|
|
297
|
+
const t = normalizeTranscript({ words: [{ text: "x", startMs: 500, endMs: 100 }] })
|
|
298
|
+
expect(t.words[0].endMs).toBeGreaterThanOrEqual(t.words[0].startMs)
|
|
299
|
+
expect(t.words[0].endMs).toBe(500)
|
|
300
|
+
})
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
// ── Fixes from the 2026-09-17 adversarial review ──────────────────────────
|
|
304
|
+
|
|
305
|
+
/** A 3-segment fixture with two crossfades (exercises cumulative overlap). */
|
|
306
|
+
function threeSegOverlap(): Edl {
|
|
307
|
+
return {
|
|
308
|
+
version: 1,
|
|
309
|
+
clock: "master",
|
|
310
|
+
sources: [{ id: "m", url: "https://x/m.mp4", kind: "video", role: "master-audio" }],
|
|
311
|
+
segments: [
|
|
312
|
+
{ id: "s0", inMs: 0, outMs: 4000, video: "m" },
|
|
313
|
+
{ id: "s1", inMs: 4000, outMs: 8000, video: "m", transition: { type: "crossfade", durationMs: 500 } },
|
|
314
|
+
{ id: "s2", inMs: 8000, outMs: 12000, video: "m", transition: { type: "crossfade", durationMs: 500 } },
|
|
315
|
+
],
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
describe("edlDurationMs ↔ segmentOutputStarts invariant", () => {
|
|
320
|
+
it("remap(lastSeg.outMs-1)+1 === edlDurationMs under cumulative overlap", () => {
|
|
321
|
+
const edl = threeSegOverlap()
|
|
322
|
+
expect(edlDurationMs(edl)).toBe(11000) // 12000 - 2*500
|
|
323
|
+
const last = edl.segments[edl.segments.length - 1]
|
|
324
|
+
const endOut = remapMsThroughEdl(edl, last.outMs - 1)!
|
|
325
|
+
expect(endOut + 1).toBe(edlDurationMs(edl))
|
|
326
|
+
})
|
|
327
|
+
|
|
328
|
+
it("stays consistent even if a transition is (wrongly) on segments[0]", () => {
|
|
329
|
+
// normalizeEdl strips it; but even on the raw object the two overlap paths must agree.
|
|
330
|
+
const raw: Edl = {
|
|
331
|
+
...threeSegOverlap(),
|
|
332
|
+
segments: [
|
|
333
|
+
{ id: "s0", inMs: 0, outMs: 4000, video: "m", transition: { type: "crossfade", durationMs: 500 } },
|
|
334
|
+
{ id: "s1", inMs: 4000, outMs: 8000, video: "m" },
|
|
335
|
+
],
|
|
336
|
+
}
|
|
337
|
+
const last = raw.segments[raw.segments.length - 1]
|
|
338
|
+
expect(remapMsThroughEdl(raw, last.outMs - 1)! + 1).toBe(edlDurationMs(raw))
|
|
339
|
+
})
|
|
340
|
+
})
|
|
341
|
+
|
|
342
|
+
describe("remapTranscriptThroughEdl segments (envelope, not endpoint-probe)", () => {
|
|
343
|
+
it("does not collapse a segment straddling into dropped material", () => {
|
|
344
|
+
const edl = tightenEdl() // s0[0,5000), dropped[5000,8000), s1[8000,12000)
|
|
345
|
+
const t: Transcript = { version: 1, words: [], segments: [{ startMs: 4000, endMs: 6000, text: "spanning" }] }
|
|
346
|
+
const out = remapTranscriptThroughEdl(edl, t).segments!
|
|
347
|
+
// kept portion is [4000,5000) master → output [4000,5000), NOT a 1ms stub
|
|
348
|
+
expect(out[0]).toMatchObject({ startMs: 4000, endMs: 5000 })
|
|
349
|
+
})
|
|
350
|
+
|
|
351
|
+
it("never inverts across a crossfade boundary", () => {
|
|
352
|
+
const edl: Edl = {
|
|
353
|
+
version: 1,
|
|
354
|
+
clock: "master",
|
|
355
|
+
sources: [{ id: "m", url: "u", kind: "video", role: "master-audio" }],
|
|
356
|
+
segments: [
|
|
357
|
+
{ id: "s0", inMs: 0, outMs: 5000, video: "m" },
|
|
358
|
+
{ id: "s1", inMs: 5000, outMs: 9000, video: "m", transition: { type: "crossfade", durationMs: 1000 } },
|
|
359
|
+
],
|
|
360
|
+
}
|
|
361
|
+
const t: Transcript = { version: 1, words: [], segments: [{ startMs: 4700, endMs: 5300, text: "x" }] }
|
|
362
|
+
const out = remapTranscriptThroughEdl(edl, t).segments![0]
|
|
363
|
+
expect(out.endMs).toBeGreaterThanOrEqual(out.startMs)
|
|
364
|
+
})
|
|
365
|
+
|
|
366
|
+
it("spans both when a segment straddles two contiguous kept segments", () => {
|
|
367
|
+
const edl: Edl = {
|
|
368
|
+
version: 1,
|
|
369
|
+
clock: "master",
|
|
370
|
+
sources: [{ id: "m", url: "u", kind: "video", role: "master-audio" }],
|
|
371
|
+
segments: [
|
|
372
|
+
{ id: "s0", inMs: 0, outMs: 5000, video: "m" },
|
|
373
|
+
{ id: "s1", inMs: 5000, outMs: 9000, video: "m" },
|
|
374
|
+
],
|
|
375
|
+
}
|
|
376
|
+
const t: Transcript = { version: 1, words: [], segments: [{ startMs: 4000, endMs: 6000, text: "x" }] }
|
|
377
|
+
const out = remapTranscriptThroughEdl(edl, t).segments![0]
|
|
378
|
+
expect(out).toMatchObject({ startMs: 4000, endMs: 6000 })
|
|
379
|
+
})
|
|
380
|
+
})
|
|
381
|
+
|
|
382
|
+
describe("remap boundary exclusivity & range", () => {
|
|
383
|
+
it("outMs is exclusive; out-of-range instants are null", () => {
|
|
384
|
+
const edl = tightenEdl()
|
|
385
|
+
expect(remapMsThroughEdl(edl, 5000)).toBeNull() // == s0.outMs (exclusive) and inside the dropped span
|
|
386
|
+
expect(remapMsThroughEdl(edl, -1)).toBeNull()
|
|
387
|
+
expect(remapMsThroughEdl(edl, 999_999)).toBeNull()
|
|
388
|
+
})
|
|
389
|
+
|
|
390
|
+
it("keeps a zero-width word sitting in kept material", () => {
|
|
391
|
+
const edl = tightenEdl()
|
|
392
|
+
const t: Transcript = { version: 1, words: [{ text: "pt", startMs: 2000, endMs: 2000 }] }
|
|
393
|
+
const out = remapTranscriptThroughEdl(edl, t)
|
|
394
|
+
expect(out.words).toHaveLength(1)
|
|
395
|
+
expect(out.words[0]).toMatchObject({ startMs: 2000, endMs: 2000 })
|
|
396
|
+
})
|
|
397
|
+
})
|
|
398
|
+
|
|
399
|
+
describe("validateEdl — transition bound applies only to overlap types", () => {
|
|
400
|
+
it("accepts a long pan/zoom/cut (no xfade, no time consumed)", () => {
|
|
401
|
+
const mk = (type: string): Edl => ({
|
|
402
|
+
version: 1,
|
|
403
|
+
clock: "master",
|
|
404
|
+
sources: [{ id: "m", url: "u", kind: "video", role: "master-audio" }],
|
|
405
|
+
segments: [
|
|
406
|
+
{ id: "s0", inMs: 0, outMs: 10000, video: "m" },
|
|
407
|
+
{ id: "s1", inMs: 10000, outMs: 20000, video: "m", layout: { mode: "single", transition: { type, durationMs: 9500 } } },
|
|
408
|
+
],
|
|
409
|
+
})
|
|
410
|
+
expect(validateEdl(mk("pan")).ok).toBe(true)
|
|
411
|
+
expect(validateEdl(mk("zoom")).ok).toBe(true)
|
|
412
|
+
const cut: Edl = {
|
|
413
|
+
version: 1,
|
|
414
|
+
clock: "master",
|
|
415
|
+
sources: [{ id: "m", url: "u", kind: "video", role: "master-audio" }],
|
|
416
|
+
segments: [
|
|
417
|
+
{ id: "s0", inMs: 0, outMs: 300, video: "m" },
|
|
418
|
+
{ id: "s1", inMs: 300, outMs: 600, video: "m", transition: { type: "cut", durationMs: 5000 } },
|
|
419
|
+
],
|
|
420
|
+
}
|
|
421
|
+
expect(validateEdl(cut).ok).toBe(true) // a cut's durationMs is inert
|
|
422
|
+
})
|
|
423
|
+
|
|
424
|
+
it("still rejects an over-long crossfade with the 0.9·min message", () => {
|
|
425
|
+
const over: Edl = { ...tightenEdl(), segments: [{ id: "s0", inMs: 0, outMs: 5000, video: "master" }, { id: "s1", inMs: 8000, outMs: 12000, video: "master", transition: { type: "crossfade", durationMs: 3800 } }], dropped: undefined }
|
|
426
|
+
expect(validateEdl(over).issues.join("\n")).toMatch(/exceeds 0.9·min/)
|
|
427
|
+
})
|
|
428
|
+
})
|
|
429
|
+
|
|
430
|
+
describe("validateEdl — more branches", () => {
|
|
431
|
+
it("rejects duplicate source id, wrong version, bad clock, audio-typed video, empty url", () => {
|
|
432
|
+
const dup: Edl = { ...tightenEdl(), sources: [{ id: "master", url: "u", kind: "video", role: "master-audio" }, { id: "master", url: "u2", kind: "video" }] }
|
|
433
|
+
expect(validateEdl(dup).issues.join("\n")).toMatch(/duplicate source id/)
|
|
434
|
+
const badV = { ...tightenEdl(), version: 2 } as unknown as Edl
|
|
435
|
+
expect(validateEdl(badV).ok).toBe(false)
|
|
436
|
+
const badClock = { ...tightenEdl(), clock: "wall" } as unknown as Edl
|
|
437
|
+
expect(validateEdl(badClock).issues.join("\n")).toMatch(/clock must be/)
|
|
438
|
+
const audioVideo: Edl = {
|
|
439
|
+
version: 1, clock: "master",
|
|
440
|
+
sources: [{ id: "a", url: "u", kind: "audio", role: "master-audio" }, { id: "v", url: "w", kind: "video" }],
|
|
441
|
+
segments: [{ id: "s", inMs: 0, outMs: 1000, video: "a" }],
|
|
442
|
+
}
|
|
443
|
+
expect(validateEdl(audioVideo).issues.join("\n")).toMatch(/video source "a" is kind:"audio"/)
|
|
444
|
+
const emptyUrl: Edl = { version: 1, clock: "master", sources: [{ id: "m", url: "", kind: "video", role: "master-audio" }], segments: [{ id: "s", inMs: 0, outMs: 1000, video: "m" }] }
|
|
445
|
+
expect(validateEdl(emptyUrl).issues.join("\n")).toMatch(/url is empty/)
|
|
446
|
+
})
|
|
447
|
+
|
|
448
|
+
it("accepts a segment whose audio falls back to the master-audio source", () => {
|
|
449
|
+
const edl: Edl = {
|
|
450
|
+
version: 1, clock: "master",
|
|
451
|
+
sources: [{ id: "aud", url: "u", kind: "audio", role: "master-audio" }, { id: "cam", url: "w", kind: "video" }],
|
|
452
|
+
segments: [{ id: "s", inMs: 0, outMs: 1000, video: "cam" }], // no explicit audio → master-audio
|
|
453
|
+
}
|
|
454
|
+
expect(validateEdl(edl).ok).toBe(true)
|
|
455
|
+
})
|
|
456
|
+
|
|
457
|
+
it("rejects a segment with no audio and no master-audio/video fallback", () => {
|
|
458
|
+
const edl: Edl = {
|
|
459
|
+
version: 1, clock: "master",
|
|
460
|
+
sources: [{ id: "aud", url: "u", kind: "audio" }], // no master-audio role
|
|
461
|
+
segments: [{ id: "s", inMs: 0, outMs: 1000 }], // no audio, no video
|
|
462
|
+
}
|
|
463
|
+
expect(validateEdl(edl).issues.join("\n")).toMatch(/no audio source and no master-audio\/video fallback/)
|
|
464
|
+
})
|
|
465
|
+
})
|
|
466
|
+
|
|
467
|
+
describe("normalizeEdl — coerce never reject (round-trip through validateEdl)", () => {
|
|
468
|
+
it("handles degenerate input without throwing", () => {
|
|
469
|
+
for (const x of [{}, null, "x", 42, [], { segments: null }]) {
|
|
470
|
+
const n = normalizeEdl(x)
|
|
471
|
+
expect(n.version).toBe(EDL_VERSION)
|
|
472
|
+
expect(n.clock).toBe("master")
|
|
473
|
+
expect(Array.isArray(n.segments)).toBe(true)
|
|
474
|
+
}
|
|
475
|
+
expect(validateEdl(normalizeEdl({})).issues.join("\n")).toMatch(/segments is empty/)
|
|
476
|
+
})
|
|
477
|
+
|
|
478
|
+
it("fits a region that per-axis clamping alone would leave out of the frame", () => {
|
|
479
|
+
const n = normalizeEdl({ sources: [{ id: "m", url: "u", kind: "video", role: "master-audio" }], segments: [{ id: "s", inMs: 0, outMs: 1000, video: "m", region: { x: 0.9, y: 0.9, w: 0.5, h: 0.5 } }] })
|
|
480
|
+
expect(validateEdl(n).ok).toBe(true)
|
|
481
|
+
const r = n.segments[0].region!
|
|
482
|
+
expect(r.x + r.w).toBeLessThanOrEqual(1)
|
|
483
|
+
expect(r.y + r.h).toBeLessThanOrEqual(1)
|
|
484
|
+
})
|
|
485
|
+
|
|
486
|
+
it("defaults a layout with no mode to \"single\" and keeps its slots + transition", () => {
|
|
487
|
+
const n = normalizeEdl({ segments: [{ id: "a", inMs: 0, outMs: 100 }, { id: "s", inMs: 100, outMs: 200, layout: { slots: [{ source: "a" }, { source: "b" }], transition: { type: "xfade:x", durationMs: 50 } } }] })
|
|
488
|
+
expect(n.segments[1].layout?.mode).toBe("single")
|
|
489
|
+
expect(n.segments[1].layout?.slots).toHaveLength(2)
|
|
490
|
+
expect(n.segments[1].layout?.transition).toMatchObject({ type: "xfade:x" })
|
|
491
|
+
})
|
|
492
|
+
|
|
493
|
+
it("strips a transition on segments[0] so validateEdl does not reject a fixable EDL", () => {
|
|
494
|
+
const n = normalizeEdl({
|
|
495
|
+
sources: [{ id: "m", url: "u", kind: "video", role: "master-audio" }],
|
|
496
|
+
segments: [
|
|
497
|
+
{ id: "s0", inMs: 0, outMs: 5000, video: "m", transition: { type: "crossfade", durationMs: 100 } },
|
|
498
|
+
{ id: "s1", inMs: 8000, outMs: 12000, video: "m" },
|
|
499
|
+
],
|
|
500
|
+
})
|
|
501
|
+
expect(n.segments[0].transition).toBeUndefined()
|
|
502
|
+
expect(validateEdl(n).ok).toBe(true)
|
|
503
|
+
})
|
|
504
|
+
|
|
505
|
+
it("property: a normalized EDL whose only inherent defect is empty-segments/unresolved-ids validates otherwise", () => {
|
|
506
|
+
// A grab-bag of repairable garbage; normalize must not throw, and any
|
|
507
|
+
// remaining issues must be ones normalize genuinely cannot repair.
|
|
508
|
+
const samples = [
|
|
509
|
+
{ clock: "weird", sources: [{ id: "m", url: "u", kind: "video", role: "master-audio" }], segments: [{ inMs: 0, outMs: 1000, video: "m", region: { x: 2, y: -1, w: 5, h: 5 } }] },
|
|
510
|
+
{ sources: [{ id: "m", url: "u", kind: "video", role: "master-audio" }], segments: [{ inMs: 0, outMs: 1000, video: "m", transition: { type: "crossfade", durationMs: 1e9 } }] },
|
|
511
|
+
]
|
|
512
|
+
for (const s of samples) {
|
|
513
|
+
const n = normalizeEdl(s)
|
|
514
|
+
const r = validateEdl(n)
|
|
515
|
+
// These specific samples are repairable to valid (region fitted, seg[0]
|
|
516
|
+
// transition stripped) — so they should validate clean.
|
|
517
|
+
expect(r.ok).toBe(true)
|
|
518
|
+
}
|
|
519
|
+
})
|
|
520
|
+
})
|
|
521
|
+
|
|
522
|
+
describe("validateEdlClipSet with a diverse set", () => {
|
|
523
|
+
it("validates a mixed-length clip set", () => {
|
|
524
|
+
const clipA = tightenEdl()
|
|
525
|
+
const clipB: Edl = { version: 1, clock: "master", sources: clipA.sources, segments: [{ id: "c", inMs: 0, outMs: 3000, video: "master" }], meta: { hook: "watch this" } }
|
|
526
|
+
expect(validateEdlClipSet({ version: 1, clips: [clipA, clipB] }).ok).toBe(true)
|
|
527
|
+
})
|
|
528
|
+
})
|
|
529
|
+
|
|
530
|
+
describe("transition.durationMs is optional (inert for non-overlap types)", () => {
|
|
531
|
+
it("accepts a cut with no durationMs and counts no overlap", () => {
|
|
532
|
+
const edl: Edl = {
|
|
533
|
+
version: 1,
|
|
534
|
+
clock: "master",
|
|
535
|
+
sources: [{ id: "m", url: "u", kind: "video", role: "master-audio" }],
|
|
536
|
+
segments: [
|
|
537
|
+
{ id: "s0", inMs: 0, outMs: 5000, video: "m" },
|
|
538
|
+
{ id: "s1", inMs: 8000, outMs: 12000, video: "m", transition: { type: "cut" } },
|
|
539
|
+
],
|
|
540
|
+
}
|
|
541
|
+
expect(validateEdl(edl).ok).toBe(true)
|
|
542
|
+
expect(edlDurationMs(edl)).toBe(9000)
|
|
543
|
+
})
|
|
544
|
+
|
|
545
|
+
it("normalizeEdl zeroes a cut's durationMs (inert)", () => {
|
|
546
|
+
const n = normalizeEdl({
|
|
547
|
+
sources: [{ id: "m", url: "u", kind: "video", role: "master-audio" }],
|
|
548
|
+
segments: [
|
|
549
|
+
{ id: "s0", inMs: 0, outMs: 5000, video: "m" },
|
|
550
|
+
{ id: "s1", inMs: 8000, outMs: 12000, video: "m", transition: { type: "cut", durationMs: 5000 } },
|
|
551
|
+
],
|
|
552
|
+
})
|
|
553
|
+
expect(n.segments[1].transition).toEqual({ type: "cut", durationMs: 0 })
|
|
554
|
+
})
|
|
555
|
+
|
|
556
|
+
it("validateEdl does not throw on a raw object that skipped normalize", () => {
|
|
557
|
+
expect(() => validateEdl({ version: 1, clock: "master" } as unknown as Edl)).not.toThrow()
|
|
558
|
+
expect(validateEdl({ version: 1, clock: "master" } as unknown as Edl).ok).toBe(false)
|
|
559
|
+
})
|
|
560
|
+
})
|
|
561
|
+
|
|
562
|
+
describe("unwrapEditPlanOutput — the app-side clips/tighten/chapters unwrap", () => {
|
|
563
|
+
it("clips: EdlClipSet { version, clips: Edl[] } → the BARE Edl[] (fans out)", () => {
|
|
564
|
+
const clips = [tightenEdl(), tightenEdl()]
|
|
565
|
+
const out = unwrapEditPlanOutput({ version: 1, clips, viaNodaroCloud: true })
|
|
566
|
+
// The bare array, ready for the `list` fan-out (Array.isArray on generatedJson);
|
|
567
|
+
// each element is one Edl a downstream `edl` input normalizeEdl-parses.
|
|
568
|
+
expect(Array.isArray(out)).toBe(true)
|
|
569
|
+
expect(out).toEqual(clips)
|
|
570
|
+
// A round-trip through JSON.stringify (the fan-out per-item form) still parses
|
|
571
|
+
// to a valid EDL — the whole point of the bare-array shape.
|
|
572
|
+
const perItem = (out as Edl[]).map((c) => JSON.stringify(c))
|
|
573
|
+
expect(validateEdl(normalizeEdl(JSON.parse(perItem[0]))).ok).toBe(true)
|
|
574
|
+
})
|
|
575
|
+
|
|
576
|
+
it("chapters: { version, chapters } → the object minus bookkeeping", () => {
|
|
577
|
+
const chapters = [{ startMs: 0, title: "Intro" }, { startMs: 60000, title: "Topic" }]
|
|
578
|
+
const out = unwrapEditPlanOutput({ version: 1, chapters, viaNodaroCloud: true })
|
|
579
|
+
expect(out).toEqual({ version: EDL_VERSION, chapters })
|
|
580
|
+
expect(Array.isArray(out)).toBe(false)
|
|
581
|
+
})
|
|
582
|
+
|
|
583
|
+
it("tighten: the Edl at top level → the Edl, with viaNodaroCloud stripped", () => {
|
|
584
|
+
const edl = tightenEdl()
|
|
585
|
+
const out = unwrapEditPlanOutput({ ...edl, viaNodaroCloud: true })
|
|
586
|
+
expect(out).toEqual(edl)
|
|
587
|
+
expect((out as Record<string, unknown>).viaNodaroCloud).toBeUndefined()
|
|
588
|
+
})
|
|
589
|
+
|
|
590
|
+
it("passes a non-object through unchanged", () => {
|
|
591
|
+
expect(unwrapEditPlanOutput(undefined)).toBeUndefined()
|
|
592
|
+
expect(unwrapEditPlanOutput(null)).toBeNull()
|
|
593
|
+
})
|
|
594
|
+
})
|
|
595
|
+
|
|
596
|
+
describe("edit-plan credit-id scheme", () => {
|
|
597
|
+
it("rounds a probed duration UP to the covering bucket; unknown → the ceiling", () => {
|
|
598
|
+
expect(editPlanBucketMinutes(45 * 60)).toBe(60)
|
|
599
|
+
expect(editPlanBucketMinutes(60 * 60)).toBe(60)
|
|
600
|
+
expect(editPlanBucketMinutes(61 * 60)).toBe(90)
|
|
601
|
+
expect(editPlanBucketMinutes(10 * 3600)).toBe(180) // capped
|
|
602
|
+
expect(editPlanBucketMinutes(undefined)).toBe(180) // ceiling
|
|
603
|
+
})
|
|
604
|
+
|
|
605
|
+
it("builds `edit-plan:<mode>:<tier>:<bucket>m`", () => {
|
|
606
|
+
expect(buildEditPlanCreditId("tighten", "standard", 45 * 60)).toBe("edit-plan:tighten:standard:60m")
|
|
607
|
+
expect(buildEditPlanCreditId("clips", "premium", undefined)).toBe("edit-plan:clips:premium:180m")
|
|
608
|
+
})
|
|
609
|
+
})
|
|
610
|
+
|
|
611
|
+
describe("editPlanSourceDurationSec — audio-master lane (avoids the ceiling overbill)", () => {
|
|
612
|
+
it("reads an audio master's length from metadata.durationSeconds (upload-audio writes it there ONLY)", () => {
|
|
613
|
+
// A 45-minute podcast master → 2700s → the 60m bucket, NOT the 180m ceiling.
|
|
614
|
+
expect(editPlanSourceDurationSec({ metadata: { durationSeconds: 2700 } })).toBe(2700)
|
|
615
|
+
expect(buildEditPlanCreditId("tighten", "standard", editPlanSourceDurationSec({ metadata: { durationSeconds: 2700 } }))).toBe("edit-plan:tighten:standard:60m")
|
|
616
|
+
})
|
|
617
|
+
|
|
618
|
+
it("still prefers the video lane (generatedResults / data.duration) when present", () => {
|
|
619
|
+
expect(editPlanSourceDurationSec({ generatedResults: [{ duration: 120 }], activeResultIndex: 0 })).toBe(120)
|
|
620
|
+
expect(editPlanSourceDurationSec({ duration: 90 })).toBe(90)
|
|
621
|
+
})
|
|
622
|
+
|
|
623
|
+
it("returns undefined (→ ceiling bucket) when no duration is known", () => {
|
|
624
|
+
expect(editPlanSourceDurationSec({})).toBeUndefined()
|
|
625
|
+
expect(editPlanSourceDurationSec({ metadata: {} })).toBeUndefined()
|
|
626
|
+
expect(editPlanSourceDurationSec(undefined)).toBeUndefined()
|
|
627
|
+
})
|
|
628
|
+
})
|
|
629
|
+
|
|
630
|
+
describe("transcriptDurationSec — the URL-source reserve fallback (beneath the probe)", () => {
|
|
631
|
+
it("reads the source clock from the MAX word endMs", () => {
|
|
632
|
+
// A 59.4-min episode: latest word ends at 3,564,000 ms → 3564s → the 60m
|
|
633
|
+
// bucket, NOT the 180m ceiling (the reported over-reservation).
|
|
634
|
+
const t = { version: 1, words: [{ text: "hi", startMs: 0, endMs: 500 }, { text: "bye", startMs: 3_563_000, endMs: 3_564_000 }] }
|
|
635
|
+
expect(transcriptDurationSec(t)).toBe(3564)
|
|
636
|
+
expect(buildEditPlanCreditId("tighten", "standard", transcriptDurationSec(t))).toBe("edit-plan:tighten:standard:60m")
|
|
637
|
+
})
|
|
638
|
+
|
|
639
|
+
it("takes the MAX endMs so an out-of-order words array can't under-report", () => {
|
|
640
|
+
const t = { words: [{ endMs: 3_564_000 }, { endMs: 12_000 }, { endMs: 1_000 }] }
|
|
641
|
+
expect(transcriptDurationSec(t)).toBe(3564)
|
|
642
|
+
})
|
|
643
|
+
|
|
644
|
+
it("reads segments when a transcript carries no words", () => {
|
|
645
|
+
expect(transcriptDurationSec({ segments: [{ startMs: 0, endMs: 90_000 }] })).toBe(90)
|
|
646
|
+
})
|
|
647
|
+
|
|
648
|
+
it("takes the MAX over BOTH words and segments (a segment tail past the last word wins)", () => {
|
|
649
|
+
// The plugin's transcriptDurationMs maxes both; a words-only or
|
|
650
|
+
// segments-only-when-empty read would under-report the tail.
|
|
651
|
+
const t = {
|
|
652
|
+
words: [{ endMs: 3_540_000 }], // 59.0 min
|
|
653
|
+
segments: [{ startMs: 0, endMs: 3_600_000 }], // 60.0 min tail
|
|
654
|
+
}
|
|
655
|
+
expect(transcriptDurationSec(t)).toBe(3600)
|
|
656
|
+
})
|
|
657
|
+
|
|
658
|
+
it("returns undefined (→ ceiling, safe direction) for an empty / unmeasurable transcript", () => {
|
|
659
|
+
expect(transcriptDurationSec({ words: [] })).toBeUndefined()
|
|
660
|
+
expect(transcriptDurationSec({})).toBeUndefined()
|
|
661
|
+
expect(transcriptDurationSec(undefined)).toBeUndefined()
|
|
662
|
+
expect(transcriptDurationSec("not-an-object")).toBeUndefined()
|
|
663
|
+
expect(transcriptDurationSec({ words: [{ endMs: "x" }, { endMs: NaN }] })).toBeUndefined()
|
|
664
|
+
expect(buildEditPlanCreditId("tighten", "standard", transcriptDurationSec({ words: [] }))).toBe("edit-plan:tighten:standard:180m")
|
|
665
|
+
})
|
|
666
|
+
})
|
|
667
|
+
|
|
668
|
+
describe("clampEditPlanClipCount", () => {
|
|
669
|
+
it("clamps into [1, EDIT_PLAN_MAX_CLIP_COUNT] and floors", () => {
|
|
670
|
+
expect(clampEditPlanClipCount(5)).toBe(5)
|
|
671
|
+
expect(clampEditPlanClipCount(7.9)).toBe(7)
|
|
672
|
+
expect(clampEditPlanClipCount(0.4)).toBe(1)
|
|
673
|
+
expect(clampEditPlanClipCount(9000)).toBe(EDIT_PLAN_MAX_CLIP_COUNT)
|
|
674
|
+
})
|
|
675
|
+
it("is undefined for anything that is not a positive finite number", () => {
|
|
676
|
+
for (const bad of [undefined, null, 0, -3, Number.NaN, Number.POSITIVE_INFINITY, "12", {}]) {
|
|
677
|
+
expect(clampEditPlanClipCount(bad)).toBeUndefined()
|
|
678
|
+
}
|
|
679
|
+
})
|
|
680
|
+
it("the default sits inside the allowed range", () => {
|
|
681
|
+
expect(EDIT_PLAN_DEFAULT_CLIP_COUNT).toBeGreaterThanOrEqual(1)
|
|
682
|
+
expect(EDIT_PLAN_DEFAULT_CLIP_COUNT).toBeLessThanOrEqual(EDIT_PLAN_MAX_CLIP_COUNT)
|
|
683
|
+
})
|
|
684
|
+
})
|
|
685
|
+
|
|
686
|
+
// ── Warning class (F2): registry judgements never flip `ok` ───────────────
|
|
687
|
+
|
|
688
|
+
/** A two-camera EDL on a master mic, every field valid; each case below bends ONE thing. */
|
|
689
|
+
function multicamEdl(): Edl {
|
|
690
|
+
return {
|
|
691
|
+
version: 1,
|
|
692
|
+
clock: "master",
|
|
693
|
+
sources: [
|
|
694
|
+
{ id: "mic", url: "https://x/m.wav", kind: "audio", role: "master-audio" },
|
|
695
|
+
{ id: "wide", url: "https://x/w.mp4", kind: "video", role: "wide" },
|
|
696
|
+
{ id: "camA", url: "https://x/a.mp4", kind: "video", role: "camera" },
|
|
697
|
+
{ id: "camB", url: "https://x/b.mp4", kind: "video", role: "camera" },
|
|
698
|
+
],
|
|
699
|
+
segments: [
|
|
700
|
+
{ id: "s0", inMs: 0, outMs: 4000, video: "wide" },
|
|
701
|
+
{ id: "s1", inMs: 4000, outMs: 8000, video: "camA" },
|
|
702
|
+
],
|
|
703
|
+
meta: { targetAspect: "16:9" },
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
/** Replace segment[1] with extra fields. */
|
|
708
|
+
function withSeg1(extra: Partial<Edl["segments"][number]>, base: Edl = multicamEdl()): Edl {
|
|
709
|
+
return { ...base, segments: [base.segments[0], { ...base.segments[1], ...extra }] }
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
function expectWarnOnly(edl: Edl, pattern: RegExp): void {
|
|
713
|
+
const r = validateEdl(edl)
|
|
714
|
+
expect(r.issues).toEqual([])
|
|
715
|
+
expect(r.ok).toBe(true)
|
|
716
|
+
expect(r.warnings.length).toBeGreaterThanOrEqual(1)
|
|
717
|
+
expect(r.warnings.join("\n")).toMatch(pattern)
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
describe("validateEdl — warnings (registry class; ok stays true)", () => {
|
|
721
|
+
it("the valid multicam fixture has neither issues nor warnings", () => {
|
|
722
|
+
expect(validateEdl(multicamEdl())).toEqual({ ok: true, issues: [], warnings: [] })
|
|
723
|
+
})
|
|
724
|
+
|
|
725
|
+
it("an unknown source role warns (known roles are listed)", () => {
|
|
726
|
+
const base = multicamEdl()
|
|
727
|
+
const edl: Edl = { ...base, sources: base.sources.map((s) => (s.id === "camB" ? { ...s, role: "b-roll" } : s)) }
|
|
728
|
+
expectWarnOnly(edl, /source "camB": unknown role "b-roll" \(known: master-audio, camera, wide, screen\)/)
|
|
729
|
+
})
|
|
730
|
+
|
|
731
|
+
it("a typo'd master-audio stays visible: normalizeEdl passes the role through and it warns", () => {
|
|
732
|
+
const n = normalizeEdl({ ...multicamEdl(), sources: [{ id: "mic", url: "u", kind: "audio", role: "master-audo" }, { id: "wide", url: "w", kind: "video" }, { id: "camA", url: "a", kind: "video" }] })
|
|
733
|
+
expect(n.sources[0].role).toBe("master-audo")
|
|
734
|
+
expect(validateEdl(n).warnings.join("\n")).toMatch(/unknown role "master-audo"/)
|
|
735
|
+
})
|
|
736
|
+
|
|
737
|
+
it("an unknown meta.targetAspect warns — and the type accepts it (open like role)", () => {
|
|
738
|
+
const edl: Edl = { ...multicamEdl(), meta: { targetAspect: "21:9" } }
|
|
739
|
+
expectWarnOnly(edl, /meta\.targetAspect "21:9"/)
|
|
740
|
+
})
|
|
741
|
+
|
|
742
|
+
it("a JSON null for an absent field is absent: null targetAspect / null role never warn", () => {
|
|
743
|
+
const edl = {
|
|
744
|
+
...multicamEdl(),
|
|
745
|
+
meta: { targetAspect: null },
|
|
746
|
+
sources: multicamEdl().sources.map((s, i) => (i === 0 ? { ...s, role: null } : s)),
|
|
747
|
+
} as unknown as Edl
|
|
748
|
+
expect(validateEdl(edl).warnings).toEqual([])
|
|
749
|
+
})
|
|
750
|
+
|
|
751
|
+
it("a known-atom emphasis that breaks the set rules says which rule, not 'unknown'", () => {
|
|
752
|
+
expectWarnOnly(withSeg1({ layout: { mode: "single", emphasis: { style: "none+scale", durationMs: 200 } } }), /"none" must stand alone/)
|
|
753
|
+
expectWarnOnly(withSeg1({ layout: { mode: "single", emphasis: { style: "glow", durationMs: 200 } } }), /unknown emphasis style "glow"/)
|
|
754
|
+
})
|
|
755
|
+
|
|
756
|
+
it("an unknown layout mode warns (mode stays an open string)", () => {
|
|
757
|
+
expectWarnOnly(withSeg1({ layout: { mode: "carousel" } }), /segment\[1\] "s1": unknown layout mode "carousel"/)
|
|
758
|
+
})
|
|
759
|
+
|
|
760
|
+
it("a known layout whose slot count is outside [min, max] warns", () => {
|
|
761
|
+
const edl = withSeg1({ layout: { mode: "side-by-side", slots: [{ source: "camA" }, { source: "camB" }, { source: "wide" }] } })
|
|
762
|
+
expectWarnOnly(edl, /layout "side-by-side" takes 2 slot\(s\), got 3/)
|
|
763
|
+
})
|
|
764
|
+
|
|
765
|
+
it("a known layout not drawn for the known targetAspect warns", () => {
|
|
766
|
+
const base = { ...multicamEdl(), meta: { targetAspect: "9:16" as const } }
|
|
767
|
+
const edl = withSeg1({ layout: { mode: "side-by-side", slots: [{ source: "camA" }, { source: "camB" }] } }, base)
|
|
768
|
+
expectWarnOnly(edl, /layout "side-by-side" is not drawn for targetAspect 9:16/)
|
|
769
|
+
// …and the same layout on 16:9 is silent.
|
|
770
|
+
expect(validateEdl(withSeg1({ layout: { mode: "side-by-side", slots: [{ source: "camA" }, { source: "camB" }] } })).warnings).toEqual([])
|
|
771
|
+
})
|
|
772
|
+
|
|
773
|
+
it("an unknown layout transition type warns", () => {
|
|
774
|
+
expectWarnOnly(withSeg1({ layout: { mode: "single", transition: { type: "wipe" } } }), /unknown layout transition "wipe"/)
|
|
775
|
+
// A known xfade id is silent.
|
|
776
|
+
expect(validateEdl(withSeg1({ layout: { mode: "single", transition: { type: "xfade:fade", durationMs: 200 } } })).warnings).toEqual([])
|
|
777
|
+
})
|
|
778
|
+
|
|
779
|
+
it("a same-source switch (pan) between two DIFFERENT single picture sources warns", () => {
|
|
780
|
+
expectWarnOnly(withSeg1({ layout: { mode: "single", transition: { type: "pan" } } }), /switch "pan" moves within ONE picture source.*"wide".*"camA"/)
|
|
781
|
+
})
|
|
782
|
+
|
|
783
|
+
it("pan within one source, or across a multi-slot neighbour, does not warn (no defined 'picture source' there)", () => {
|
|
784
|
+
const same = withSeg1({ video: "wide", region: { x: 0.5, y: 0, w: 0.5, h: 1 }, layout: { mode: "single", transition: { type: "pan" } } })
|
|
785
|
+
expect(validateEdl(same).warnings).toEqual([])
|
|
786
|
+
const multi = withSeg1({ layout: { mode: "side-by-side", slots: [{ source: "camA" }, { source: "camB" }], transition: { type: "pan" } } })
|
|
787
|
+
expect(validateEdl(multi).warnings).toEqual([])
|
|
788
|
+
// zoom is not a same-source switch.
|
|
789
|
+
expect(validateEdl(withSeg1({ layout: { mode: "single", transition: { type: "zoom" } } })).warnings).toEqual([])
|
|
790
|
+
})
|
|
791
|
+
|
|
792
|
+
it("an unknown emphasis style (any unknown atom) warns; a known '+'-joined set does not", () => {
|
|
793
|
+
expectWarnOnly(withSeg1({ layout: { mode: "single", emphasis: { style: "scale+glow", durationMs: 200 } } }), /unknown emphasis style "scale\+glow"/)
|
|
794
|
+
expect(validateEdl(withSeg1({ layout: { mode: "single", emphasis: { style: "scale+border", durationMs: 200 } } })).warnings).toEqual([])
|
|
795
|
+
})
|
|
796
|
+
|
|
797
|
+
it("a slots-only layout with ≥2 slots WARNS after normalizeEdl defaults its mode to \"single\" — that EDL is ambiguous; do not 'fix' normalize by guessing a mode", () => {
|
|
798
|
+
const n = normalizeEdl({ ...multicamEdl(), segments: [multicamEdl().segments[0], { id: "s1", inMs: 4000, outMs: 8000, video: "camA", layout: { slots: [{ source: "camA" }, { source: "camB" }] } }] })
|
|
799
|
+
expect(n.segments[1].layout?.mode).toBe("single")
|
|
800
|
+
expectWarnOnly(n, /layout "single" takes 1 slot\(s\), got 2/)
|
|
801
|
+
})
|
|
802
|
+
|
|
803
|
+
it("an existing error case still yields ok:false (warnings never mask issues)", () => {
|
|
804
|
+
const base = multicamEdl()
|
|
805
|
+
const edl: Edl = {
|
|
806
|
+
...base,
|
|
807
|
+
sources: [...base.sources, { id: "mic2", url: "u", kind: "audio", role: "master-audio" }, { id: "x", url: "u", kind: "video", role: "b-roll" }],
|
|
808
|
+
}
|
|
809
|
+
const r = validateEdl(edl)
|
|
810
|
+
expect(r.ok).toBe(false)
|
|
811
|
+
expect(r.issues.join("\n")).toMatch(/more than one source has role:"master-audio"/)
|
|
812
|
+
expect(r.warnings.join("\n")).toMatch(/unknown role "b-roll"/)
|
|
813
|
+
})
|
|
814
|
+
|
|
815
|
+
it("validateEdlClipSet propagates warnings with the clip[i] prefix and stays ok", () => {
|
|
816
|
+
const warned = withSeg1({ layout: { mode: "carousel" } })
|
|
817
|
+
const r = validateEdlClipSet({ version: 1, clips: [multicamEdl(), warned] })
|
|
818
|
+
expect(r.ok).toBe(true)
|
|
819
|
+
expect(r.issues).toEqual([])
|
|
820
|
+
expect(r.warnings).toEqual([expect.stringMatching(/^clip\[1\]: segment\[1\] "s1": unknown layout mode "carousel"/)])
|
|
821
|
+
})
|
|
822
|
+
})
|