@nodaro/shared 3.12.1 → 3.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/dist/index.cjs +442 -6
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +404 -3
  4. package/dist/index.d.ts +404 -3
  5. package/dist/index.js +400 -7
  6. package/dist/index.js.map +1 -1
  7. package/package.json +1 -1
  8. package/src/__tests__/producer-types.test.ts +5 -0
  9. package/src/__tests__/video-overlay-docs.test.ts +39 -0
  10. package/src/__tests__/video-overlay.test.ts +572 -0
  11. package/src/i18n/action-fx.ja.ts +17 -17
  12. package/src/i18n/aesthetic.ja.ts +26 -20
  13. package/src/i18n/animals.ja.ts +49 -49
  14. package/src/i18n/atmosphere.ja.ts +19 -19
  15. package/src/i18n/backdrop.ja.ts +13 -13
  16. package/src/i18n/camera-format.ja.ts +3 -3
  17. package/src/i18n/camera-motions.ja.ts +15 -15
  18. package/src/i18n/character-fx.ja.ts +12 -12
  19. package/src/i18n/character-motion.ja.ts +72 -72
  20. package/src/i18n/color-look.ja.ts +19 -19
  21. package/src/i18n/composition-effects.ja.ts +6 -6
  22. package/src/i18n/era.ja.ts +11 -11
  23. package/src/i18n/exposure-settings.ja.ts +10 -10
  24. package/src/i18n/framing.ja.ts +11 -11
  25. package/src/i18n/furniture.ja.ts +22 -22
  26. package/src/i18n/held-prop.ja.ts +7 -7
  27. package/src/i18n/instrumentation.ja.ts +41 -41
  28. package/src/i18n/lens.ja.ts +5 -5
  29. package/src/i18n/lighting.ja.ts +21 -21
  30. package/src/i18n/materials.ja.ts +4 -4
  31. package/src/i18n/mood.ja.ts +18 -18
  32. package/src/i18n/music-genre.ja.ts +16 -16
  33. package/src/i18n/music-mood.ja.ts +18 -18
  34. package/src/i18n/person.ar.ts +1 -0
  35. package/src/i18n/person.de.ts +1 -0
  36. package/src/i18n/person.es.ts +1 -0
  37. package/src/i18n/person.fr.ts +1 -0
  38. package/src/i18n/person.he.ts +1 -0
  39. package/src/i18n/person.hi.ts +1 -0
  40. package/src/i18n/person.ja.ts +179 -178
  41. package/src/i18n/person.ko.ts +1 -0
  42. package/src/i18n/person.pt-BR.ts +1 -0
  43. package/src/i18n/person.ru.ts +1 -0
  44. package/src/i18n/person.zh-CN.ts +1 -0
  45. package/src/i18n/photo-genre.ja.ts +11 -11
  46. package/src/i18n/photographer.ja.ts +17 -17
  47. package/src/i18n/pose.ja.ts +14 -14
  48. package/src/i18n/post-process-effects.ja.ts +5 -5
  49. package/src/i18n/render-quality.ja.ts +9 -9
  50. package/src/i18n/setting.ar.ts +2 -0
  51. package/src/i18n/setting.de.ts +2 -0
  52. package/src/i18n/setting.es.ts +2 -0
  53. package/src/i18n/setting.fr.ts +2 -0
  54. package/src/i18n/setting.he.ts +2 -0
  55. package/src/i18n/setting.hi.ts +2 -0
  56. package/src/i18n/setting.ja.ts +16 -14
  57. package/src/i18n/setting.ko.ts +2 -0
  58. package/src/i18n/setting.pt-BR.ts +2 -0
  59. package/src/i18n/setting.ru.ts +2 -0
  60. package/src/i18n/setting.zh-CN.ts +2 -0
  61. package/src/i18n/style.ja.ts +10 -10
  62. package/src/i18n/styling.ar.ts +2 -0
  63. package/src/i18n/styling.de.ts +2 -0
  64. package/src/i18n/styling.es.ts +2 -0
  65. package/src/i18n/styling.fr.ts +2 -0
  66. package/src/i18n/styling.he.ts +2 -0
  67. package/src/i18n/styling.hi.ts +2 -0
  68. package/src/i18n/styling.ja.ts +58 -56
  69. package/src/i18n/styling.ko.ts +2 -0
  70. package/src/i18n/styling.pt-BR.ts +2 -0
  71. package/src/i18n/styling.ru.ts +2 -0
  72. package/src/i18n/styling.zh-CN.ts +2 -0
  73. package/src/i18n/temporal.ja.ts +5 -5
  74. package/src/i18n/transitions.ja.ts +12 -12
  75. package/src/i18n/vehicles.ja.ts +29 -29
  76. package/src/i18n/voice-character.ja.ts +27 -27
  77. package/src/i18n/voice-delivery.ja.ts +28 -28
  78. package/src/i18n/weapons.ja.ts +19 -19
  79. package/src/index.ts +2 -0
  80. package/src/producer-types.ts +3 -0
  81. package/src/trigger-node-types.ts +3 -0
  82. package/src/video-overlay.ts +812 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nodaro/shared",
3
- "version": "3.12.1",
3
+ "version": "3.13.0",
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",
@@ -96,4 +96,9 @@ describe("producer-types", () => {
96
96
  it("classifies apply-edl as a video output (literal VIDEO_OUTPUT_TYPES wins over DYNAMIC 'data')", () => {
97
97
  expect(getOutputType("apply-edl")).toBe("video")
98
98
  })
99
+ // video-overlay renders timed image layers over a video → emits a video URL
100
+ // on `video-out`. Absent here, every downstream video input would reject it.
101
+ it("registers video-overlay as a video producer", () => {
102
+ expect(VIDEO_PRODUCER_TYPES.has("video-overlay")).toBe(true)
103
+ })
99
104
  })
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Public Docs Maintenance Rule: the Video Overlay pages describe the slot
3
+ * model the code runs. A layer's number is its slot, and a slot is NOT bounded
4
+ * by the 20-layer limit — removing Layers 1–4 of a 24-layer node leaves
5
+ * Layers 5–24, and both engines run them (videoOverlaySlotSources, the REST
6
+ * route's `slot`). Only the handles stop at 12. This reads the public pages and
7
+ * fails if they bound the handle-less layers at 20 again.
8
+ */
9
+ import { describe, it, expect } from "vitest"
10
+ import { readFileSync } from "node:fs"
11
+ import { dirname, join } from "node:path"
12
+ import { fileURLToPath } from "node:url"
13
+ import { VIDEO_OVERLAY_HANDLE_IDS, VIDEO_OVERLAY_MAX_LAYERS } from "../video-overlay"
14
+
15
+ const ROOT = join(dirname(fileURLToPath(import.meta.url)), "../../../..")
16
+ const read = (p: string) => readFileSync(join(ROOT, p), "utf8")
17
+ const NODE_PAGE = read("docs/nodes/processing-video/video-overlay.md")
18
+ const INVENTORY_ROW = read("docs/node-inventory.md")
19
+ .split("\n")
20
+ .find((l) => l.startsWith("| `video-overlay` |")) ?? ""
21
+
22
+ const FIRST_HANDLE_LESS = VIDEO_OVERLAY_HANDLE_IDS.length + 1
23
+ const SLOT_RANGE = new RegExp(`layers ${FIRST_HANDLE_LESS}\\s*[–-]\\s*${VIDEO_OVERLAY_MAX_LAYERS}`, "i")
24
+
25
+ describe("Video Overlay public docs — slot model", () => {
26
+ it("the inventory row exists", () => {
27
+ expect(INVENTORY_ROW).not.toBe("")
28
+ })
29
+
30
+ it(`never bounds the handle-less layers at ${VIDEO_OVERLAY_MAX_LAYERS}`, () => {
31
+ expect(NODE_PAGE).not.toMatch(SLOT_RANGE)
32
+ expect(INVENTORY_ROW).not.toMatch(SLOT_RANGE)
33
+ })
34
+
35
+ it(`says the handle-less layers start at ${FIRST_HANDLE_LESS} and go up`, () => {
36
+ expect(NODE_PAGE).toContain(`layers ${FIRST_HANDLE_LESS} and up`)
37
+ expect(INVENTORY_ROW).toContain(`layers ${FIRST_HANDLE_LESS} and up`)
38
+ })
39
+ })
@@ -0,0 +1,572 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import {
3
+ DEFAULT_VIDEO_OVERLAY_LAYER,
4
+ VIDEO_OVERLAY_HANDLE_IDS,
5
+ VIDEO_OVERLAY_PRESETS,
6
+ applyVideoOverlayPreset,
7
+ assembleVideoOverlayRequest,
8
+ clearWiredVideoOverlayImageUrls,
9
+ expandVideoOverlayLayer,
10
+ expandVideoOverlayPresets,
11
+ formatVideoOverlayError,
12
+ hasDrawableVideoOverlayBox,
13
+ normalizeVideoOverlayNodes,
14
+ resolveVideoOverlayGeometry,
15
+ toCustomVideoOverlayLayer,
16
+ validateVideoOverlayRequest,
17
+ videoOverlayCanvas,
18
+ VIDEO_OVERLAY_MAX_COMPOSITION_KEY_LENGTH,
19
+ VIDEO_OVERLAY_MAX_LAYERS,
20
+ videoOverlayCompositionKey,
21
+ videoOverlayRenderOrder,
22
+ videoOverlaySlotOfHandle,
23
+ videoOverlaySlotSources,
24
+ type VideoOverlayIssue,
25
+ type VideoOverlayLayerInput,
26
+ } from "../video-overlay.js"
27
+
28
+ const IMG = "https://cdn.example/a.png"
29
+
30
+ describe("constants", () => {
31
+ it("presets are the spec's boxes in the custom vocabulary", () => {
32
+ expect(VIDEO_OVERLAY_PRESETS.card).toEqual({ anchor: "center", x: 0, y: -4, width: 78, height: 60, fit: "contain" })
33
+ expect(VIDEO_OVERLAY_PRESETS["full-frame"]).toEqual({ anchor: "center", x: 0, y: 0, width: 100, height: 100, fit: "cover" })
34
+ expect(VIDEO_OVERLAY_PRESETS["corner-badge"]["bottom-right"]).toEqual({ anchor: "bottom-right", x: -4, y: -4, width: 18, fit: "contain" })
35
+ expect(VIDEO_OVERLAY_PRESETS["corner-badge"]["top-left"]).toEqual({ anchor: "top-left", x: 4, y: 4, width: 18, fit: "contain" })
36
+ expect(VIDEO_OVERLAY_PRESETS["corner-badge"]["top-right"]).toEqual({ anchor: "top-right", x: -4, y: 4, width: 18, fit: "contain" })
37
+ expect(VIDEO_OVERLAY_PRESETS["corner-badge"]["bottom-left"]).toEqual({ anchor: "bottom-left", x: 4, y: -4, width: 18, fit: "contain" })
38
+ })
39
+ it("the default layer is the bottom-right corner badge from 0 to the end (D2)", () => {
40
+ expect(DEFAULT_VIDEO_OVERLAY_LAYER).toEqual({
41
+ start: 0, preset: "corner-badge", corner: "bottom-right",
42
+ anchor: "bottom-right", x: -4, y: -4, width: 18, fit: "contain", opacity: 1, animate: true,
43
+ })
44
+ expect("end" in DEFAULT_VIDEO_OVERLAY_LAYER).toBe(false)
45
+ })
46
+ it("12 canvas handles; slot numbers are 1-based and nothing else is a layer handle", () => {
47
+ expect(VIDEO_OVERLAY_HANDLE_IDS).toHaveLength(12)
48
+ expect(videoOverlaySlotOfHandle("overlay")).toBe(1)
49
+ expect(videoOverlaySlotOfHandle("overlay12")).toBe(12)
50
+ for (const h of ["overlay0", "overlay13", "video", "layerPlan", "", null, undefined]) expect(videoOverlaySlotOfHandle(h)).toBe(0)
51
+ })
52
+ })
53
+
54
+ describe("expandVideoOverlayLayer — the one normaliser (§3.3)", () => {
55
+ it("copies each preset's box in and keeps the tag (all presets, all four corners)", () => {
56
+ expect(expandVideoOverlayLayer({ preset: "card", start: 1 })).toMatchObject({ ...VIDEO_OVERLAY_PRESETS.card, preset: "card", start: 1, opacity: 1, animate: true })
57
+ expect(expandVideoOverlayLayer({ preset: "full-frame", start: 1 })).toMatchObject({ ...VIDEO_OVERLAY_PRESETS["full-frame"], preset: "full-frame" })
58
+ for (const corner of ["top-left", "top-right", "bottom-left", "bottom-right"] as const) {
59
+ expect(expandVideoOverlayLayer({ preset: "corner-badge", corner, start: 1 })).toMatchObject({ ...VIDEO_OVERLAY_PRESETS["corner-badge"][corner], preset: "corner-badge", corner })
60
+ }
61
+ })
62
+ it("returns a complete layer BY REFERENCE", () => {
63
+ const full = { imageUrl: IMG, start: 1, anchor: "top" as const, x: 1, y: 2, width: 30, fit: "cover" as const, opacity: 0.5, animate: false }
64
+ expect(expandVideoOverlayLayer(full)).toBe(full)
65
+ expect(expandVideoOverlayLayer(DEFAULT_VIDEO_OVERLAY_LAYER)).toBe(DEFAULT_VIDEO_OVERLAY_LAYER)
66
+ })
67
+ it("an explicit box field that DIFFERS from the preset wins and clears the tag", () => {
68
+ const l = expandVideoOverlayLayer({ preset: "card", width: 50, start: 0 })
69
+ expect(l).toMatchObject({ anchor: "center", x: 0, y: -4, width: 50, height: 60, fit: "contain" })
70
+ expect(l.preset).toBeUndefined()
71
+ })
72
+ it("a box field EQUAL to the preset keeps the tag", () => {
73
+ expect(expandVideoOverlayLayer({ preset: "card", width: 78, start: 0 }).preset).toBe("card")
74
+ })
75
+ it("a height on a corner badge counts as differing (the preset sets none)", () => {
76
+ const l = expandVideoOverlayLayer({ preset: "corner-badge", corner: "top-right", height: 30, start: 0 })
77
+ expect(l.preset).toBeUndefined()
78
+ expect(l).toMatchObject({ anchor: "top-right", x: -4, y: 4, width: 18, height: 30 })
79
+ })
80
+ it("is idempotent WITH the tag kept (the second-pass case)", () => {
81
+ const once = expandVideoOverlayLayer({ preset: "card", start: 0 })
82
+ expect(expandVideoOverlayLayer(once)).toBe(once)
83
+ expect(once.preset).toBe("card")
84
+ })
85
+ it("an empty object is the full default, start 0 included (D2 / D8)", () => {
86
+ expect(expandVideoOverlayLayer({})).toEqual(DEFAULT_VIDEO_OVERLAY_LAYER)
87
+ })
88
+ it("no preset + no box keeps the layer's own times, slot and url (D8)", () => {
89
+ const l = expandVideoOverlayLayer({ imageUrl: IMG, start: 3, end: 5, slot: 14 })
90
+ expect(l).toEqual({ ...DEFAULT_VIDEO_OVERLAY_LAYER, imageUrl: IMG, start: 3, end: 5, slot: 14 })
91
+ })
92
+ it("no preset + no box + no start fills start 0 (D8's spread)", () => {
93
+ expect(expandVideoOverlayLayer({ imageUrl: IMG })).toEqual({ ...DEFAULT_VIDEO_OVERLAY_LAYER, imageUrl: IMG })
94
+ })
95
+ it("no preset + no box + its own corner gets THAT corner's badge box, idempotently (D8)", () => {
96
+ const l = expandVideoOverlayLayer({ imageUrl: IMG, start: 2, corner: "top-left" })
97
+ expect(l).toMatchObject({ ...VIDEO_OVERLAY_PRESETS["corner-badge"]["top-left"], preset: "corner-badge", corner: "top-left", start: 2 })
98
+ expect(expandVideoOverlayLayer(l)).toBe(l)
99
+ })
100
+ it("a no-preset PARTIAL box passes through untouched (the validator refuses it)", () => {
101
+ const partial = { start: 0, width: 40 }
102
+ expect(expandVideoOverlayLayer(partial)).toBe(partial)
103
+ })
104
+ it("reads null as absent — null fields and a null layer (Review Focus 1)", () => {
105
+ const l = expandVideoOverlayLayer({ imageUrl: IMG, start: 1, end: null, height: null, preset: null, anchor: "center", width: 30 })
106
+ expect(l).toEqual({ imageUrl: IMG, start: 1, anchor: "center", width: 30, x: 0, y: 0, fit: "contain", opacity: 1, animate: true })
107
+ // The single-layer form reads a null layer as `{}` (the engine assemblies never pass one —
108
+ // they spread `raw ?? {}` — but a caller that does gets the D2 default, not a crash).
109
+ expect(expandVideoOverlayLayer(null)).toEqual(DEFAULT_VIDEO_OVERLAY_LAYER)
110
+ // A null SLOT in stored data is a different thing — it stays null at the write
111
+ // boundary (normalizeVideoOverlayNodes, below).
112
+ })
113
+ it("an unknown preset / corner string is treated as absent", () => {
114
+ const l = expandVideoOverlayLayer({ imageUrl: IMG, start: 0, preset: "banner" as never, corner: "middle" as never })
115
+ expect(l).toMatchObject({ preset: "corner-badge", corner: "bottom-right", anchor: "bottom-right" })
116
+ })
117
+ it("the array form returns the SAME array when nothing changed", () => {
118
+ const arr = [DEFAULT_VIDEO_OVERLAY_LAYER]
119
+ expect(expandVideoOverlayPresets(arr)).toBe(arr)
120
+ })
121
+ })
122
+
123
+ describe("preset click / Custom (the panel's two writers)", () => {
124
+ it("a preset click REPLACES the six box fields — height is dropped going into corner badge", () => {
125
+ const l = applyVideoOverlayPreset({ imageUrl: IMG, start: 2, end: 4, preset: "card", anchor: "center", x: 0, y: -4, width: 78, height: 60, fit: "contain" }, "corner-badge", "top-left")
126
+ expect(l).toMatchObject({ imageUrl: IMG, start: 2, end: 4, preset: "corner-badge", corner: "top-left", ...VIDEO_OVERLAY_PRESETS["corner-badge"]["top-left"] })
127
+ expect(l.height).toBeUndefined()
128
+ expect(expandVideoOverlayLayer(l)).toBe(l)
129
+ })
130
+ it("Custom clears the tag and keeps the box", () => {
131
+ const l = toCustomVideoOverlayLayer({ preset: "card", start: 1 })
132
+ expect(l.preset).toBeUndefined()
133
+ expect(l).toMatchObject(VIDEO_OVERLAY_PRESETS.card)
134
+ })
135
+ })
136
+
137
+ describe("clearWiredVideoOverlayImageUrls (D10)", () => {
138
+ it("clears only the wired slots", () => {
139
+ const layers: VideoOverlayLayerInput[] = [{ imageUrl: IMG, start: 0 }, { imageUrl: IMG, start: 1 }]
140
+ const out = clearWiredVideoOverlayImageUrls(layers, new Set([2]))
141
+ expect(out[0]).toEqual({ imageUrl: IMG, start: 0 })
142
+ expect(out[1]).toEqual({ start: 1 })
143
+ })
144
+ it("an empty set returns the array by reference", () => {
145
+ const layers = [{ imageUrl: IMG, start: 0 }]
146
+ expect(clearWiredVideoOverlayImageUrls(layers, new Set())).toBe(layers)
147
+ })
148
+ it("a non-object entry on a wired slot is skipped, never `in`-probed", () => {
149
+ const layers: unknown[] = ["junk", 7, [IMG]]
150
+ expect(clearWiredVideoOverlayImageUrls(layers as VideoOverlayLayerInput[], new Set([1, 2, 3]))).toBe(layers)
151
+ })
152
+ it("slots 13–20 are never touched (no handle)", () => {
153
+ const layers = Array.from({ length: 14 }, () => ({ imageUrl: IMG, start: 0 }))
154
+ expect(clearWiredVideoOverlayImageUrls(layers, new Set([13, 14]))).toBe(layers)
155
+ })
156
+ })
157
+
158
+ describe("normalizeVideoOverlayNodes (the JSON write-boundary pass)", () => {
159
+ const node = (layers: unknown[]) => ({ id: "vo", type: "video-overlay", data: { label: "Video Overlay", layers } })
160
+ it("expands presets and clears the imageUrl of a slot the written edges wire", () => {
161
+ const [out] = normalizeVideoOverlayNodes([node([{ imageUrl: IMG, start: 0 }, {}, { imageUrl: IMG, preset: "card", start: 1 }])], [{ target: "vo", targetHandle: "overlay3" }])
162
+ const layers = (out!.data as { layers: VideoOverlayLayerInput[] }).layers
163
+ expect(layers[0]).toMatchObject({ imageUrl: IMG, preset: "corner-badge" })
164
+ expect(layers[1]).toEqual(DEFAULT_VIDEO_OVERLAY_LAYER)
165
+ expect(layers[2]).toMatchObject({ ...VIDEO_OVERLAY_PRESETS.card, start: 1 })
166
+ expect(layers[2]!.imageUrl).toBeUndefined()
167
+ })
168
+ it("returns the same array when there is nothing to do, and never touches other node types", () => {
169
+ const nodes = [{ id: "g", type: "generate-image", data: { layers: [{}] } }, node([DEFAULT_VIDEO_OVERLAY_LAYER])]
170
+ expect(normalizeVideoOverlayNodes(nodes, [])).toBe(nodes)
171
+ })
172
+ it("a non-object layer entry (an agent writing bare URL strings) does not throw and is left as stored for the validator", () => {
173
+ const nodes = [{ id: "n", type: "video-overlay", data: { layers: ["junk", { imageUrl: IMG, start: 1 }] } }]
174
+ const [out] = normalizeVideoOverlayNodes(nodes, [{ target: "n", targetHandle: "overlay" }])
175
+ const layers = (out!.data as { layers: unknown[] }).layers
176
+ expect(layers[0]).toBe("junk")
177
+ expect(layers[1]).toEqual(expandVideoOverlayLayer({ imageUrl: IMG, start: 1 }))
178
+ expect(validateVideoOverlayRequest({ layers: layers as VideoOverlayLayerInput[] })).toMatchObject({ ok: false, code: "layer_without_image", layer: 0 })
179
+ })
180
+ it("a null slot STAYS null — wired or not; only the engine assemblies and the stage synthesise the default (Review Focus 1)", () => {
181
+ const [out] = normalizeVideoOverlayNodes([node([null, { imageUrl: IMG, start: 1 }, null])], [{ target: "vo", targetHandle: "overlay3" }])
182
+ expect((out!.data as { layers: unknown[] }).layers).toEqual([null, expandVideoOverlayLayer({ imageUrl: IMG, start: 1 }), null])
183
+ const clean = [node([null, DEFAULT_VIDEO_OVERLAY_LAYER])]
184
+ expect(normalizeVideoOverlayNodes(clean, [])).toBe(clean)
185
+ })
186
+ })
187
+
188
+ describe("resolveVideoOverlayGeometry (§3.4 — shared by the preview and the worker)", () => {
189
+ it("the card preset on 1080×1920 (the spec's worked example)", () => {
190
+ expect(resolveVideoOverlayGeometry({ w: 1080, h: 1920 }, VIDEO_OVERLAY_PRESETS.card, 1080 / 2160)).toEqual({
191
+ box: { left: 119, top: 307, width: 842, height: 1152 },
192
+ drawn: { left: 252, top: 307, width: 576, height: 1152 },
193
+ })
194
+ })
195
+ it("full-frame on a 9:16 canvas is the whole canvas — no edge shrink", () => {
196
+ expect(resolveVideoOverlayGeometry({ w: 1080, h: 1920 }, VIDEO_OVERLAY_PRESETS["full-frame"], 16 / 9).drawn).toEqual({ left: 0, top: 0, width: 1080, height: 1920 })
197
+ })
198
+ it("a corner badge on 16:9", () => {
199
+ expect(resolveVideoOverlayGeometry({ w: 1920, h: 1080 }, VIDEO_OVERLAY_PRESETS["corner-badge"]["bottom-right"], 1).drawn).toEqual({ left: 1497, top: 691, width: 346, height: 346 })
200
+ })
201
+ it("an odd drawn size is rounded DOWN to even (libx264 yuv420p)", () => {
202
+ expect(resolveVideoOverlayGeometry({ w: 1000, h: 1000 }, { anchor: "top-left", x: 0, y: 0, width: 25, fit: "contain" }, 3).drawn).toEqual({ left: 1, top: 0, width: 248, height: 82 })
203
+ })
204
+ it("a very tall image at width 60 % with no height is fitted inside the canvas (the clamp): 192 × 1920", () => {
205
+ expect(resolveVideoOverlayGeometry({ w: 1080, h: 1920 }, { anchor: "center", x: 0, y: 0, width: 60, fit: "contain" }, 400 / 4000)).toEqual({
206
+ box: { left: 444, top: 0, width: 192, height: 1920 },
207
+ drawn: { left: 444, top: 0, width: 192, height: 1920 },
208
+ })
209
+ })
210
+ it("…and with height 60 % set: box 648 × 1152, contain → 114 × 1152, no clamp", () => {
211
+ const g = resolveVideoOverlayGeometry({ w: 1080, h: 1920 }, { anchor: "center", x: 0, y: 0, width: 60, height: 60, fit: "contain" }, 400 / 4000)
212
+ expect(g.box).toEqual({ left: 216, top: 384, width: 648, height: 1152 })
213
+ expect(g.drawn).toEqual({ left: 483, top: 384, width: 114, height: 1152 })
214
+ })
215
+ it("a drawn size that would round below 2 px is floored at 2 × 2 (Review Focus 4)", () => {
216
+ expect(resolveVideoOverlayGeometry({ w: 100, h: 100 }, { anchor: "top-left", x: 0, y: 0, width: 1, fit: "contain" }, 1).drawn).toMatchObject({ width: 2, height: 2 })
217
+ })
218
+ it("property: drawn is even, ≥ 2 and never larger than the canvas", () => {
219
+ const canvases = [{ w: 1080, h: 1920 }, { w: 1920, h: 1080 }, { w: 100, h: 100 }, { w: 640, h: 360 }]
220
+ for (const canvas of canvases) {
221
+ for (let width = 1; width <= 100; width += 7) {
222
+ for (const height of [undefined, 1, 14, 60, 100]) {
223
+ for (const aspect of [0.05, 0.5, 1, 2, 20]) {
224
+ for (const fit of ["contain", "cover"] as const) {
225
+ const { drawn } = resolveVideoOverlayGeometry(canvas, { anchor: "center", x: 0, y: 0, width, height, fit }, aspect)
226
+ expect(drawn.width % 2).toBe(0)
227
+ expect(drawn.height % 2).toBe(0)
228
+ expect(drawn.width).toBeGreaterThanOrEqual(2)
229
+ expect(drawn.width).toBeLessThanOrEqual(canvas.w)
230
+ expect(drawn.height).toBeLessThanOrEqual(canvas.h)
231
+ }
232
+ }
233
+ }
234
+ }
235
+ }
236
+ })
237
+ })
238
+
239
+ describe("videoOverlayCanvas", () => {
240
+ it("is the display size: SAR resolved, even-rounded", () => {
241
+ expect(videoOverlayCanvas({ width: 540, height: 1920, sar: 2 })).toEqual({ w: 1080, h: 1920 })
242
+ expect(videoOverlayCanvas({ width: 1079, height: 1919 })).toEqual({ w: 1078, h: 1918 })
243
+ })
244
+ it("a target aspect wins", () => {
245
+ expect(videoOverlayCanvas({ width: 1920, height: 1080 }, "9:16")).toEqual({ w: 1080, h: 1920 })
246
+ expect(videoOverlayCanvas(null, "4:5")).toEqual({ w: 1080, h: 1350 })
247
+ })
248
+ it("unknown size and no aspect → null", () => {
249
+ expect(videoOverlayCanvas(null)).toBeNull()
250
+ })
251
+ })
252
+
253
+ describe("videoOverlayRenderOrder", () => {
254
+ it("explicit zIndex wins, ties keep array order", () => {
255
+ expect(videoOverlayRenderOrder([{ zIndex: 5 }, {}, { zIndex: 0 }])).toEqual([2, 1, 0])
256
+ })
257
+
258
+ // The stage orders every slot; the worker orders the COMPACTED request
259
+ // (empty slots dropped by the assembly, late starters dropped by the clamp).
260
+ // A layer with no zIndex must sit at its own position in both.
261
+ const stageSlots = (stored: ReadonlyArray<VideoOverlayLayerInput | null>): number[] =>
262
+ videoOverlayRenderOrder(stored.map((l, i) => ({ zIndex: l?.zIndex, slot: i + 1 })))
263
+ .filter((i) => stored[i] !== null)
264
+ .map((i) => i + 1)
265
+
266
+ it("stage order equals render order with an empty slot before the layers: [null, A, B{zIndex:0}] → slots [3, 2] both", () => {
267
+ const stored: Array<VideoOverlayLayerInput | null> = [null, { imageUrl: IMG, start: 0 }, { imageUrl: IMG, start: 0, zIndex: 0 }]
268
+ const request = assembleVideoOverlayRequest({ videoUrl: "v", data: { layers: stored }, wiredImageUrls: [] })
269
+ // The worker's graph layers carry the request index and the stamped slot.
270
+ const graph = request.layers.map((l, index) => ({ index, slot: l.slot, zIndex: l.zIndex }))
271
+ const renderSlots = videoOverlayRenderOrder(graph).map((k) => graph[k]!.slot)
272
+ expect(stageSlots(stored)).toEqual([3, 2])
273
+ expect(renderSlots).toEqual(stageSlots(stored))
274
+ })
275
+
276
+ it("a layer skipped for starting after the end does not shift the others (REST request: no slots, request index)", () => {
277
+ // [X (skipped), Y, Z{zIndex:0}] — the full request orders Z below Y; so must the kept pair.
278
+ const full = [{ index: 0 }, { index: 1 }, { index: 2, zIndex: 0 }]
279
+ const expected = videoOverlayRenderOrder(full).filter((i) => i !== 0)
280
+ const kept = full.slice(1)
281
+ expect(videoOverlayRenderOrder(kept).map((k) => kept[k]!.index)).toEqual(expected)
282
+ expect(expected).toEqual([2, 1])
283
+ })
284
+
285
+ it("the same with canvas slots: a skipped slot-1 layer, slot 3 at zIndex 1 stays above slot 2", () => {
286
+ const stored: Array<VideoOverlayLayerInput | null> = [
287
+ { imageUrl: IMG, start: 99 },
288
+ { imageUrl: IMG, start: 0 },
289
+ { imageUrl: IMG, start: 0, zIndex: 1 },
290
+ ]
291
+ const request = assembleVideoOverlayRequest({ videoUrl: "v", data: { layers: stored }, wiredImageUrls: [] })
292
+ const kept = request.layers.map((l, index) => ({ index, slot: l.slot, zIndex: l.zIndex })).filter((l) => l.index !== 0)
293
+ expect(videoOverlayRenderOrder(kept).map((k) => kept[k]!.slot)).toEqual(stageSlots(stored).filter((s) => s !== 1))
294
+ })
295
+ })
296
+
297
+ describe("hasDrawableVideoOverlayBox", () => {
298
+ it("a complete (expanded) layer is drawable", () => {
299
+ expect(hasDrawableVideoOverlayBox(expandVideoOverlayLayer({ imageUrl: IMG, start: 0 }))).toBe(true)
300
+ expect(hasDrawableVideoOverlayBox(expandVideoOverlayLayer({ imageUrl: IMG, start: 0, preset: "card" }))).toBe(true)
301
+ })
302
+
303
+ it("a stored partial box is not — the expansion leaves it as written, the geometry would throw", () => {
304
+ const noAnchor = { imageUrl: IMG, start: 0, width: 40 }
305
+ const nullAnchor = { imageUrl: IMG, start: 0, anchor: null, width: 30 }
306
+ expect(expandVideoOverlayLayer(noAnchor)).toBe(noAnchor)
307
+ expect(hasDrawableVideoOverlayBox(noAnchor)).toBe(false)
308
+ expect(hasDrawableVideoOverlayBox(nullAnchor)).toBe(false)
309
+ expect(hasDrawableVideoOverlayBox({ imageUrl: IMG, start: 0, anchor: "center" })).toBe(false)
310
+ })
311
+
312
+ it("an unknown anchor, a non-finite width or an out-of-range field is not", () => {
313
+ const ok = expandVideoOverlayLayer({ imageUrl: IMG, start: 0 })
314
+ expect(hasDrawableVideoOverlayBox({ ...ok, anchor: "middle" })).toBe(false)
315
+ expect(hasDrawableVideoOverlayBox({ ...ok, width: Number.NaN })).toBe(false)
316
+ expect(hasDrawableVideoOverlayBox({ ...ok, width: "40" })).toBe(false)
317
+ expect(hasDrawableVideoOverlayBox({ ...ok, x: 500 })).toBe(false)
318
+ expect(hasDrawableVideoOverlayBox(null)).toBe(false)
319
+ expect(hasDrawableVideoOverlayBox("layer")).toBe(false)
320
+ })
321
+
322
+ it("agrees with the validator: whatever it passes is drawable", () => {
323
+ const layers = [
324
+ expandVideoOverlayLayer({ imageUrl: IMG, start: 0, anchor: "top-left", width: 30 }),
325
+ expandVideoOverlayLayer({ imageUrl: IMG, start: 0, preset: "full-frame" }),
326
+ ]
327
+ expect(validateVideoOverlayRequest({ layers }).ok).toBe(true)
328
+ for (const l of layers) expect(hasDrawableVideoOverlayBox(l)).toBe(true)
329
+ })
330
+ })
331
+
332
+ describe("validateVideoOverlayRequest (§3.5)", () => {
333
+ const ok = { imageUrl: IMG, start: 0 }
334
+ const fail = (body: Parameters<typeof validateVideoOverlayRequest>[0]) => validateVideoOverlayRequest(body) as VideoOverlayIssue
335
+ it("returns a code, the 0-based layer, the slot when present, and params — never English", () => {
336
+ const r = fail({ layers: [ok, ok, { imageUrl: IMG, start: 5, end: 4 }] })
337
+ expect(r).toEqual({ ok: false, code: "end_before_start", layer: 2, params: { start: 5, end: 4 } })
338
+ expect(formatVideoOverlayError(r)).toBe("layers[2]: end (4 s) must be after start (5 s)")
339
+ const s = fail({ layers: [{ imageUrl: IMG, start: 5, end: 4, slot: 3 }] })
340
+ expect(formatVideoOverlayError(s)).toBe("Layer 3: end (4 s) must be after start (5 s)")
341
+ })
342
+ it("layer count 1..20", () => {
343
+ expect(fail({ layers: [] }).code).toBe("no_layers")
344
+ const many = fail({ layers: Array.from({ length: 21 }, () => ok) })
345
+ expect(many.code).toBe("too_many_layers")
346
+ expect(formatVideoOverlayError(many)).toBe("At most 20 layers (got 21)")
347
+ })
348
+ it("times finite within 0..3600", () => {
349
+ expect(fail({ layers: [{ imageUrl: IMG, start: -1 }] }).code).toBe("time_out_of_range")
350
+ expect(fail({ layers: [{ imageUrl: IMG, start: 0, end: 3601 }] }).code).toBe("time_out_of_range")
351
+ expect(fail({ layers: [{ imageUrl: IMG, start: Number.POSITIVE_INFINITY }] }).code).toBe("time_out_of_range")
352
+ })
353
+ it("a no-preset partial box is incomplete_box; no preset and no box is fine before AND after expansion (D8)", () => {
354
+ const partial = fail({ layers: [{ start: 0, width: 40 }] })
355
+ expect(partial.code).toBe("incomplete_box")
356
+ expect(formatVideoOverlayError(partial)).toBe("layers[0]: anchor and width are required without a preset")
357
+ expect(validateVideoOverlayRequest({ layers: [{ imageUrl: IMG, start: 0 }] })).toEqual({ ok: true })
358
+ expect(validateVideoOverlayRequest({ layers: expandVideoOverlayPresets([{ imageUrl: IMG, start: 0 }]) })).toEqual({ ok: true })
359
+ expect(validateVideoOverlayRequest({ layers: [{ imageUrl: IMG, start: 0, preset: "card", width: 50 }] })).toEqual({ ok: true })
360
+ })
361
+ it("a present box / look field outside VIDEO_OVERLAY_BOUNDS or its enum / type is field_out_of_bounds (spec §4.4, the DAG path's only check)", () => {
362
+ const code = (extra: Record<string, unknown>) => {
363
+ const r = validateVideoOverlayRequest({ layers: [ok, { imageUrl: IMG, start: 0, anchor: "center", width: 30, ...extra } as VideoOverlayLayerInput] })
364
+ return r.ok ? "ok" : ([r.code, r.layer, r.params.field] as const)
365
+ }
366
+ expect(code({ width: 500, height: 100, fit: "cover" })).toEqual(["field_out_of_bounds", 1, "width"])
367
+ expect(code({ width: 101 })).toEqual(["field_out_of_bounds", 1, "width"])
368
+ expect(code({ width: "50" })).toEqual(["field_out_of_bounds", 1, "width"])
369
+ expect(code({ x: -101 })).toEqual(["field_out_of_bounds", 1, "x"])
370
+ expect(code({ height: 0 })).toEqual(["field_out_of_bounds", 1, "height"])
371
+ expect(code({ y: Number.NaN })).toEqual(["field_out_of_bounds", 1, "y"])
372
+ expect(code({ opacity: 5 })).toEqual(["field_out_of_bounds", 1, "opacity"])
373
+ expect(code({ opacity: 9 })).toEqual(["field_out_of_bounds", 1, "opacity"])
374
+ expect(code({ zIndex: 2.5 })).toEqual(["field_out_of_bounds", 1, "zIndex"])
375
+ expect(code({ zIndex: -3 })).toEqual(["field_out_of_bounds", 1, "zIndex"])
376
+ expect(code({ anchor: 7 })).toEqual(["field_out_of_bounds", 1, "anchor"])
377
+ expect(code({ anchor: "middle" })).toEqual(["field_out_of_bounds", 1, "anchor"])
378
+ expect(code({ fit: "stretch" })).toEqual(["field_out_of_bounds", 1, "fit"])
379
+ expect(code({ animate: "yes" })).toEqual(["field_out_of_bounds", 1, "animate"])
380
+ // null reads as absent; the bounds themselves are inclusive
381
+ expect(code({ height: null, zIndex: null, opacity: null })).toBe("ok")
382
+ expect(code({ x: -100, y: 100, width: 100, height: 1, opacity: 0, zIndex: 100, fit: "cover", animate: false })).toBe("ok")
383
+ })
384
+ it("field_out_of_bounds carries the slot, a symbolic range, and renders one English line", () => {
385
+ const r = fail({ layers: [{ imageUrl: IMG, start: 0, anchor: "center", width: 30, zIndex: 2.5, slot: 4 }] })
386
+ expect(r).toEqual({ ok: false, code: "field_out_of_bounds", layer: 0, slot: 4, params: { field: "zIndex", allowed: "integers 0..100" } })
387
+ expect(formatVideoOverlayError(r)).toBe("Layer 4: zIndex is out of range (allowed: integers 0..100)")
388
+ expect(fail({ layers: [{ imageUrl: IMG, start: 0, anchor: "center", width: 30, fit: "stretch" } as unknown as VideoOverlayLayerInput] }).params).toEqual({ field: "fit", allowed: "contain, cover" })
389
+ })
390
+ it("the bounds verdict is the same before and after the expansion (§3.5 order-independence)", () => {
391
+ const bad = [{ imageUrl: IMG, start: 0, preset: "card" as const, width: 500 }]
392
+ expect(fail({ layers: bad }).code).toBe("field_out_of_bounds")
393
+ expect(fail({ layers: expandVideoOverlayPresets(bad) }).code).toBe("field_out_of_bounds")
394
+ const good = [{ imageUrl: IMG, start: 0, preset: "card" as const }]
395
+ expect(validateVideoOverlayRequest({ layers: good })).toEqual({ ok: true })
396
+ expect(validateVideoOverlayRequest({ layers: expandVideoOverlayPresets(good) })).toEqual({ ok: true })
397
+ const noBoxBadLook = [{ imageUrl: IMG, start: 0, opacity: 5 }]
398
+ expect(fail({ layers: noBoxBadLook }).code).toBe("field_out_of_bounds")
399
+ expect(fail({ layers: expandVideoOverlayPresets(noBoxBadLook) }).code).toBe("field_out_of_bounds")
400
+ })
401
+ it("an assembled JSON-written layer with an unbounded box is refused before it reaches the geometry (the DAG path)", () => {
402
+ const req = assembleVideoOverlayRequest({
403
+ videoUrl: "v",
404
+ data: { layers: [{ anchor: "center", width: 100000, height: 100, fit: "cover", start: 0 } as VideoOverlayLayerInput] },
405
+ wiredImageUrls: [IMG],
406
+ })
407
+ expect(validateVideoOverlayRequest(req)).toMatchObject({ ok: false, code: "field_out_of_bounds", slot: 1, params: { field: "width" } })
408
+ const anchor = assembleVideoOverlayRequest({ videoUrl: "v", data: { layers: [{ anchor: 7, width: 30, start: 0 } as unknown as VideoOverlayLayerInput] }, wiredImageUrls: [IMG] })
409
+ expect(validateVideoOverlayRequest(anchor)).toMatchObject({ ok: false, code: "field_out_of_bounds", params: { field: "anchor" } })
410
+ })
411
+ it("every layer needs an image", () => {
412
+ expect(fail({ layers: [{ start: 0 }] }).code).toBe("layer_without_image")
413
+ })
414
+ it("baseFit / backgroundColor only with an outputAspect", () => {
415
+ expect(fail({ layers: [ok], baseFit: "contain" }).code).toBe("fit_without_aspect")
416
+ expect(validateVideoOverlayRequest({ layers: [ok], outputAspect: "9:16", baseFit: "contain", backgroundColor: "#ff0000" })).toEqual({ ok: true })
417
+ })
418
+ })
419
+
420
+ describe("assembleVideoOverlayRequest (the ONE assembly both engines call)", () => {
421
+ it("a wired slot with no settings runs as the default layer, slot stamped (D2)", () => {
422
+ const r = assembleVideoOverlayRequest({ videoUrl: "v", data: { layers: [] }, wiredImageUrls: ["https://x/1.png"] })
423
+ expect(r.layers).toEqual([{ ...DEFAULT_VIDEO_OVERLAY_LAYER, imageUrl: "https://x/1.png", slot: 1 }])
424
+ })
425
+ it("wired wins over a stored imageUrl; an imageUrl-only layer 13 is kept with slot 13; an empty unwired slot is dropped", () => {
426
+ const layers: Array<VideoOverlayLayerInput | null> = Array.from({ length: 13 }, () => null)
427
+ layers[0] = { imageUrl: "https://x/stored.png", start: 2 }
428
+ layers[12] = { imageUrl: "https://x/13.png", start: 1, preset: "card" }
429
+ const r = assembleVideoOverlayRequest({ videoUrl: "v", data: { layers }, wiredImageUrls: ["https://x/wired.png"] })
430
+ expect(r.layers.map((l) => [l.slot, l.imageUrl])).toEqual([[1, "https://x/wired.png"], [13, "https://x/13.png"]])
431
+ expect(r.layers[0]).toMatchObject({ start: 2, preset: "corner-badge" })
432
+ })
433
+ it("a null slot that is wired is the default layer (Review Focus 1)", () => {
434
+ const r = assembleVideoOverlayRequest({ videoUrl: "v", data: { layers: [null, { start: 3 }] }, wiredImageUrls: ["https://x/1.png", "https://x/2.png"] })
435
+ expect(r.layers[0]).toEqual({ ...DEFAULT_VIDEO_OVERLAY_LAYER, imageUrl: "https://x/1.png", slot: 1 })
436
+ expect(r.layers[1]).toMatchObject({ start: 3, slot: 2 })
437
+ })
438
+ it("a non-object layer entry reads as an empty slot: the D2 default when wired, dropped when not — never spread", () => {
439
+ const r = assembleVideoOverlayRequest({ videoUrl: "v", data: { layers: ["ab", "https://x/unwired.png"] as unknown as VideoOverlayLayerInput[] }, wiredImageUrls: ["https://x/1.png"] })
440
+ expect(r.layers).toEqual([{ ...DEFAULT_VIDEO_OVERLAY_LAYER, imageUrl: "https://x/1.png", slot: 1 }])
441
+ expect(r.layers[0]).not.toHaveProperty("0")
442
+ expect(expandVideoOverlayLayer("ab" as unknown as VideoOverlayLayerInput)).toEqual(DEFAULT_VIDEO_OVERLAY_LAYER)
443
+ })
444
+ it("carries every stored slot past 20, so the validator refuses the request (too_many_layers) instead of dropping layers 21+", () => {
445
+ const layers = Array.from({ length: 25 }, () => ({ imageUrl: "https://x/i.png", start: 0 }))
446
+ const r = assembleVideoOverlayRequest({ videoUrl: "v", data: { layers }, wiredImageUrls: [] })
447
+ expect(r.layers).toHaveLength(25)
448
+ expect(r.layers.at(-1)!.slot).toBe(25)
449
+ expect(validateVideoOverlayRequest(r)).toEqual({ ok: false, code: "too_many_layers", params: { max: 20, count: 25 } })
450
+ })
451
+ it("21+ stored slots without an image are still dropped — only layers that render count toward the cap", () => {
452
+ const layers: Array<VideoOverlayLayerInput | null> = Array.from({ length: 25 }, () => null)
453
+ layers[0] = { imageUrl: "https://x/i.png", start: 0 }
454
+ const r = assembleVideoOverlayRequest({ videoUrl: "v", data: { layers }, wiredImageUrls: [] })
455
+ expect(r.layers.map((l) => l.slot)).toEqual([1])
456
+ expect(validateVideoOverlayRequest(r)).toEqual({ ok: true })
457
+ })
458
+ it("keeps the 12-handle cap on the wired list: a 13th wired URL is ignored", () => {
459
+ const wired = Array.from({ length: 13 }, (_, i) => `https://x/w${i}.png`)
460
+ const r = assembleVideoOverlayRequest({ videoUrl: "v", data: { layers: [] }, wiredImageUrls: wired })
461
+ expect(r.layers).toHaveLength(12)
462
+ })
463
+ it("carries baseFit / backgroundColor as stored — the VALIDATOR refuses them without an outputAspect (spec §3.5 fit_without_aspect)", () => {
464
+ const base = { layers: [{ imageUrl: IMG, start: 0 }], baseFit: "contain", backgroundColor: "#ff0000" }
465
+ const noAspect = assembleVideoOverlayRequest({ videoUrl: "v", data: base, wiredImageUrls: [] })
466
+ expect(noAspect).toMatchObject({ baseFit: "contain", backgroundColor: "#ff0000" })
467
+ expect(noAspect).not.toHaveProperty("outputAspect")
468
+ expect(validateVideoOverlayRequest(noAspect)).toMatchObject({ ok: false, code: "fit_without_aspect" })
469
+ expect(assembleVideoOverlayRequest({ videoUrl: "v", data: { ...base, outputAspect: "9:16" }, wiredImageUrls: [] })).toMatchObject({ outputAspect: "9:16", baseFit: "contain", backgroundColor: "#ff0000" })
470
+ })
471
+ it("never forwards a value the route's schema would refuse: an unknown aspect or fit, a colour that is not #rrggbb", () => {
472
+ const r = assembleVideoOverlayRequest({ videoUrl: "v", data: { layers: [{ imageUrl: IMG, start: 0 }], outputAspect: "3:2", baseFit: "stretch", backgroundColor: "red" }, wiredImageUrls: [] })
473
+ expect(Object.keys(r).sort()).toEqual(["layers", "videoUrl"])
474
+ })
475
+ })
476
+
477
+ describe("videoOverlaySlotSources — wired ?? imageUrl, by slot", () => {
478
+ it("a wire wins; an unwired slot draws its own imageUrl; neither is undefined", () => {
479
+ expect(
480
+ videoOverlaySlotSources(
481
+ [{ imageUrl: "https://x/own0.png" }, null, { imageUrl: "https://x/own2.png" }],
482
+ ["https://x/wired0.png", undefined, undefined, "https://x/wired3.png"],
483
+ ),
484
+ ).toEqual(["https://x/wired0.png", undefined, "https://x/own2.png", "https://x/wired3.png"])
485
+ })
486
+ it("a layer's own imageUrl is its source at any slot — past 20 too (layers 1–4 removed from a 24-layer node)", () => {
487
+ const layers: Array<VideoOverlayLayerInput | null> = Array.from({ length: 24 }, (_, i) => ({ imageUrl: `https://x/${i}.png` }))
488
+ for (const i of [0, 1, 2, 3]) layers[i] = null
489
+ const sources = videoOverlaySlotSources(layers, [])
490
+ expect(sources).toHaveLength(24)
491
+ expect(sources.slice(0, 4)).toEqual([undefined, undefined, undefined, undefined])
492
+ expect(sources[23]).toBe("https://x/23.png")
493
+ expect(sources.filter(Boolean)).toHaveLength(20)
494
+ })
495
+ it("wired handles stay capped at 12: a 13th wired URL is no source, the slot's own imageUrl still is", () => {
496
+ const wired = Array.from({ length: 14 }, (_, i) => `https://x/w${i}.png`)
497
+ const layers: Array<VideoOverlayLayerInput | null> = Array.from({ length: 13 }, () => null)
498
+ layers[12] = { imageUrl: "https://x/own13.png" }
499
+ const sources = videoOverlaySlotSources(layers, wired)
500
+ expect(sources).toHaveLength(13)
501
+ expect(sources[11]).toBe("https://x/w11.png")
502
+ expect(sources[12]).toBe("https://x/own13.png")
503
+ expect(videoOverlaySlotSources([], wired)).toHaveLength(12)
504
+ })
505
+ it("the composition key follows a slot past 20: another image at slot 24 is another key", () => {
506
+ const layers = (url: string): Array<VideoOverlayLayerInput | null> => [null, null, null, null, ...Array.from({ length: 19 }, (_, i) => ({ imageUrl: `https://x/${i}.png` })), { imageUrl: url }]
507
+ const key = (url: string) => videoOverlayCompositionKey({ baseUrl: "b", sources: videoOverlaySlotSources(layers(url), []), data: { layers: layers(url) } })
508
+ const a = layers("https://x/a.png")
509
+ expect(videoOverlaySlotSources(a, [])[23]).toBe("https://x/a.png")
510
+ expect(key("https://x/a.png")).not.toBe(key("https://x/b.png"))
511
+ expect(JSON.parse(key("https://x/a.png"))[1]).toHaveLength(24)
512
+ })
513
+ it("reads non-array layers as none", () => {
514
+ expect(videoOverlaySlotSources(null, ["https://x/w.png"])).toEqual(["https://x/w.png"])
515
+ })
516
+ })
517
+
518
+ describe("videoOverlayCompositionKey — what a run stamps as resultCompositionKey (audit U10)", () => {
519
+ const base = { baseUrl: "https://x/base.mp4", sources: ["https://x/a.png"], data: { layers: [{ start: 1 }] } }
520
+ const key = videoOverlayCompositionKey(base)
521
+
522
+ it("changes with the base video, a slot's image, the layers, the aspect, the fit and the pad colour", () => {
523
+ const variants = [
524
+ { ...base, baseUrl: "https://x/other.mp4" },
525
+ { ...base, sources: ["https://x/b.png"] },
526
+ { ...base, data: { ...base.data, layers: [{ start: 2 }] } },
527
+ { ...base, data: { ...base.data, outputAspect: "9:16" } },
528
+ { ...base, data: { ...base.data, baseFit: "contain" } },
529
+ { ...base, data: { ...base.data, backgroundColor: "#ff0000" } },
530
+ ]
531
+ for (const v of variants) expect(videoOverlayCompositionKey(v)).not.toBe(key)
532
+ })
533
+
534
+ it("is canonical: a layer's key order does not change it (a jsonb round-trip reorders keys; the canvas keeps insertion order)", () => {
535
+ const canvas: VideoOverlayLayerInput = { imageUrl: IMG, start: 1, end: 4, preset: "card", anchor: "center", x: 0, y: -4, width: 78, height: 60, fit: "contain", opacity: 1, animate: true }
536
+ const fromDb: VideoOverlayLayerInput = { x: 0, y: -4, end: 4, fit: "contain", start: 1, width: 78, anchor: "center", height: 60, preset: "card", animate: true, opacity: 1, imageUrl: IMG }
537
+ expect(videoOverlayCompositionKey({ ...base, data: { layers: [canvas] } })).toBe(videoOverlayCompositionKey({ ...base, data: { layers: [fromDb] } }))
538
+ })
539
+
540
+ it("an absent, undefined or null layer field is the same key (the assembly reads all three as absent); a null SLOT keeps its place", () => {
541
+ const k = videoOverlayCompositionKey({ ...base, data: { layers: [{ start: 1 }] } })
542
+ expect(videoOverlayCompositionKey({ ...base, data: { layers: [{ start: 1, end: undefined }] } })).toBe(k)
543
+ expect(videoOverlayCompositionKey({ ...base, data: { layers: [{ start: 1, end: null } as unknown as VideoOverlayLayerInput] } })).toBe(k)
544
+ expect(videoOverlayCompositionKey({ ...base, data: { layers: [null, { start: 1 }] } })).not.toBe(k)
545
+ })
546
+
547
+ it("an unset top-level field is the same key whether absent, undefined or null", () => {
548
+ expect(videoOverlayCompositionKey({ ...base, data: { ...base.data, outputAspect: undefined, baseFit: null } })).toBe(key)
549
+ expect(videoOverlayCompositionKey({ ...base, baseUrl: undefined })).toBe(videoOverlayCompositionKey({ ...base, baseUrl: null }))
550
+ })
551
+
552
+ it("trailing empty slots do not change it: a wired list with an unfilled last handle equals the shorter list", () => {
553
+ expect(videoOverlayCompositionKey({ ...base, sources: ["https://x/a.png", undefined, null] })).toBe(key)
554
+ expect(videoOverlayCompositionKey({ ...base, sources: [undefined, "https://x/a.png"] })).not.toBe(key)
555
+ })
556
+ })
557
+
558
+ describe("VIDEO_OVERLAY_MAX_COMPOSITION_KEY_LENGTH — the bound the REST route accepts the canvas key under", () => {
559
+ it("fits a full 20-layer composition with long signed URLs on every slot and every stored layer", () => {
560
+ const url = (i: number) => `https://storage.example/bucket/${"p".repeat(400)}/${i}.png?X-Signature=${"s".repeat(200)}`
561
+ const layers: VideoOverlayLayerInput[] = Array.from({ length: VIDEO_OVERLAY_MAX_LAYERS }, (_, i) => ({
562
+ imageUrl: url(i), start: 1234.567, end: 2345.678, preset: "corner-badge", corner: "bottom-right", anchor: "bottom-right",
563
+ x: -4, y: -4, width: 18, height: 60, fit: "contain", opacity: 0.5, animate: true, zIndex: 100,
564
+ }))
565
+ const key = videoOverlayCompositionKey({
566
+ baseUrl: url(99),
567
+ sources: layers.map((_, i) => url(i + 100)),
568
+ data: { layers, outputAspect: "9:16", baseFit: "contain", backgroundColor: "#101010" },
569
+ })
570
+ expect(key.length).toBeLessThan(VIDEO_OVERLAY_MAX_COMPOSITION_KEY_LENGTH)
571
+ })
572
+ })