@nodaro/shared 3.11.0 → 3.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/dist/index.cjs +2047 -84
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +1556 -27
  4. package/dist/index.d.ts +1556 -27
  5. package/dist/index.js +1861 -85
  6. package/dist/index.js.map +1 -1
  7. package/package.json +1 -1
  8. package/src/__tests__/caption-styles.test.ts +207 -0
  9. package/src/__tests__/edl-multicam.test.ts +304 -0
  10. package/src/__tests__/edl.test.ts +822 -0
  11. package/src/__tests__/fan-out-rows.test.ts +208 -0
  12. package/src/__tests__/instagram-scrape.test.ts +66 -0
  13. package/src/__tests__/llm-models.test.ts +48 -11
  14. package/src/__tests__/meta-ads-scrape.test.ts +284 -0
  15. package/src/__tests__/node-runtime-keys.test.ts +15 -0
  16. package/src/__tests__/presentation-utils.test.ts +67 -0
  17. package/src/__tests__/producer-types.test.ts +19 -0
  18. package/src/__tests__/schedule-rules.test.ts +265 -0
  19. package/src/__tests__/speaker-layouts.test.ts +203 -0
  20. package/src/__tests__/transcribe-capabilities.test.ts +104 -0
  21. package/src/__tests__/transcribe-preflight.test.ts +60 -0
  22. package/src/__tests__/trigger-feeds.test.ts +39 -0
  23. package/src/__tests__/video-duration-auto.test.ts +65 -0
  24. package/src/__tests__/video-duration.test.ts +56 -0
  25. package/src/__tests__/video-link.test.ts +137 -0
  26. package/src/__tests__/workflow-export-strip.test.ts +59 -1
  27. package/src/caption-styles.ts +240 -0
  28. package/src/credit-identifiers.ts +31 -0
  29. package/src/edit-plan-contract.ts +96 -0
  30. package/src/edl-multicam.ts +185 -0
  31. package/src/edl.ts +747 -0
  32. package/src/entity-image-handle.ts +24 -1
  33. package/src/fan-out-rows.ts +213 -0
  34. package/src/i18n/action-fx.he.ts +8 -8
  35. package/src/i18n/aesthetic.he.ts +8 -8
  36. package/src/i18n/animals.he.ts +34 -34
  37. package/src/i18n/atmosphere.he.ts +6 -6
  38. package/src/i18n/backdrop.he.ts +7 -7
  39. package/src/i18n/camera-format.he.ts +4 -4
  40. package/src/i18n/camera-motions.he.ts +20 -20
  41. package/src/i18n/character-fx.he.ts +16 -16
  42. package/src/i18n/character-motion.he.ts +184 -184
  43. package/src/i18n/color-look.he.ts +4 -4
  44. package/src/i18n/composition-effects.he.ts +3 -3
  45. package/src/i18n/era.he.ts +9 -9
  46. package/src/i18n/exposure-settings.he.ts +2 -2
  47. package/src/i18n/framing.he.ts +13 -13
  48. package/src/i18n/furniture.he.ts +52 -52
  49. package/src/i18n/held-prop.he.ts +10 -10
  50. package/src/i18n/instrumentation.he.ts +26 -26
  51. package/src/i18n/lens.he.ts +6 -6
  52. package/src/i18n/lighting.he.ts +17 -17
  53. package/src/i18n/loop-subject.he.ts +4 -4
  54. package/src/i18n/materials.he.ts +8 -8
  55. package/src/i18n/mood.he.ts +12 -12
  56. package/src/i18n/music-genre.he.ts +9 -9
  57. package/src/i18n/music-mood.he.ts +7 -7
  58. package/src/i18n/person.he.ts +94 -94
  59. package/src/i18n/photo-genre.he.ts +24 -24
  60. package/src/i18n/photographer.he.ts +21 -21
  61. package/src/i18n/pose.he.ts +14 -14
  62. package/src/i18n/post-process-effects.he.ts +4 -4
  63. package/src/i18n/render-quality.he.ts +7 -7
  64. package/src/i18n/setting.he.ts +14 -14
  65. package/src/i18n/style.he.ts +9 -9
  66. package/src/i18n/styling.he.ts +126 -126
  67. package/src/i18n/temporal.he.ts +8 -8
  68. package/src/i18n/transitions.he.ts +9 -9
  69. package/src/i18n/vehicles.he.ts +25 -25
  70. package/src/i18n/voice-character.he.ts +14 -14
  71. package/src/i18n/voice-delivery.he.ts +11 -11
  72. package/src/i18n/weapons.he.ts +23 -23
  73. package/src/index.ts +206 -3
  74. package/src/instagram-scrape.ts +204 -0
  75. package/src/llm-models.ts +80 -3
  76. package/src/meta-ads-scrape.ts +463 -0
  77. package/src/model-catalog.ts +48 -5
  78. package/src/model-constants.ts +148 -5
  79. package/src/node-mappable-fields.ts +2 -0
  80. package/src/node-runtime-keys.ts +28 -0
  81. package/src/presentation-utils.ts +49 -0
  82. package/src/producer-types.ts +20 -0
  83. package/src/schedule-rules.ts +484 -0
  84. package/src/speaker-layouts.ts +220 -0
  85. package/src/transcribe-preflight.ts +101 -0
  86. package/src/trigger-feeds.ts +59 -0
  87. package/src/trigger-node-types.ts +20 -0
  88. package/src/video-duration-auto.ts +18 -0
  89. package/src/video-duration.ts +32 -0
  90. package/src/video-link.ts +167 -0
  91. package/src/workflow-export.ts +37 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nodaro/shared",
3
- "version": "3.11.0",
3
+ "version": "3.12.1",
4
4
  "description": "Shared types, model catalog, wire contracts, and structural vocabularies for the Nodaro platform and SDK.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -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
+ })