@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
package/package.json
CHANGED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest"
|
|
2
|
+
import {
|
|
3
|
+
captionRoutesToRemotion,
|
|
4
|
+
resolveCaptionLevers,
|
|
5
|
+
KINETIC_ONLY_CAPTION_LEVER_KEYS,
|
|
6
|
+
KINETIC_CAPTION_STYLES,
|
|
7
|
+
} from "../caption-styles.js"
|
|
8
|
+
|
|
9
|
+
type RouteInput = Parameters<typeof captionRoutesToRemotion>[0]
|
|
10
|
+
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// KINETIC_ONLY_CAPTION_LEVER_KEYS — the two levers meaningless on a subtitle
|
|
13
|
+
// render (no per-word spoken cursor to colour, no motion to switch off). The
|
|
14
|
+
// route rejects exactly these on `subtitle`; the frontend "don't send a stale
|
|
15
|
+
// lever" strip and the config panel derive from the SAME constant.
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
describe("KINETIC_ONLY_CAPTION_LEVER_KEYS", () => {
|
|
18
|
+
it("is exactly [highlightColor, animate]", () => {
|
|
19
|
+
expect(KINETIC_ONLY_CAPTION_LEVER_KEYS).toEqual(["highlightColor", "animate"])
|
|
20
|
+
})
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// captionRoutesToRemotion — the SINGLE predicate that decides the Remotion vs
|
|
25
|
+
// FFmpeg-drawtext path for both the worker dispatch and the credit id. Every
|
|
26
|
+
// branch of its contract is pinned below; the sole FALSE case is a plain-text
|
|
27
|
+
// subtitle with no lever / transcript / captions / segments.
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
describe("captionRoutesToRemotion", () => {
|
|
30
|
+
it("FALSE for a plain-text subtitle (the cheap FFmpeg drawtext path)", () => {
|
|
31
|
+
expect(captionRoutesToRemotion({ style: "subtitle", text: "hello world" })).toBe(false)
|
|
32
|
+
// style unset → the subtitle default, still the FFmpeg path with plain text.
|
|
33
|
+
expect(captionRoutesToRemotion({ text: "hello world" })).toBe(false)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it("TRUE when non-empty segments are present (per-segment treatments)", () => {
|
|
37
|
+
expect(captionRoutesToRemotion({ style: "subtitle", text: "hi", segments: [{}] })).toBe(true)
|
|
38
|
+
})
|
|
39
|
+
it("segments WIN even alongside plain text", () => {
|
|
40
|
+
expect(captionRoutesToRemotion({ text: "hello", segments: [{ startMs: 0, endMs: 1 }] })).toBe(true)
|
|
41
|
+
})
|
|
42
|
+
it("an EMPTY segments array does not route (length 0)", () => {
|
|
43
|
+
expect(captionRoutesToRemotion({ style: "subtitle", text: "hi", segments: [] })).toBe(false)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it("TRUE for EVERY kinetic style, even with no other lever", () => {
|
|
47
|
+
for (const style of KINETIC_CAPTION_STYLES) {
|
|
48
|
+
expect(captionRoutesToRemotion({ style, text: "hi" }), style).toBe(true)
|
|
49
|
+
}
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it("TRUE for a subtitle carrying ANY single styling lever", () => {
|
|
53
|
+
const leverInputs: RouteInput[] = [
|
|
54
|
+
{ style: "subtitle", text: "hi", look: "outline" },
|
|
55
|
+
{ style: "subtitle", text: "hi", fontFamily: "Montserrat" },
|
|
56
|
+
{ style: "subtitle", text: "hi", fontWeight: 900 },
|
|
57
|
+
{ style: "subtitle", text: "hi", strokeColor: "#000000" },
|
|
58
|
+
{ style: "subtitle", text: "hi", strokeWidth: 6 },
|
|
59
|
+
{ style: "subtitle", text: "hi", uppercase: true },
|
|
60
|
+
{ style: "subtitle", text: "hi", positionY: 65 },
|
|
61
|
+
]
|
|
62
|
+
for (const input of leverInputs) {
|
|
63
|
+
expect(captionRoutesToRemotion(input), JSON.stringify(input)).toBe(true)
|
|
64
|
+
}
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it("routes on lever PRESENCE (!== undefined), not truthiness — legitimate zero/false values still route", () => {
|
|
68
|
+
expect(captionRoutesToRemotion({ style: "subtitle", text: "hi", strokeWidth: 0 })).toBe(true) // 0 = "no outline"
|
|
69
|
+
expect(captionRoutesToRemotion({ style: "subtitle", text: "hi", positionY: 0 })).toBe(true) // top of frame
|
|
70
|
+
expect(captionRoutesToRemotion({ style: "subtitle", text: "hi", uppercase: false })).toBe(true) // explicitly not caps
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it("TRUE for a subtitle with a wired transcript (timed captions need Remotion)", () => {
|
|
74
|
+
expect(captionRoutesToRemotion({ style: "subtitle", text: "hi", transcript: { words: [] } })).toBe(true)
|
|
75
|
+
expect(captionRoutesToRemotion({ style: "subtitle", transcript: { version: 1 } })).toBe(true)
|
|
76
|
+
})
|
|
77
|
+
it("a NULL transcript alongside plain text does NOT route (null is not a wired transcript)", () => {
|
|
78
|
+
expect(captionRoutesToRemotion({ style: "subtitle", text: "hi", transcript: null })).toBe(false)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it("TRUE for a subtitle with a non-empty captions[]", () => {
|
|
82
|
+
expect(captionRoutesToRemotion({ style: "subtitle", text: "hi", captions: [{ text: "a", startMs: 0, endMs: 1 }] })).toBe(true)
|
|
83
|
+
})
|
|
84
|
+
it("an EMPTY captions[] alongside plain text does NOT route", () => {
|
|
85
|
+
expect(captionRoutesToRemotion({ style: "subtitle", text: "hi", captions: [] })).toBe(false)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it("TRUE for a subtitle with NO text (the only caption source is transcription → timed captions)", () => {
|
|
89
|
+
expect(captionRoutesToRemotion({ style: "subtitle" })).toBe(true)
|
|
90
|
+
expect(captionRoutesToRemotion({ style: "subtitle", text: "" })).toBe(true)
|
|
91
|
+
expect(captionRoutesToRemotion({ style: "subtitle", text: null })).toBe(true)
|
|
92
|
+
})
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
// The add-captions credit id is derived 1:1 from this predicate on BOTH backend
|
|
97
|
+
// call sites (routes/add-captions.ts::buildAddCaptionsCreditId and the DAG
|
|
98
|
+
// payload-builder): captionRoutesToRemotion(req) ? "add-captions:kinetic" :
|
|
99
|
+
// "add-captions". Covering the mapping through the shared predicate is what keeps
|
|
100
|
+
// the price and the renderer from drifting (a Remotion render must never reserve
|
|
101
|
+
// the static FFmpeg price, and vice-versa).
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
describe("add-captions credit id maps 1:1 from captionRoutesToRemotion", () => {
|
|
104
|
+
const creditId = (input: RouteInput): string =>
|
|
105
|
+
captionRoutesToRemotion(input) ? "add-captions:kinetic" : "add-captions"
|
|
106
|
+
|
|
107
|
+
it("a plain-text subtitle bills as add-captions (cheap FFmpeg burn)", () => {
|
|
108
|
+
expect(creditId({ style: "subtitle", text: "hello world" })).toBe("add-captions")
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it("kinetic / styled / timed / segmented all bill as add-captions:kinetic (Remotion render)", () => {
|
|
112
|
+
expect(creditId({ style: "word-pop", text: "hi" })).toBe("add-captions:kinetic")
|
|
113
|
+
expect(creditId({ style: "subtitle", text: "hi", look: "outline" })).toBe("add-captions:kinetic")
|
|
114
|
+
expect(creditId({ style: "subtitle", text: "hi", transcript: { words: [{}] } })).toBe("add-captions:kinetic")
|
|
115
|
+
expect(creditId({ style: "subtitle", text: "hi", captions: [{ text: "a", startMs: 0, endMs: 1 }] })).toBe("add-captions:kinetic")
|
|
116
|
+
expect(creditId({ style: "subtitle", text: "hi", segments: [{}] })).toBe("add-captions:kinetic")
|
|
117
|
+
})
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
describe("resolveCaptionLevers — per-style default look (kinetic → outline, subtitle → clean)", () => {
|
|
121
|
+
it("a bare subtitle (no look) resolves the CLEAN preset — a pinned sans, no outline house-style", () => {
|
|
122
|
+
const out = resolveCaptionLevers("subtitle", undefined, { color: "#fff", uppercase: true }, 32)
|
|
123
|
+
// A face is ALWAYS pinned: with none, the Remotion render falls back to
|
|
124
|
+
// headless Chrome's default serif (the plain FFmpeg subtitle draws sans).
|
|
125
|
+
expect(out.fontFamily).toBe("Inter")
|
|
126
|
+
expect(out).toEqual({ fontFamily: "Inter", color: "#fff", uppercase: true })
|
|
127
|
+
expect(out.strokeWidth).toBeUndefined() // did NOT inherit the outline stroke
|
|
128
|
+
expect(out.highlightColor).toBeUndefined() // nor the outline spoken-word colour
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it("an unset style is treated as subtitle (the route default) → clean", () => {
|
|
132
|
+
expect(resolveCaptionLevers(undefined, undefined, {}, 32).fontFamily).toBe("Inter")
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it("a subtitle that NAMES a look resolves that preset", () => {
|
|
136
|
+
const out = resolveCaptionLevers("subtitle", "outline", {}, 32)
|
|
137
|
+
expect(out.fontFamily).toBe("Montserrat")
|
|
138
|
+
expect(out.uppercase).toBe(true)
|
|
139
|
+
expect(out.strokeWidth).toBeGreaterThan(0)
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it("a kinetic style with no look resolves the default (outline) preset", () => {
|
|
143
|
+
const out = resolveCaptionLevers("word-highlight", undefined, {}, 32)
|
|
144
|
+
expect(out.fontFamily).toBe("Montserrat")
|
|
145
|
+
expect(out.highlightColor).toBeDefined()
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it("explicit levers override the resolved preset", () => {
|
|
149
|
+
const out = resolveCaptionLevers("word-highlight", "outline", { fontFamily: "Anton", uppercase: false }, 32)
|
|
150
|
+
expect(out.fontFamily).toBe("Anton")
|
|
151
|
+
expect(out.uppercase).toBe(false)
|
|
152
|
+
})
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
describe("normalizeCaptionNumericLevers — coerce authored node data into the plan's range", () => {
|
|
156
|
+
it("clamps out-of-range values and snaps fontWeight / maxWordsPerLine", async () => {
|
|
157
|
+
const { normalizeCaptionNumericLevers } = await import("../caption-styles.js")
|
|
158
|
+
expect(normalizeCaptionNumericLevers({ fontSize: 999, strokeWidth: -3, positionY: 140, fontWeight: 850, maxWordsPerLine: 2.6 }))
|
|
159
|
+
.toEqual({ fontSize: 200, strokeWidth: 0, positionY: 100, fontWeight: 900, maxWordsPerLine: 3 })
|
|
160
|
+
expect(normalizeCaptionNumericLevers({ fontSize: 1, fontWeight: 40, maxWordsPerLine: 0 }))
|
|
161
|
+
.toEqual({ fontSize: 12, fontWeight: 100, maxWordsPerLine: 1 })
|
|
162
|
+
})
|
|
163
|
+
it("DROPS a non-numeric / non-finite lever instead of passing garbage to the plan", async () => {
|
|
164
|
+
const { normalizeCaptionNumericLevers } = await import("../caption-styles.js")
|
|
165
|
+
expect(normalizeCaptionNumericLevers({ fontSize: "big", positionY: Number.NaN, strokeWidth: Infinity, style: "karaoke" }))
|
|
166
|
+
.toEqual({ style: "karaoke" })
|
|
167
|
+
})
|
|
168
|
+
it("accepts a numeric string, leaves in-range values and unrelated fields untouched, and never mutates", async () => {
|
|
169
|
+
const { normalizeCaptionNumericLevers } = await import("../caption-styles.js")
|
|
170
|
+
const input = { fontSize: "64", positionY: 83.5, look: "outline", uppercase: true }
|
|
171
|
+
const out = normalizeCaptionNumericLevers(input)
|
|
172
|
+
expect(out).toEqual({ fontSize: 64, positionY: 83.5, look: "outline", uppercase: true })
|
|
173
|
+
expect(input.fontSize).toBe("64")
|
|
174
|
+
expect(normalizeCaptionNumericLevers({ positionY: undefined })).toEqual({ positionY: undefined })
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it("DROPS a null numeric lever — a null reaching the render plan's numeric schema throws mid-run", async () => {
|
|
178
|
+
const { normalizeCaptionNumericLevers } = await import("../caption-styles.js")
|
|
179
|
+
const out = normalizeCaptionNumericLevers({ fontSize: null, strokeWidth: null, positionY: null, fontWeight: null, maxWordsPerLine: null, look: null })
|
|
180
|
+
expect(out).toEqual({ look: null })
|
|
181
|
+
expect("fontSize" in out).toBe(false)
|
|
182
|
+
})
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
describe("captionRoutesToRemotion — null is unset", () => {
|
|
186
|
+
it("a plain-text subtitle whose stored levers are all null stays on FFmpeg (and its price)", async () => {
|
|
187
|
+
const { captionRoutesToRemotion } = await import("../caption-styles.js")
|
|
188
|
+
expect(
|
|
189
|
+
captionRoutesToRemotion({
|
|
190
|
+
style: "subtitle", text: "hi",
|
|
191
|
+
look: null, fontFamily: null, fontWeight: null, strokeColor: null, strokeWidth: null,
|
|
192
|
+
uppercase: null, positionY: null, maxWordsPerLine: null, segments: null, captions: null, transcript: null,
|
|
193
|
+
}),
|
|
194
|
+
).toBe(false)
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
it("each lever still routes on its own once it carries a real value (false and 0 are values)", async () => {
|
|
198
|
+
const { captionRoutesToRemotion } = await import("../caption-styles.js")
|
|
199
|
+
const base = { style: "subtitle", text: "hi" }
|
|
200
|
+
for (const lever of [
|
|
201
|
+
{ look: "clean" }, { fontFamily: "Inter" }, { fontWeight: 700 }, { strokeColor: "#000000" },
|
|
202
|
+
{ strokeWidth: 0 }, { uppercase: false }, { positionY: 0 }, { maxWordsPerLine: 2 },
|
|
203
|
+
]) {
|
|
204
|
+
expect(captionRoutesToRemotion({ ...base, ...lever })).toBe(true)
|
|
205
|
+
}
|
|
206
|
+
})
|
|
207
|
+
})
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest"
|
|
2
|
+
import { remapMsThroughEdl, type Edl, type EdlRegion, type EdlSegment } from "../edl.js"
|
|
3
|
+
import {
|
|
4
|
+
EDL_FULL_FRAME,
|
|
5
|
+
mergeEdlSourceOffsets,
|
|
6
|
+
resolveEdlSegmentSlots,
|
|
7
|
+
type ResolveEdlSlotsOptions,
|
|
8
|
+
} from "../edl-multicam.js"
|
|
9
|
+
|
|
10
|
+
// Distinct, valid boxes so a test can tell which rung supplied the region.
|
|
11
|
+
const R_SLOT: EdlRegion = { x: 0.1, y: 0.1, w: 0.2, h: 0.2 }
|
|
12
|
+
const R_SEGMENT: EdlRegion = { x: 0.2, y: 0.2, w: 0.2, h: 0.2 }
|
|
13
|
+
const R_RESOLVER: EdlRegion = { x: 0.3, y: 0.3, w: 0.2, h: 0.2 }
|
|
14
|
+
const R_SPEAKER: EdlRegion = { x: 0.4, y: 0.4, w: 0.2, h: 0.2 }
|
|
15
|
+
const R_SOURCE: EdlRegion = { x: 0.5, y: 0.5, w: 0.2, h: 0.2 }
|
|
16
|
+
/** Past the right edge (x+w > 1) — not a valid in-frame box. */
|
|
17
|
+
const R_INVALID: EdlRegion = { x: 0.6, y: 0, w: 0.6, h: 0.5 }
|
|
18
|
+
|
|
19
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
20
|
+
// resolveEdlSegmentSlots — D20
|
|
21
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
describe("resolveEdlSegmentSlots — D20 precedence", () => {
|
|
24
|
+
/** Every rung present; `drop` removes the named rungs (top-down mutation). */
|
|
25
|
+
function ladder(drop: ReadonlySet<string>) {
|
|
26
|
+
const edl: Edl = {
|
|
27
|
+
version: 1,
|
|
28
|
+
clock: "master",
|
|
29
|
+
sources: [{ id: "wide", url: "u", kind: "video", role: "master-audio", ...(drop.has("source") ? {} : { region: R_SOURCE }) }],
|
|
30
|
+
segments: [],
|
|
31
|
+
}
|
|
32
|
+
const segment: EdlSegment = {
|
|
33
|
+
id: "s0",
|
|
34
|
+
inMs: 0,
|
|
35
|
+
outMs: 1000,
|
|
36
|
+
video: "wide",
|
|
37
|
+
speaker: "A",
|
|
38
|
+
...(drop.has("segment") ? {} : { region: R_SEGMENT }),
|
|
39
|
+
layout: { mode: "single", slots: [{ source: "wide", ...(drop.has("slot") ? {} : { region: R_SLOT }) }] },
|
|
40
|
+
}
|
|
41
|
+
const opts: ResolveEdlSlotsOptions = {
|
|
42
|
+
regionFor: () => (drop.has("resolver") ? undefined : R_RESOLVER),
|
|
43
|
+
speakerRegions: drop.has("speaker") ? [] : [{ source: "wide", speaker: "A", region: R_SPEAKER }],
|
|
44
|
+
}
|
|
45
|
+
const slots = resolveEdlSegmentSlots(edl, segment, opts)
|
|
46
|
+
expect(slots).toHaveLength(1)
|
|
47
|
+
return slots[0]
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Each row: with every rung ABOVE it removed (and every rung below still
|
|
51
|
+
// present), that rung wins. Row k+1 is row k's mutation — remove the winning
|
|
52
|
+
// rung and the next one wins.
|
|
53
|
+
const order = [
|
|
54
|
+
["slot", R_SLOT],
|
|
55
|
+
["segment", R_SEGMENT],
|
|
56
|
+
["resolver", R_RESOLVER],
|
|
57
|
+
["speaker", R_SPEAKER],
|
|
58
|
+
["source", R_SOURCE],
|
|
59
|
+
["full", EDL_FULL_FRAME],
|
|
60
|
+
] as const
|
|
61
|
+
order.forEach(([rung, region], k) => {
|
|
62
|
+
it(`"${rung}" wins over every lower rung; removing it hands over to "${order[k + 1]?.[0] ?? "(none)"}"`, () => {
|
|
63
|
+
const higher = new Set<string>(order.slice(0, k).map(([r]) => r))
|
|
64
|
+
const slot = ladder(higher)
|
|
65
|
+
expect(slot.regionFrom).toBe(rung)
|
|
66
|
+
expect(slot.region).toEqual(region)
|
|
67
|
+
if (k + 1 < order.length) {
|
|
68
|
+
const next = ladder(new Set([...higher, rung]))
|
|
69
|
+
expect(next.regionFrom).toBe(order[k + 1][0])
|
|
70
|
+
}
|
|
71
|
+
})
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it("the full frame is the frozen EDL_FULL_FRAME", () => {
|
|
75
|
+
expect(EDL_FULL_FRAME).toEqual({ x: 0, y: 0, w: 1, h: 1 })
|
|
76
|
+
expect(Object.isFrozen(EDL_FULL_FRAME)).toBe(true)
|
|
77
|
+
})
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
describe("resolveEdlSegmentSlots — slots, speakers, keying", () => {
|
|
81
|
+
const edl: Edl = {
|
|
82
|
+
version: 1,
|
|
83
|
+
clock: "master",
|
|
84
|
+
sources: [
|
|
85
|
+
{ id: "wide", url: "u", kind: "video", region: R_SOURCE },
|
|
86
|
+
{ id: "camA", url: "u", kind: "video" },
|
|
87
|
+
{ id: "camB", url: "u", kind: "video" },
|
|
88
|
+
{ id: "mic", url: "u", kind: "audio", role: "master-audio" },
|
|
89
|
+
],
|
|
90
|
+
segments: [],
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
it("a non-array speakerRegions is ignored, never thrown on", () => {
|
|
94
|
+
const opts = { speakerRegions: { wide: R_SPEAKER } } as unknown as ResolveEdlSlotsOptions
|
|
95
|
+
expect(() => resolveEdlSegmentSlots(edl, { id: "s", inMs: 0, outMs: 1, video: "wide", speaker: "A" }, opts)).not.toThrow()
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it("keys the speaker table by (source, speaker): a region for (wide, A) is NOT applied to (camA, A)", () => {
|
|
99
|
+
const opts: ResolveEdlSlotsOptions = { speakerRegions: [{ source: "wide", speaker: "A", region: R_SPEAKER }] }
|
|
100
|
+
const onCam = resolveEdlSegmentSlots(edl, { id: "s", inMs: 0, outMs: 1, video: "camA", speaker: "A" }, opts)
|
|
101
|
+
expect(onCam).toEqual([{ source: "camA", region: EDL_FULL_FRAME, regionFrom: "full", speaker: "A" }])
|
|
102
|
+
const onWide = resolveEdlSegmentSlots(edl, { id: "s", inMs: 0, outMs: 1, video: "wide", speaker: "A" }, opts)
|
|
103
|
+
expect(onWide[0]).toMatchObject({ source: "wide", region: R_SPEAKER, regionFrom: "speaker", speaker: "A" })
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it("ignores segment.region on a 2-slot layout (it is single-slot only)", () => {
|
|
107
|
+
const seg: EdlSegment = {
|
|
108
|
+
id: "s",
|
|
109
|
+
inMs: 0,
|
|
110
|
+
outMs: 1,
|
|
111
|
+
video: "camA",
|
|
112
|
+
region: R_SEGMENT,
|
|
113
|
+
layout: { mode: "side-by-side", slots: [{ source: "camA" }, { source: "wide" }] },
|
|
114
|
+
}
|
|
115
|
+
const slots = resolveEdlSegmentSlots(edl, seg)
|
|
116
|
+
expect(slots.map((s) => [s.source, s.regionFrom])).toEqual([
|
|
117
|
+
["camA", "full"],
|
|
118
|
+
["wide", "source"],
|
|
119
|
+
])
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it("a multi-slot slot's speaker is its own (never the segment's), and weight passes through", () => {
|
|
123
|
+
const seg: EdlSegment = {
|
|
124
|
+
id: "s",
|
|
125
|
+
inMs: 0,
|
|
126
|
+
outMs: 1,
|
|
127
|
+
speaker: "A",
|
|
128
|
+
layout: { mode: "side-by-side", slots: [{ source: "camA", speaker: "A", weight: 1 }, { source: "camB", weight: 0.4 }] },
|
|
129
|
+
}
|
|
130
|
+
expect(resolveEdlSegmentSlots(edl, seg)).toEqual([
|
|
131
|
+
{ source: "camA", region: EDL_FULL_FRAME, regionFrom: "full", speaker: "A", weight: 1 },
|
|
132
|
+
{ source: "camB", region: EDL_FULL_FRAME, regionFrom: "full", weight: 0.4 },
|
|
133
|
+
])
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it("with no layout slots, makes ONE implicit slot from segment.video (speaker = segment.speaker)", () => {
|
|
137
|
+
const slots = resolveEdlSegmentSlots(edl, { id: "s", inMs: 0, outMs: 1, video: "wide", speaker: "B", layout: { mode: "single", slots: [] } })
|
|
138
|
+
expect(slots).toEqual([{ source: "wide", region: R_SOURCE, regionFrom: "source", speaker: "B" }])
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it("no video and no slots → []", () => {
|
|
142
|
+
expect(resolveEdlSegmentSlots(edl, { id: "s", inMs: 0, outMs: 1, audio: "mic" })).toEqual([])
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it("an invalid region on any rung falls through to the next rung", () => {
|
|
146
|
+
const seg: EdlSegment = { id: "s", inMs: 0, outMs: 1, video: "wide", speaker: "A", region: R_INVALID, layout: { mode: "single", slots: [{ source: "wide", region: { x: Number.NaN, y: 0, w: 0.5, h: 0.5 } }] } }
|
|
147
|
+
const opts: ResolveEdlSlotsOptions = {
|
|
148
|
+
regionFor: () => ({ x: 0, y: 0, w: 0, h: 0.5 }), // zero width
|
|
149
|
+
speakerRegions: [{ source: "wide", speaker: "A", region: { x: 0, y: 0.8, w: 0.5, h: 0.5 } }], // past the bottom
|
|
150
|
+
}
|
|
151
|
+
expect(resolveEdlSegmentSlots(edl, seg, opts)[0]).toMatchObject({ region: R_SOURCE, regionFrom: "source" })
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it("an unknown source id skips the source rung (full frame)", () => {
|
|
155
|
+
const slots = resolveEdlSegmentSlots(edl, { id: "s", inMs: 0, outMs: 1, video: "ghost" })
|
|
156
|
+
expect(slots).toEqual([{ source: "ghost", region: EDL_FULL_FRAME, regionFrom: "full" }])
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it("regionFor receives (segment, source, speaker) per slot", () => {
|
|
160
|
+
const calls: Array<{ segment: EdlSegment; source: string; speaker?: string }> = []
|
|
161
|
+
const seg: EdlSegment = {
|
|
162
|
+
id: "s",
|
|
163
|
+
inMs: 0,
|
|
164
|
+
outMs: 1,
|
|
165
|
+
layout: { mode: "side-by-side", slots: [{ source: "camA", speaker: "A" }, { source: "camB" }] },
|
|
166
|
+
}
|
|
167
|
+
resolveEdlSegmentSlots(edl, seg, { regionFor: (q) => { calls.push(q); return undefined } })
|
|
168
|
+
expect(calls).toHaveLength(2)
|
|
169
|
+
expect(calls[0].segment).toBe(seg)
|
|
170
|
+
expect(calls[0]).toMatchObject({ source: "camA", speaker: "A" })
|
|
171
|
+
expect(calls[1]).toMatchObject({ source: "camB" })
|
|
172
|
+
expect(calls[1].speaker).toBeUndefined()
|
|
173
|
+
})
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
177
|
+
// mergeEdlSourceOffsets — D19, anchored SET
|
|
178
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
179
|
+
|
|
180
|
+
/** Two cameras + a master mic. Segments are on the mic's (master) clock. */
|
|
181
|
+
function podcast(): Edl {
|
|
182
|
+
return {
|
|
183
|
+
version: 1,
|
|
184
|
+
clock: "master",
|
|
185
|
+
sources: [
|
|
186
|
+
{ id: "camA", url: "https://x/a.mp4", kind: "video" },
|
|
187
|
+
{ id: "camB", url: "https://x/b.mp4", kind: "video" },
|
|
188
|
+
{ id: "mic", url: "https://x/m.wav", kind: "audio", role: "master-audio" },
|
|
189
|
+
],
|
|
190
|
+
segments: [
|
|
191
|
+
{ id: "s0", inMs: 0, outMs: 5000, video: "camA" },
|
|
192
|
+
// 5000–8000 dropped
|
|
193
|
+
{ id: "s1", inMs: 8000, outMs: 20000, video: "camB" },
|
|
194
|
+
],
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
describe("mergeEdlSourceOffsets — anchored rebase", () => {
|
|
199
|
+
it("rebases onto the master when audio-sync's reference is NOT the master (the −1200 ms mic example)", () => {
|
|
200
|
+
// audio-sync measured against its reference camA (refMs = sourceMs + offset):
|
|
201
|
+
// the mic reads −1200. A plain SET would write mic.offsetMs = −1200 and read
|
|
202
|
+
// every segment 1.2 s late; anchored, the mic stays at 0 and the cameras move.
|
|
203
|
+
const r = mergeEdlSourceOffsets(podcast(), { camA: 0, camB: 500, mic: -1200 })
|
|
204
|
+
expect(r.anchor).toBe("mic")
|
|
205
|
+
const off = Object.fromEntries(r.edl.sources.map((s) => [s.id, s.offsetMs]))
|
|
206
|
+
expect(off).toEqual({ camA: 1200, camB: 1700, mic: undefined })
|
|
207
|
+
expect(r.applied).toEqual(["camA", "camB"])
|
|
208
|
+
expect(r.ignored).toEqual([])
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
it("never changes the anchor's own offsetMs and rebases the others onto it", () => {
|
|
212
|
+
const edl: Edl = { ...podcast(), sources: podcast().sources.map((s) => (s.id === "mic" ? { ...s, offsetMs: 300 } : s)) }
|
|
213
|
+
const r = mergeEdlSourceOffsets(edl, { camA: 0, camB: 500, mic: -1200 })
|
|
214
|
+
const off = Object.fromEntries(r.edl.sources.map((s) => [s.id, s.offsetMs]))
|
|
215
|
+
expect(off).toEqual({ camA: 1500, camB: 2000, mic: 300 })
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
it("opts.anchor overrides the master-audio default", () => {
|
|
219
|
+
const r = mergeEdlSourceOffsets(podcast(), { camA: 0, camB: 500, mic: -1200 }, { anchor: "camA" })
|
|
220
|
+
expect(r.anchor).toBe("camA")
|
|
221
|
+
const off = Object.fromEntries(r.edl.sources.map((s) => [s.id, s.offsetMs]))
|
|
222
|
+
expect(off).toEqual({ camA: undefined, camB: 500, mic: -1200 })
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
it("is SET, never ADD: re-running on its own output is idempotent", () => {
|
|
226
|
+
const offsets = { camA: 0, camB: 500, mic: -1200 }
|
|
227
|
+
const once = mergeEdlSourceOffsets(podcast(), offsets).edl
|
|
228
|
+
const twice = mergeEdlSourceOffsets(once, offsets).edl
|
|
229
|
+
expect(twice).toEqual(once)
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
it("rounds to integer ms", () => {
|
|
233
|
+
const r = mergeEdlSourceOffsets(podcast(), { camA: 0.4, camB: 500.6, mic: -0.2 })
|
|
234
|
+
const off = Object.fromEntries(r.edl.sources.map((s) => [s.id, s.offsetMs]))
|
|
235
|
+
// camA: 0.4 + 0.2 = 0.6 → 1 ; camB: 500.6 + 0.2 = 500.8 → 501
|
|
236
|
+
expect(off).toEqual({ camA: 1, camB: 501, mic: undefined })
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
it("reports an unknown source and a non-finite offset in ignored, applying the rest", () => {
|
|
240
|
+
const r = mergeEdlSourceOffsets(podcast(), { camA: 100, ghost: 5, camB: Number.NaN, mic: 0 })
|
|
241
|
+
expect(r.applied).toEqual(["camA"])
|
|
242
|
+
expect(r.ignored).toEqual([
|
|
243
|
+
{ sourceId: "ghost", reason: "unknown-source" },
|
|
244
|
+
{ sourceId: "camB", reason: "not-finite" },
|
|
245
|
+
])
|
|
246
|
+
expect(r.edl.sources.find((s) => s.id === "camB")?.offsetMs).toBeUndefined()
|
|
247
|
+
expect(r.edl.sources.find((s) => s.id === "camA")?.offsetMs).toBe(100)
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
it("anchor-unknown applies NOTHING", () => {
|
|
251
|
+
const input = podcast()
|
|
252
|
+
const r = mergeEdlSourceOffsets(input, { camA: 100, camB: 200 }, { anchor: "ghost" })
|
|
253
|
+
expect(r.applied).toEqual([])
|
|
254
|
+
expect(r.ignored).toEqual([{ sourceId: "ghost", reason: "anchor-unknown" }])
|
|
255
|
+
expect(r.edl).toEqual(input)
|
|
256
|
+
expect(r.anchor).toBeUndefined()
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
it("anchor-not-finite applies NOTHING", () => {
|
|
260
|
+
const input = podcast()
|
|
261
|
+
const r = mergeEdlSourceOffsets(input, { camA: 100, camB: 200, mic: Number.POSITIVE_INFINITY })
|
|
262
|
+
expect(r.applied).toEqual([])
|
|
263
|
+
expect(r.ignored).toEqual([{ sourceId: "mic", reason: "anchor-not-finite" }])
|
|
264
|
+
expect(r.edl).toEqual(input)
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
it("with no anchor (no unique master-audio) sets the offsets verbatim (rounded)", () => {
|
|
268
|
+
const noMaster: Edl = { ...podcast(), sources: podcast().sources.map((s) => ({ ...s, role: undefined })) }
|
|
269
|
+
const r = mergeEdlSourceOffsets(noMaster, { camA: 0, camB: 500.4, mic: -1200 })
|
|
270
|
+
expect(r.anchor).toBeUndefined()
|
|
271
|
+
const off = Object.fromEntries(r.edl.sources.map((s) => [s.id, s.offsetMs]))
|
|
272
|
+
expect(off).toEqual({ camA: 0, camB: 500, mic: -1200 })
|
|
273
|
+
// Two master-audio sources are not a UNIQUE master either.
|
|
274
|
+
const twoMasters: Edl = { ...podcast(), sources: podcast().sources.map((s) => ({ ...s, role: "master-audio" })) }
|
|
275
|
+
expect(mergeEdlSourceOffsets(twoMasters, { camB: 7 }).anchor).toBeUndefined()
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
it("returns a new Edl and never mutates its input", () => {
|
|
279
|
+
const input = podcast()
|
|
280
|
+
const snapshot = JSON.parse(JSON.stringify(input))
|
|
281
|
+
const offsets = { camA: 0, camB: 500, mic: -1200 }
|
|
282
|
+
const r = mergeEdlSourceOffsets(input, offsets)
|
|
283
|
+
expect(r.edl).not.toBe(input)
|
|
284
|
+
expect(input).toEqual(snapshot)
|
|
285
|
+
expect(offsets).toEqual({ camA: 0, camB: 500, mic: -1200 })
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
it("remapMsThroughEdl round-trip: an instant on camera B's clock lands at the expected output time after the merge", () => {
|
|
289
|
+
// Event E at reference (camA) time 9500. refMs = sourceMs + offset ⇒ camB
|
|
290
|
+
// reads it at 9000, the mic (master) at 10700. On the master clock E is in
|
|
291
|
+
// s1 [8000, 20000), which starts at output 5000 (s0 is 5000 long) ⇒ 7700.
|
|
292
|
+
const { edl } = mergeEdlSourceOffsets(podcast(), { camA: 0, camB: 500, mic: -1200 })
|
|
293
|
+
expect(remapMsThroughEdl(edl, 9000, "camB")).toBe(7700)
|
|
294
|
+
// …and the same instant read on the master clock agrees.
|
|
295
|
+
expect(remapMsThroughEdl(edl, 10700, "mic")).toBe(7700)
|
|
296
|
+
expect(remapMsThroughEdl(edl, 9500, "camA")).toBe(7700)
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
it("never throws on garbage input", () => {
|
|
300
|
+
expect(() => mergeEdlSourceOffsets({} as Edl, { a: 1 })).not.toThrow()
|
|
301
|
+
expect(mergeEdlSourceOffsets({} as Edl, { a: 1 }).ignored).toEqual([{ sourceId: "a", reason: "unknown-source" }])
|
|
302
|
+
expect(() => mergeEdlSourceOffsets(podcast(), null as unknown as Record<string, number>)).not.toThrow()
|
|
303
|
+
})
|
|
304
|
+
})
|