@nodaro/shared 3.12.1 → 3.14.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 (83) hide show
  1. package/dist/index.cjs +442 -6
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +423 -3
  4. package/dist/index.d.ts +423 -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/catalog-projection.ts +20 -0
  12. package/src/i18n/action-fx.ja.ts +17 -17
  13. package/src/i18n/aesthetic.ja.ts +26 -20
  14. package/src/i18n/animals.ja.ts +49 -49
  15. package/src/i18n/atmosphere.ja.ts +19 -19
  16. package/src/i18n/backdrop.ja.ts +13 -13
  17. package/src/i18n/camera-format.ja.ts +3 -3
  18. package/src/i18n/camera-motions.ja.ts +15 -15
  19. package/src/i18n/character-fx.ja.ts +12 -12
  20. package/src/i18n/character-motion.ja.ts +72 -72
  21. package/src/i18n/color-look.ja.ts +19 -19
  22. package/src/i18n/composition-effects.ja.ts +6 -6
  23. package/src/i18n/era.ja.ts +11 -11
  24. package/src/i18n/exposure-settings.ja.ts +10 -10
  25. package/src/i18n/framing.ja.ts +11 -11
  26. package/src/i18n/furniture.ja.ts +22 -22
  27. package/src/i18n/held-prop.ja.ts +7 -7
  28. package/src/i18n/instrumentation.ja.ts +41 -41
  29. package/src/i18n/lens.ja.ts +5 -5
  30. package/src/i18n/lighting.ja.ts +21 -21
  31. package/src/i18n/materials.ja.ts +4 -4
  32. package/src/i18n/mood.ja.ts +18 -18
  33. package/src/i18n/music-genre.ja.ts +16 -16
  34. package/src/i18n/music-mood.ja.ts +18 -18
  35. package/src/i18n/person.ar.ts +1 -0
  36. package/src/i18n/person.de.ts +1 -0
  37. package/src/i18n/person.es.ts +1 -0
  38. package/src/i18n/person.fr.ts +1 -0
  39. package/src/i18n/person.he.ts +1 -0
  40. package/src/i18n/person.hi.ts +1 -0
  41. package/src/i18n/person.ja.ts +179 -178
  42. package/src/i18n/person.ko.ts +1 -0
  43. package/src/i18n/person.pt-BR.ts +1 -0
  44. package/src/i18n/person.ru.ts +1 -0
  45. package/src/i18n/person.zh-CN.ts +1 -0
  46. package/src/i18n/photo-genre.ja.ts +11 -11
  47. package/src/i18n/photographer.ja.ts +17 -17
  48. package/src/i18n/pose.ja.ts +14 -14
  49. package/src/i18n/post-process-effects.ja.ts +5 -5
  50. package/src/i18n/render-quality.ja.ts +9 -9
  51. package/src/i18n/setting.ar.ts +2 -0
  52. package/src/i18n/setting.de.ts +2 -0
  53. package/src/i18n/setting.es.ts +2 -0
  54. package/src/i18n/setting.fr.ts +2 -0
  55. package/src/i18n/setting.he.ts +2 -0
  56. package/src/i18n/setting.hi.ts +2 -0
  57. package/src/i18n/setting.ja.ts +16 -14
  58. package/src/i18n/setting.ko.ts +2 -0
  59. package/src/i18n/setting.pt-BR.ts +2 -0
  60. package/src/i18n/setting.ru.ts +2 -0
  61. package/src/i18n/setting.zh-CN.ts +2 -0
  62. package/src/i18n/style.ja.ts +10 -10
  63. package/src/i18n/styling.ar.ts +2 -0
  64. package/src/i18n/styling.de.ts +2 -0
  65. package/src/i18n/styling.es.ts +2 -0
  66. package/src/i18n/styling.fr.ts +2 -0
  67. package/src/i18n/styling.he.ts +2 -0
  68. package/src/i18n/styling.hi.ts +2 -0
  69. package/src/i18n/styling.ja.ts +58 -56
  70. package/src/i18n/styling.ko.ts +2 -0
  71. package/src/i18n/styling.pt-BR.ts +2 -0
  72. package/src/i18n/styling.ru.ts +2 -0
  73. package/src/i18n/styling.zh-CN.ts +2 -0
  74. package/src/i18n/temporal.ja.ts +5 -5
  75. package/src/i18n/transitions.ja.ts +12 -12
  76. package/src/i18n/vehicles.ja.ts +29 -29
  77. package/src/i18n/voice-character.ja.ts +27 -27
  78. package/src/i18n/voice-delivery.ja.ts +28 -28
  79. package/src/i18n/weapons.ja.ts +19 -19
  80. package/src/index.ts +2 -0
  81. package/src/producer-types.ts +3 -0
  82. package/src/trigger-node-types.ts +3 -0
  83. package/src/video-overlay.ts +812 -0
@@ -0,0 +1,812 @@
1
+ /**
2
+ * Video Overlay — timed image layers over a video. The wire contract of the
3
+ * `video-overlay` node, `POST /v1/video-overlay`, the `overlay_images` MCP
4
+ * verb, `client.media.videoOverlay` and `nodaro media video-overlay`, defined
5
+ * ONCE:
6
+ * - every numeric bound (the route schema, the validator — which refuses a
7
+ * present box / look field outside `VIDEO_OVERLAY_BOUNDS`, so the DAG path
8
+ * that never meets the route schema is bounded too — both engine
9
+ * assemblies and the canvas read these — never a second copy);
10
+ * - the placement presets, stored as data in the custom box vocabulary: a
11
+ * `preset` is a TAG, the box is the truth — the worker and the preview
12
+ * never switch on the tag;
13
+ * - `expandVideoOverlayLayer` / `expandVideoOverlayPresets`: the one
14
+ * normaliser every write boundary calls;
15
+ * - `resolveVideoOverlayGeometry`: the one placement function the canvas
16
+ * preview and the ffmpeg worker both call;
17
+ * - `assembleVideoOverlayRequest`: the one node-data → request assembly both
18
+ * workflow engines call;
19
+ * - `videoOverlayCompositionKey` (+ `videoOverlaySlotSources`): the freshness
20
+ * key a run stamps on its result (`resultCompositionKey`) — the canvas
21
+ * single-node Run (which sends it with the request, bounded by
22
+ * `VIDEO_OVERLAY_MAX_COMPOSITION_KEY_LENGTH`) and the DAG payload compute
23
+ * it with this one function, and the node compares it against the key of
24
+ * its current settings;
25
+ * - `validateVideoOverlayRequest`: what a schema cannot express, at every
26
+ * engine entry. It returns a CODE, never English: `formatVideoOverlayError`
27
+ * renders the route / worker text, the canvas renders the code through its
28
+ * dictionaries.
29
+ * Wire contract only — no prompt content, no I/O.
30
+ */
31
+ import { OVERLAY_ANCHORS, type OverlayAnchor } from "./image-overlay-layers.js"
32
+ import { ASPECT_RATIO_DIMENSIONS } from "./model-constants.js"
33
+
34
+ /** Engine + MCP contract: layers per request. */
35
+ export const VIDEO_OVERLAY_MAX_LAYERS = 20
36
+ /** Ceiling for `start` / `end`, seconds. */
37
+ export const VIDEO_OVERLAY_MAX_TIME_SEC = 3600
38
+ /**
39
+ * The canvas layer handles, index-aligned with `layers[]` (overlay → layers[0]
40
+ * … overlay12 → layers[11]). Slots 13 and up exist in data only, via `imageUrl`.
41
+ * The same ids Image Overlay renders.
42
+ */
43
+ export const VIDEO_OVERLAY_HANDLE_IDS = [
44
+ "overlay", "overlay2", "overlay3", "overlay4", "overlay5", "overlay6",
45
+ "overlay7", "overlay8", "overlay9", "overlay10", "overlay11", "overlay12",
46
+ ] as const
47
+ export const VIDEO_OVERLAY_PRESET_IDS = ["card", "corner-badge", "full-frame"] as const
48
+ export type VideoOverlayPresetId = (typeof VIDEO_OVERLAY_PRESET_IDS)[number]
49
+ export const VIDEO_OVERLAY_CORNERS = ["top-left", "top-right", "bottom-left", "bottom-right"] as const
50
+ export type VideoOverlayCorner = (typeof VIDEO_OVERLAY_CORNERS)[number]
51
+ export const VIDEO_OVERLAY_FITS = ["contain", "cover"] as const
52
+ export type VideoOverlayFit = (typeof VIDEO_OVERLAY_FITS)[number]
53
+ /** Target aspects; absent = the base's own size and fps. */
54
+ export const VIDEO_OVERLAY_OUTPUT_ASPECTS = ["16:9", "9:16", "1:1", "4:5"] as const
55
+ export type VideoOverlayOutputAspect = (typeof VIDEO_OVERLAY_OUTPUT_ASPECTS)[number]
56
+ /**
57
+ * Reserved input id for a JSON layer plan. Payload: `VideoOverlayLayer[]` — the
58
+ * same shape as the node's `layers[]`, a `preset` tag allowed and expanded at
59
+ * the boundary. v1 renders NO pip for it: it is not in the node's inputs nor
60
+ * any handle registry; both input resolvers route an edge on it into
61
+ * `inputs.layerPlan`, which v1 ignores.
62
+ */
63
+ export const VIDEO_OVERLAY_LAYER_PLAN_HANDLE = "layerPlan"
64
+ /** Box and look bounds. */
65
+ export const VIDEO_OVERLAY_BOUNDS = {
66
+ x: [-100, 100],
67
+ y: [-100, 100],
68
+ width: [1, 100],
69
+ height: [1, 100],
70
+ opacity: [0, 1],
71
+ zIndex: [0, 100],
72
+ } as const
73
+ /** Σ downloaded layer bytes per job. */
74
+ export const VIDEO_OVERLAY_MAX_TOTAL_LAYER_BYTES = 100 * 1024 * 1024
75
+ /** Fade + scale ramp, seconds each way (clamped to half of a short layer). */
76
+ export const VIDEO_OVERLAY_ANIMATION_SEC = 0.15
77
+ /** The scale a layer grows from and shrinks back to during the ramp. */
78
+ export const VIDEO_OVERLAY_ANIMATION_MIN_SCALE = 0.96
79
+ /** Corner-badge width and inset, % of the output canvas. */
80
+ export const VIDEO_OVERLAY_BADGE_WIDTH = 18
81
+ export const VIDEO_OVERLAY_BADGE_INSET = 4
82
+ /** Pad colour for `baseFit: "contain"` when none is given. */
83
+ export const VIDEO_OVERLAY_DEFAULT_BACKGROUND = "#000000"
84
+ /** The six box fields — exactly what a preset writes. */
85
+ export const VIDEO_OVERLAY_BOX_FIELDS = ["anchor", "x", "y", "width", "height", "fit"] as const
86
+ export const VIDEO_OVERLAY_WARNING_CODES = ["clipped", "skipped", "animated_first_frame", "audio_reencoded"] as const
87
+ export type VideoOverlayWarningCode = (typeof VIDEO_OVERLAY_WARNING_CODES)[number]
88
+ export const VIDEO_OVERLAY_ERROR_CODES = [
89
+ "no_layers", "too_many_layers", "incomplete_box", "layer_without_image",
90
+ "time_out_of_range", "end_before_start", "fit_without_aspect", "field_out_of_bounds",
91
+ ] as const
92
+ export type VideoOverlayErrorCode = (typeof VIDEO_OVERLAY_ERROR_CODES)[number]
93
+
94
+ export interface VideoOverlayBox {
95
+ readonly anchor: OverlayAnchor
96
+ readonly x: number
97
+ readonly y: number
98
+ readonly width: number
99
+ readonly height?: number
100
+ readonly fit: VideoOverlayFit
101
+ }
102
+
103
+ /** A layer as stored and enqueued — complete: every box and look field present. */
104
+ export interface VideoOverlayLayer {
105
+ /**
106
+ * Used when the layer's canvas handle is not wired (MCP, template, layers
107
+ * 13 and up). Wiring the handle CLEARS it — on the canvas by the one `layers[]`
108
+ * writer (D3), on JSON-written workflows by the write-boundary pass (D10) —
109
+ * and the run-time merge is still `wired ?? imageUrl` everywhere.
110
+ */
111
+ imageUrl?: string
112
+ /** 1-based canvas slot (overlay = 1 … overlay12 = 12; 13 and up handle-less), stamped by both engine assemblies. Messages prefer it. */
113
+ slot?: number
114
+ /** Seconds, 0..3600, stored ms-precise; rendered on the base's frame grid (±1 frame). */
115
+ start: number
116
+ /** Seconds, 0..3600, > start; absent = until the video ends. */
117
+ end?: number
118
+ /** A TAG only — the box below is the truth. */
119
+ preset?: VideoOverlayPresetId
120
+ /** corner-badge only; default bottom-right. */
121
+ corner?: VideoOverlayCorner
122
+ anchor: OverlayAnchor
123
+ /** Offset from the anchor, % of the output canvas width (−100..100; negative on a right anchor = inward). */
124
+ x: number
125
+ /** Offset from the anchor, % of the output canvas height. */
126
+ y: number
127
+ /** % of the output canvas width (1..100). */
128
+ width: number
129
+ /** % of the output canvas height (1..100); absent = follows the image's aspect. */
130
+ height?: number
131
+ fit: VideoOverlayFit
132
+ /** 0..1 */
133
+ opacity: number
134
+ /** Fade + slight scale in and out, 0.15 s each way. */
135
+ animate: boolean
136
+ /** Integer 0..100; absent = its position (slot − 1: layer 1 lowest). */
137
+ zIndex?: number
138
+ }
139
+
140
+ /** A layer as a WRITER may send it (workflow JSON, a template, the canvas): every field optional; `null` means absent. */
141
+ export type VideoOverlayLayerInput = { [K in keyof VideoOverlayLayer]?: VideoOverlayLayer[K] | null }
142
+
143
+ /** A layer on the REST / SDK wire: image and start required; the box may be a preset, explicit fields, or nothing (→ the corner-badge default). */
144
+ export type VideoOverlayLayerSpec = { imageUrl: string; start: number } & Partial<Omit<VideoOverlayLayer, "imageUrl" | "start">>
145
+
146
+ /** The `POST /v1/video-overlay` body. */
147
+ export interface VideoOverlayRequest {
148
+ videoUrl: string
149
+ /** 1..20 */
150
+ layers: VideoOverlayLayerSpec[]
151
+ outputAspect?: VideoOverlayOutputAspect
152
+ /** Only with `outputAspect`; default cover. */
153
+ baseFit?: VideoOverlayFit
154
+ /** `#rrggbb`, only with `outputAspect` (drawn under `contain`); default `#000000`. */
155
+ backgroundColor?: string
156
+ }
157
+
158
+ /** A request whose layers went through `expandVideoOverlayPresets`. */
159
+ export interface ExpandedVideoOverlayRequest extends Omit<VideoOverlayRequest, "layers"> {
160
+ layers: VideoOverlayLayer[]
161
+ }
162
+
163
+ /** `output_data.warnings[]`: `layer` = 0-based index of the request's layers (absent for a render-wide warning such as `audio_reencoded`), `slot` when known. */
164
+ export interface VideoOverlayWarning {
165
+ readonly layer?: number
166
+ readonly slot?: number
167
+ readonly code: VideoOverlayWarningCode
168
+ readonly detail: string
169
+ }
170
+
171
+ const badge = (anchor: VideoOverlayCorner, sx: 1 | -1, sy: 1 | -1): VideoOverlayBox => ({
172
+ anchor,
173
+ x: sx * VIDEO_OVERLAY_BADGE_INSET,
174
+ y: sy * VIDEO_OVERLAY_BADGE_INSET,
175
+ width: VIDEO_OVERLAY_BADGE_WIDTH,
176
+ fit: "contain",
177
+ })
178
+
179
+ /**
180
+ * Placement presets as DATA in the custom vocabulary. `card` sits centred, 4 %
181
+ * above centre, contain into 78 % × 60 % of the frame; `full-frame` covers the
182
+ * frame; `corner-badge` is 18 % wide, 4 % in from its corner.
183
+ */
184
+ export const VIDEO_OVERLAY_PRESETS: {
185
+ readonly card: VideoOverlayBox
186
+ readonly "full-frame": VideoOverlayBox
187
+ readonly "corner-badge": Readonly<Record<VideoOverlayCorner, VideoOverlayBox>>
188
+ } = {
189
+ card: { anchor: "center", x: 0, y: -4, width: 78, height: 60, fit: "contain" },
190
+ "full-frame": { anchor: "center", x: 0, y: 0, width: 100, height: 100, fit: "cover" },
191
+ "corner-badge": {
192
+ "bottom-right": badge("bottom-right", -1, -1),
193
+ "bottom-left": badge("bottom-left", 1, -1),
194
+ "top-right": badge("top-right", -1, 1),
195
+ "top-left": badge("top-left", 1, 1),
196
+ },
197
+ }
198
+
199
+ /** D2 — what a wired slot with no settings runs (and previews) with: the bottom-right corner badge, from 0 to the end. */
200
+ export const DEFAULT_VIDEO_OVERLAY_LAYER: VideoOverlayLayer = Object.freeze({
201
+ start: 0,
202
+ preset: "corner-badge" as const,
203
+ corner: "bottom-right" as const,
204
+ ...VIDEO_OVERLAY_PRESETS["corner-badge"]["bottom-right"],
205
+ opacity: 1,
206
+ animate: true,
207
+ })
208
+
209
+ const HEX6 = /^#[0-9a-fA-F]{6}$/
210
+
211
+ export function isVideoOverlayPresetId(v: unknown): v is VideoOverlayPresetId {
212
+ return typeof v === "string" && (VIDEO_OVERLAY_PRESET_IDS as readonly string[]).includes(v)
213
+ }
214
+ export function isVideoOverlayCorner(v: unknown): v is VideoOverlayCorner {
215
+ return typeof v === "string" && (VIDEO_OVERLAY_CORNERS as readonly string[]).includes(v)
216
+ }
217
+ export function isVideoOverlayOutputAspect(v: unknown): v is VideoOverlayOutputAspect {
218
+ return typeof v === "string" && (VIDEO_OVERLAY_OUTPUT_ASPECTS as readonly string[]).includes(v)
219
+ }
220
+ function isFit(v: unknown): v is VideoOverlayFit {
221
+ return v === "contain" || v === "cover"
222
+ }
223
+
224
+ /**
225
+ * A `layers[]` entry as a layer, or null when it is not a plain object. Layer
226
+ * arrays reach this module from untrusted workflow JSON (the MCP write sites
227
+ * type nodes as `record<string, unknown>`), so a bare string / number / array
228
+ * entry is read as an ABSENT layer — never spread, never `in`-probed.
229
+ */
230
+ function asLayer(v: unknown): VideoOverlayLayerInput | null {
231
+ return v !== null && typeof v === "object" && !Array.isArray(v) ? (v as VideoOverlayLayerInput) : null
232
+ }
233
+
234
+ /** 1-based slot of a layer handle id (`overlay` → 1 … `overlay12` → 12); 0 for anything else. */
235
+ export function videoOverlaySlotOfHandle(handle: string | null | undefined): number {
236
+ return (VIDEO_OVERLAY_HANDLE_IDS as readonly string[]).indexOf(handle ?? "") + 1
237
+ }
238
+
239
+ export function videoOverlayPresetBox(preset: VideoOverlayPresetId, corner?: VideoOverlayCorner | null): VideoOverlayBox {
240
+ return preset === "corner-badge"
241
+ ? VIDEO_OVERLAY_PRESETS["corner-badge"][isVideoOverlayCorner(corner) ? corner : "bottom-right"]
242
+ : VIDEO_OVERLAY_PRESETS[preset]
243
+ }
244
+
245
+ const TRAILING_DEFAULTS = { x: 0, y: 0, fit: "contain", opacity: 1, animate: true } as const
246
+
247
+ function usable(key: string, value: unknown): boolean {
248
+ if (value === null || value === undefined) return false
249
+ if (key === "preset") return isVideoOverlayPresetId(value)
250
+ if (key === "corner") return isVideoOverlayCorner(value)
251
+ return true
252
+ }
253
+
254
+ /** `null` / `undefined` / an unknown preset or corner → absent. By reference when nothing is dropped. */
255
+ function clean(raw: VideoOverlayLayerInput): Partial<VideoOverlayLayer> {
256
+ const entries = Object.entries(raw)
257
+ if (entries.every(([k, v]) => usable(k, v))) return raw as Partial<VideoOverlayLayer>
258
+ return Object.fromEntries(entries.filter(([k, v]) => usable(k, v))) as Partial<VideoOverlayLayer>
259
+ }
260
+
261
+ function sameShallow(a: Readonly<Record<string, unknown>>, b: unknown): boolean {
262
+ if (!b || typeof b !== "object") return false
263
+ const ka = Object.keys(a)
264
+ const kb = Object.keys(b)
265
+ return ka.length === kb.length && ka.every((k) => a[k] === (b as Record<string, unknown>)[k])
266
+ }
267
+
268
+ /**
269
+ * The ONE normaliser (spec §3.3), called at every write boundary.
270
+ * - `preset` + no box field → the preset's box is copied in, the tag stays.
271
+ * - `preset` + explicit box fields → explicit fields win, the preset fills
272
+ * what is absent; the tag is cleared only when an explicit field DIFFERS
273
+ * from the preset's box (a field the preset does not set — `height` on a
274
+ * corner badge — differs when present).
275
+ * - no `preset` + no box field → the corner badge of the layer's own `corner`
276
+ * (bottom-right when none), the D2 default filling tag / corner / trailing
277
+ * fields, the layer's own fields winning (D8): `{}` → the full default.
278
+ * - no `preset` + a PARTIAL box (not anchor AND width) → returned untouched;
279
+ * the validator answers `incomplete_box`.
280
+ * Trailing defaults x 0, y 0, fit contain, opacity 1, animate true are filled
281
+ * here and nowhere else. `null` reads as absent. Returned BY REFERENCE when
282
+ * nothing changes; idempotent, tag included.
283
+ */
284
+ export function expandVideoOverlayLayer(raw: VideoOverlayLayerInput | null | undefined): VideoOverlayLayer {
285
+ const obj = asLayer(raw)
286
+ const layer = obj ? clean(obj) : {}
287
+ const present = VIDEO_OVERLAY_BOX_FIELDS.filter((f) => layer[f] !== undefined)
288
+ let out: Record<string, unknown>
289
+ if (isVideoOverlayPresetId(layer.preset)) {
290
+ const box = videoOverlayPresetBox(layer.preset, layer.corner)
291
+ out = { ...box, ...layer }
292
+ if (present.some((f) => layer[f] !== box[f])) delete out.preset
293
+ } else if (present.length === 0) {
294
+ out = { ...DEFAULT_VIDEO_OVERLAY_LAYER, ...videoOverlayPresetBox("corner-badge", layer.corner), ...layer }
295
+ } else if (layer.anchor === undefined || layer.width === undefined) {
296
+ return raw as VideoOverlayLayer
297
+ } else {
298
+ out = { ...layer }
299
+ }
300
+ for (const [k, v] of Object.entries(TRAILING_DEFAULTS)) if (out[k] === undefined) out[k] = v
301
+ return sameShallow(out, raw) ? (raw as VideoOverlayLayer) : (out as unknown as VideoOverlayLayer)
302
+ }
303
+
304
+ /** `expandVideoOverlayLayer` over an array; the SAME array when no layer changed. */
305
+ export function expandVideoOverlayPresets(layers: ReadonlyArray<VideoOverlayLayerInput | null | undefined>): VideoOverlayLayer[] {
306
+ let changed = false
307
+ const out = layers.map((l) => {
308
+ const e = expandVideoOverlayLayer(l)
309
+ if (e !== l) changed = true
310
+ return e
311
+ })
312
+ return changed ? out : (layers as VideoOverlayLayer[])
313
+ }
314
+
315
+ /** The panel's preset click: REPLACE the six box fields with the preset's (dropping any it does not set), set the tag (+ corner), expand. */
316
+ export function applyVideoOverlayPreset(
317
+ layer: VideoOverlayLayerInput | null | undefined,
318
+ preset: VideoOverlayPresetId,
319
+ corner?: VideoOverlayCorner,
320
+ ): VideoOverlayLayer {
321
+ const base: Record<string, unknown> = { ...expandVideoOverlayLayer(layer) }
322
+ for (const f of VIDEO_OVERLAY_BOX_FIELDS) delete base[f]
323
+ delete base.preset
324
+ const nextCorner = preset === "corner-badge" ? (corner ?? (isVideoOverlayCorner(base.corner) ? base.corner : "bottom-right")) : undefined
325
+ return expandVideoOverlayLayer({
326
+ ...base,
327
+ ...videoOverlayPresetBox(preset, nextCorner),
328
+ preset,
329
+ ...(nextCorner ? { corner: nextCorner } : {}),
330
+ } as VideoOverlayLayerInput)
331
+ }
332
+
333
+ /** The panel's "Custom": the tag is cleared, the box kept. */
334
+ export function toCustomVideoOverlayLayer(layer: VideoOverlayLayerInput | null | undefined): VideoOverlayLayer {
335
+ const { preset: _tag, ...rest } = expandVideoOverlayLayer(layer)
336
+ return rest as VideoOverlayLayer
337
+ }
338
+
339
+ /**
340
+ * D10 — delete `imageUrl` from every layer whose canvas handle is wired
341
+ * (`wiredSlots`: 1-based). Pure over `layers` (the route has no edges, so this
342
+ * is a sibling of the expansion, not part of it). Slots 13 and up have no handle
343
+ * and are never touched. The input array by reference when nothing changes.
344
+ */
345
+ export function clearWiredVideoOverlayImageUrls<T extends VideoOverlayLayerInput | null | undefined>(
346
+ layers: readonly T[],
347
+ wiredSlots: ReadonlySet<number>,
348
+ ): readonly T[] {
349
+ let out: T[] | null = null
350
+ for (const slot of wiredSlots) {
351
+ if (!Number.isInteger(slot) || slot < 1 || slot > VIDEO_OVERLAY_HANDLE_IDS.length) continue
352
+ const layer = layers[slot - 1]
353
+ if (!asLayer(layer) || !("imageUrl" in (layer as object))) continue
354
+ out ??= [...layers]
355
+ const { imageUrl: _cleared, ...rest } = layer as VideoOverlayLayerInput
356
+ out[slot - 1] = rest as T
357
+ }
358
+ return out ?? layers
359
+ }
360
+
361
+ interface NodeLike {
362
+ id?: unknown
363
+ type?: unknown
364
+ data?: unknown
365
+ }
366
+ interface EdgeLike {
367
+ target?: unknown
368
+ targetHandle?: unknown
369
+ }
370
+
371
+ /**
372
+ * The workflow-JSON write-boundary pass (spec §3.3, D10), beside
373
+ * `normalizeNodeModelParams`: for every `video-overlay` node, clear the
374
+ * `imageUrl` of each layer whose handle the WRITTEN edges wire, then expand
375
+ * presets. Runs on every write, so an edge-only write still clears. A `null`
376
+ * (or missing) slot STAYS `null`: "no settings" is stored as such, wired or
377
+ * not — the default corner badge is synthesised only where a wired slot is
378
+ * run or drawn (the two engine assemblies and the stage, D2), so the panel,
379
+ * the reorder and the tail trim see the same empty slot whichever writer
380
+ * stored it. (`{}` is not `null`: it is an explicit empty layer, and the
381
+ * expansion turns it into the full default — spec §3.3.) Nodes and the array
382
+ * are returned by reference when nothing changes.
383
+ */
384
+ export function normalizeVideoOverlayNodes<T extends NodeLike>(nodes: readonly T[], edges: ReadonlyArray<EdgeLike> | null | undefined): T[] {
385
+ let changed = false
386
+ const out = nodes.map((node) => {
387
+ if (node.type !== "video-overlay") return node
388
+ const data = node.data
389
+ if (!data || typeof data !== "object" || Array.isArray(data)) return node
390
+ const d = data as Record<string, unknown>
391
+ if (!Array.isArray(d.layers)) return node
392
+ const layers = d.layers as Array<VideoOverlayLayerInput | null>
393
+ const wired = new Set<number>()
394
+ for (const e of edges ?? []) {
395
+ if (e.target !== node.id) continue
396
+ const slot = videoOverlaySlotOfHandle(typeof e.targetHandle === "string" ? e.targetHandle : undefined)
397
+ if (slot > 0) wired.add(slot)
398
+ }
399
+ const cleared = clearWiredVideoOverlayImageUrls(layers, wired)
400
+ let layersChanged = cleared !== layers
401
+ const next = cleared.map((l) => {
402
+ // null / missing stays as stored; a non-object entry is left untouched so
403
+ // the validator sees it (it reads as an absent layer everywhere).
404
+ if (!asLayer(l)) return l
405
+ const e = expandVideoOverlayLayer(l)
406
+ if (e !== l) layersChanged = true
407
+ return e
408
+ })
409
+ if (!layersChanged) return node
410
+ changed = true
411
+ return { ...node, data: { ...d, layers: next } }
412
+ })
413
+ return changed ? out : (nodes as T[])
414
+ }
415
+
416
+ /** What `videoOverlayRenderOrder` reads from a layer. */
417
+ export interface VideoOverlayOrderable {
418
+ readonly zIndex?: number | null
419
+ /** 1-based canvas slot (stamped by both engine assemblies). */
420
+ readonly slot?: number | null
421
+ /** 0-based index in the request's `layers[]` (the worker's graph layers carry it). */
422
+ readonly index?: number | null
423
+ }
424
+
425
+ const isSlot = (v: unknown): v is number => typeof v === "number" && Number.isInteger(v) && v >= 1
426
+ const isIndex = (v: unknown): v is number => typeof v === "number" && Number.isInteger(v) && v >= 0
427
+
428
+ /**
429
+ * Render order, bottom to top, as positions in `layers`: an explicit zIndex
430
+ * wins; a layer without one sits at its OWN position — `slot − 1` when it has
431
+ * a slot, else its request `index`, else its position in the array given —
432
+ * never at its position in whatever (compacted) array it happens to be passed
433
+ * in. The stage passes every slot; the worker passes the request minus empty
434
+ * slots and minus layers skipped for starting after the end — both must agree
435
+ * (spec: the preview and the run use the same numbers). Ties keep array order
436
+ * (layer 1 lowest).
437
+ */
438
+ export function videoOverlayRenderOrder(layers: ReadonlyArray<VideoOverlayOrderable | null | undefined>): number[] {
439
+ return layers
440
+ .map((l, i) => {
441
+ const own = isSlot(l?.slot) ? l.slot - 1 : isIndex(l?.index) ? l.index : i
442
+ return { i, z: typeof l?.zIndex === "number" && Number.isFinite(l.zIndex) ? l.zIndex : own }
443
+ })
444
+ .sort((a, b) => a.z - b.z || a.i - b.i)
445
+ .map((e) => e.i)
446
+ }
447
+
448
+ export interface VideoOverlayRect {
449
+ readonly left: number
450
+ readonly top: number
451
+ readonly width: number
452
+ readonly height: number
453
+ }
454
+ export interface VideoOverlayCanvas {
455
+ readonly w: number
456
+ readonly h: number
457
+ }
458
+ export interface VideoOverlayGeometry {
459
+ readonly box: VideoOverlayRect
460
+ readonly drawn: VideoOverlayRect
461
+ }
462
+
463
+ const evenFloor = (n: number): number => Math.max(2, Math.floor(n / 2) * 2)
464
+
465
+ /**
466
+ * The output canvas: `ASPECT_RATIO_DIMENSIONS[outputAspect]` when a target
467
+ * aspect is set; otherwise the base's DISPLAY size (rotation already applied by
468
+ * the caller's probe) with the sample aspect ratio resolved and both sides
469
+ * rounded down to even — `trunc(iw*sar/2)*2 × trunc(ih/2)*2`, what the ffmpeg
470
+ * base chain produces. null when neither is known.
471
+ */
472
+ export function videoOverlayCanvas(
473
+ display: { readonly width: number; readonly height: number; readonly sar?: number } | null | undefined,
474
+ outputAspect?: string | null,
475
+ ): VideoOverlayCanvas | null {
476
+ if (isVideoOverlayOutputAspect(outputAspect)) {
477
+ const d = ASPECT_RATIO_DIMENSIONS[outputAspect]!
478
+ return { w: d.width, h: d.height }
479
+ }
480
+ if (!display || !(display.width > 0) || !(display.height > 0)) return null
481
+ const sar = typeof display.sar === "number" && Number.isFinite(display.sar) && display.sar > 0 ? display.sar : 1
482
+ return { w: evenFloor(display.width * sar), h: evenFloor(display.height) }
483
+ }
484
+
485
+ function anchorFactors(anchor: OverlayAnchor): readonly [number, number] {
486
+ const h = anchor.endsWith("left") ? 0 : anchor.endsWith("right") ? 1 : 0.5
487
+ const v = anchor.startsWith("top") ? 0 : anchor.startsWith("bottom") ? 1 : 0.5
488
+ return [h, v]
489
+ }
490
+
491
+ /**
492
+ * Spec §3.4. `width%` → px of the canvas width; height follows the image's
493
+ * aspect unless `height%` is set; nine anchors; x/y are additive offsets in %
494
+ * of the canvas. An aspect-following box taller than the canvas is fitted
495
+ * inside it (both sides scaled, aspect kept) — so `drawn` never exceeds the
496
+ * canvas, which is what bounds the sharp pre-fit at one canvas of RGBA. `fit`:
497
+ * contain → the image scaled inside the box, centred; cover → the box itself.
498
+ * The only post-processing is rounding `drawn` DOWN to even, floored at 2 × 2.
499
+ * Differences from Image Overlay's copies (on purpose): no 8192 edge shrink,
500
+ * the canvas clamp, the even rounding.
501
+ */
502
+ export function resolveVideoOverlayGeometry(
503
+ canvas: VideoOverlayCanvas,
504
+ layer: Pick<VideoOverlayLayer, "anchor" | "x" | "y" | "width" | "height" | "fit">,
505
+ imageAspect: number,
506
+ ): VideoOverlayGeometry {
507
+ const aspect = Number.isFinite(imageAspect) && imageAspect > 0 ? imageAspect : 1
508
+ let bw = Math.max(1, Math.round((layer.width / 100) * canvas.w))
509
+ let bh = layer.height !== undefined ? Math.max(1, Math.round((layer.height / 100) * canvas.h)) : Math.max(1, Math.round(bw / aspect))
510
+ if (layer.height === undefined && bh > canvas.h) {
511
+ bw = Math.max(1, Math.round(bw * (canvas.h / bh)))
512
+ bh = canvas.h
513
+ }
514
+ const [ax, ay] = anchorFactors(layer.anchor)
515
+ const left = Math.round(ax * (canvas.w - bw) + (layer.x / 100) * canvas.w)
516
+ const top = Math.round(ay * (canvas.h - bh) + (layer.y / 100) * canvas.h)
517
+ let dw: number
518
+ let dh: number
519
+ if (layer.fit === "cover") {
520
+ dw = bw
521
+ dh = bh
522
+ } else if (bw / bh > aspect) {
523
+ dh = bh
524
+ dw = bh * aspect
525
+ } else {
526
+ dw = bw
527
+ dh = bw / aspect
528
+ }
529
+ const width = evenFloor(dw)
530
+ const height = evenFloor(dh)
531
+ return {
532
+ box: { left, top, width: bw, height: bh },
533
+ drawn: { left: left + Math.floor((bw - width) / 2), top: top + Math.floor((bh - height) / 2), width, height },
534
+ }
535
+ }
536
+
537
+ export interface VideoOverlayIssue {
538
+ readonly ok: false
539
+ readonly code: VideoOverlayErrorCode
540
+ /** 0-based index into the validated `layers[]`. */
541
+ readonly layer?: number
542
+ readonly slot?: number
543
+ readonly params: Readonly<Record<string, string | number>>
544
+ }
545
+ export type VideoOverlayValidation = { readonly ok: true } | VideoOverlayIssue
546
+
547
+ export interface VideoOverlayValidationInput {
548
+ readonly layers?: ReadonlyArray<VideoOverlayLayerInput | null | undefined> | null
549
+ readonly outputAspect?: string | null
550
+ readonly baseFit?: string | null
551
+ readonly backgroundColor?: string | null
552
+ }
553
+
554
+ const inTimeRange = (v: unknown): v is number =>
555
+ typeof v === "number" && Number.isFinite(v) && v >= 0 && v <= VIDEO_OVERLAY_MAX_TIME_SEC
556
+
557
+ type BoundedField = keyof typeof VIDEO_OVERLAY_BOUNDS
558
+ const BOUNDED_FIELDS = Object.keys(VIDEO_OVERLAY_BOUNDS) as BoundedField[]
559
+
560
+ /**
561
+ * The first present (non-null) box / look field outside its domain, with the
562
+ * domain as a symbolic `allowed` string; null when every present field is in
563
+ * range. Only PRESENT fields are checked, so the verdict is the same before and
564
+ * after the expansion (it fills in-range defaults only). This is what keeps a
565
+ * JSON-written layer (MCP `update_workflow_json`, copilot, templates) from
566
+ * reaching the geometry with an unbounded box — `drawn ≤ canvas` (§3.4) holds
567
+ * only for boxes inside these bounds.
568
+ */
569
+ function outOfBounds(l: VideoOverlayLayerInput): { field: string; allowed: string } | null {
570
+ for (const f of BOUNDED_FIELDS) {
571
+ const v = l[f]
572
+ if (v === undefined || v === null) continue
573
+ const [min, max] = VIDEO_OVERLAY_BOUNDS[f]
574
+ const integer = f === "zIndex"
575
+ if (typeof v !== "number" || !Number.isFinite(v) || v < min || v > max || (integer && !Number.isInteger(v))) {
576
+ return { field: f, allowed: `${integer ? "integers " : ""}${min}..${max}` }
577
+ }
578
+ }
579
+ if (l.anchor != null && !(OVERLAY_ANCHORS as readonly unknown[]).includes(l.anchor)) {
580
+ return { field: "anchor", allowed: OVERLAY_ANCHORS.join(", ") }
581
+ }
582
+ if (l.fit != null && !isFit(l.fit)) return { field: "fit", allowed: VIDEO_OVERLAY_FITS.join(", ") }
583
+ if (l.animate != null && typeof l.animate !== "boolean") return { field: "animate", allowed: "true, false" }
584
+ return null
585
+ }
586
+
587
+ /**
588
+ * Can `resolveVideoOverlayGeometry` place this layer? True when its box is what
589
+ * the validator's box checks require: `anchor` one of the nine anchors, `width`
590
+ * a finite number, and every present box / look field in range
591
+ * (`VIDEO_OVERLAY_BOUNDS`, enums). A partial box saved from workflow JSON
592
+ * (`{ imageUrl, start, width }`, `anchor: null`) is stored as written — the
593
+ * expansion leaves it for the validator's `incomplete_box` — so any reader
594
+ * that DRAWS a stored layer (the canvas stage) must skip one that fails this;
595
+ * the geometry would throw on it.
596
+ */
597
+ export function hasDrawableVideoOverlayBox(layer: unknown): boolean {
598
+ const l = asLayer(layer)
599
+ if (!l) return false
600
+ if (!(OVERLAY_ANCHORS as readonly unknown[]).includes(l.anchor)) return false
601
+ if (typeof l.width !== "number" || !Number.isFinite(l.width)) return false
602
+ return outOfBounds(l) === null
603
+ }
604
+
605
+ /**
606
+ * Spec §3.5 — what a schema cannot express, and what the DAG path skips. Run
607
+ * at every engine entry: the route (`.superRefine`, before the expansion), the
608
+ * canvas single-node run, payload-builder and the worker (after it) — the
609
+ * verdict is the same on either side of the expansion (no code reads an
610
+ * expanded field). Also refuses a PRESENT box / look field outside
611
+ * `VIDEO_OVERLAY_BOUNDS` or its enum / type (`field_out_of_bounds`, spec §4.4's
612
+ * "out-of-bounds box/opacity/zIndex" row — on the DAG path this validator is
613
+ * the only check). A non-object `layers[]` entry reads as an empty layer. The
614
+ * FIRST failure, as a code.
615
+ */
616
+ export function validateVideoOverlayRequest(body: VideoOverlayValidationInput): VideoOverlayValidation {
617
+ const layers = Array.isArray(body.layers) ? body.layers : []
618
+ if (layers.length === 0) return { ok: false, code: "no_layers", params: {} }
619
+ if (layers.length > VIDEO_OVERLAY_MAX_LAYERS) {
620
+ return { ok: false, code: "too_many_layers", params: { max: VIDEO_OVERLAY_MAX_LAYERS, count: layers.length } }
621
+ }
622
+ for (let i = 0; i < layers.length; i++) {
623
+ const l: VideoOverlayLayerInput = asLayer(layers[i]) ?? {}
624
+ const at = (code: VideoOverlayErrorCode, params: Record<string, string | number> = {}): VideoOverlayIssue => ({
625
+ ok: false,
626
+ code,
627
+ layer: i,
628
+ ...(typeof l.slot === "number" ? { slot: l.slot } : {}),
629
+ params,
630
+ })
631
+ const boxFields = VIDEO_OVERLAY_BOX_FIELDS.filter((f) => l[f] !== undefined && l[f] !== null)
632
+ if (!isVideoOverlayPresetId(l.preset) && boxFields.length > 0 && (l.anchor == null || l.width == null)) return at("incomplete_box")
633
+ const bad = outOfBounds(l)
634
+ if (bad) return at("field_out_of_bounds", bad)
635
+ if (typeof l.imageUrl !== "string" || l.imageUrl.length === 0) return at("layer_without_image")
636
+ if (!inTimeRange(l.start)) return at("time_out_of_range", { field: "start", max: VIDEO_OVERLAY_MAX_TIME_SEC })
637
+ if (l.end !== undefined && l.end !== null) {
638
+ if (!inTimeRange(l.end)) return at("time_out_of_range", { field: "end", max: VIDEO_OVERLAY_MAX_TIME_SEC })
639
+ if (!(l.end > l.start)) return at("end_before_start", { start: l.start, end: l.end })
640
+ }
641
+ }
642
+ if (!isVideoOverlayOutputAspect(body.outputAspect) && (body.baseFit != null || body.backgroundColor != null)) {
643
+ return { ok: false, code: "fit_without_aspect", params: {} }
644
+ }
645
+ return { ok: true }
646
+ }
647
+
648
+ const MESSAGES: { readonly [C in VideoOverlayErrorCode]: (p: Readonly<Record<string, string | number>>) => string } = {
649
+ no_layers: () => "At least 1 layer is required",
650
+ too_many_layers: (p) => `At most ${p.max} layers (got ${p.count})`,
651
+ incomplete_box: () => "anchor and width are required without a preset",
652
+ layer_without_image: () => "no image — connect one to the layer's handle or set imageUrl",
653
+ time_out_of_range: (p) => `${p.field} must be between 0 and ${p.max} s`,
654
+ end_before_start: (p) => `end (${p.end} s) must be after start (${p.start} s)`,
655
+ fit_without_aspect: () => "baseFit and backgroundColor need an outputAspect",
656
+ field_out_of_bounds: (p) => `${p.field} is out of range (allowed: ${p.allowed})`,
657
+ }
658
+
659
+ /** `Layer <slot>` when the layer came from the canvas, else `layers[<index>]`; "" for a request-wide failure. */
660
+ export function videoOverlayLayerLabel(ref: { readonly layer?: number; readonly slot?: number }): string {
661
+ return typeof ref.slot === "number" ? `Layer ${ref.slot}` : typeof ref.layer === "number" ? `layers[${ref.layer}]` : ""
662
+ }
663
+
664
+ /** The ONE English rendering of a verdict — the route's 400 text and the worker's error message. */
665
+ export function formatVideoOverlayError(issue: VideoOverlayIssue): string {
666
+ const label = videoOverlayLayerLabel(issue)
667
+ const text = MESSAGES[issue.code](issue.params)
668
+ return label ? `${label}: ${text}` : text
669
+ }
670
+
671
+ /** The node-data fields the assembly reads. */
672
+ export interface VideoOverlayNodeFields {
673
+ readonly layers?: ReadonlyArray<VideoOverlayLayerInput | null | undefined> | null
674
+ readonly outputAspect?: string | null
675
+ readonly baseFit?: string | null
676
+ readonly backgroundColor?: string | null
677
+ }
678
+
679
+ /**
680
+ * The ONE node → request assembly, called by the canvas single-node run AND
681
+ * payload-builder (so both engines refuse identically). Per slot i over
682
+ * max(wired handles (≤ 12), stored layers) — every stored slot, so more than
683
+ * 20 layers that render reach the validator's `too_many_layers` instead of
684
+ * being dropped: the image is the wired handle's,
685
+ * else the layer's own `imageUrl`; a slot with neither is dropped; a slot with
686
+ * no settings is the default layer (D2 — a `null` / missing / `{}` entry);
687
+ * `slot = i + 1` is stamped; the layer is expanded. `baseFit` /
688
+ * `backgroundColor` ride as stored, WITH or WITHOUT an `outputAspect`: the
689
+ * validator — not this assembly — refuses them without one
690
+ * (`fit_without_aspect`, spec §3.5), so a JSON-written node carrying a fit
691
+ * and no aspect is refused on both engines instead of silently rendering.
692
+ * (The canvas never builds that request: its two aspect writers clear both
693
+ * fields with the aspect.) A value the route's schema would reject — an
694
+ * unknown aspect or fit, a colour that is not `#rrggbb` — is dropped here.
695
+ */
696
+ export function assembleVideoOverlayRequest(input: {
697
+ readonly videoUrl: string
698
+ readonly data: VideoOverlayNodeFields
699
+ readonly wiredImageUrls: ReadonlyArray<string | null | undefined>
700
+ }): ExpandedVideoOverlayRequest {
701
+ const stored = Array.isArray(input.data.layers) ? input.data.layers : []
702
+ const wired = input.wiredImageUrls.slice(0, VIDEO_OVERLAY_HANDLE_IDS.length)
703
+ // Every stored slot, never capped at VIDEO_OVERLAY_MAX_LAYERS: a JSON-written
704
+ // node with 21+ layers must reach the validator's `too_many_layers` on both
705
+ // engines (spec §3.5) — capping here would silently drop layers 21+. Only the
706
+ // wired list is capped (12 handles).
707
+ const slots = Math.max(wired.length, stored.length)
708
+ const layers: VideoOverlayLayer[] = []
709
+ for (let i = 0; i < slots; i++) {
710
+ const raw = asLayer(stored[i])
711
+ const wiredUrl = wired[i]
712
+ const own = raw?.imageUrl
713
+ const image = typeof wiredUrl === "string" && wiredUrl.length > 0 ? wiredUrl : typeof own === "string" && own.length > 0 ? own : undefined
714
+ if (!image) continue
715
+ layers.push(expandVideoOverlayLayer({ ...(raw ?? {}), imageUrl: image, slot: i + 1 }))
716
+ }
717
+ const outputAspect = isVideoOverlayOutputAspect(input.data.outputAspect) ? input.data.outputAspect : undefined
718
+ const baseFit = isFit(input.data.baseFit) ? input.data.baseFit : undefined
719
+ const backgroundColor =
720
+ typeof input.data.backgroundColor === "string" && HEX6.test(input.data.backgroundColor) ? input.data.backgroundColor : undefined
721
+ return {
722
+ videoUrl: input.videoUrl,
723
+ layers,
724
+ ...(outputAspect ? { outputAspect } : {}),
725
+ ...(baseFit ? { baseFit } : {}),
726
+ ...(backgroundColor ? { backgroundColor } : {}),
727
+ }
728
+ }
729
+
730
+ /**
731
+ * Slot i's image: the wired handle's, else the layer's own `imageUrl` — the
732
+ * one source rule the stage, the panel, the canvas executor and the DAG
733
+ * payload share (the render's merge is the same `wired ?? imageUrl`, spec
734
+ * §3.5). Every stored slot, past 20 too: the limit is the layer COUNT, not
735
+ * the slot (layers 1–4 removed from a 24-layer node leave 20 at slots 5–24).
736
+ * Only the wired list is capped, at the 12 handles.
737
+ */
738
+ export function videoOverlaySlotSources(
739
+ layers: ReadonlyArray<VideoOverlayLayerInput | null | undefined> | null | undefined,
740
+ wiredImageUrls: ReadonlyArray<string | null | undefined>,
741
+ ): Array<string | undefined> {
742
+ const stored = Array.isArray(layers) ? layers : []
743
+ const wired = wiredImageUrls.slice(0, VIDEO_OVERLAY_HANDLE_IDS.length)
744
+ return Array.from({ length: Math.max(stored.length, wired.length) }, (_, i) => {
745
+ const w = wired[i]
746
+ if (typeof w === "string" && w.length > 0) return w
747
+ const own = asLayer(stored[i])?.imageUrl
748
+ return typeof own === "string" && own.length > 0 ? own : undefined
749
+ })
750
+ }
751
+
752
+ /** Everything that decides what a Video Overlay run paints. */
753
+ export interface VideoOverlayComposition {
754
+ /** The resolved base video URL. */
755
+ readonly baseUrl?: string | null
756
+ /** The image each slot draws, by slot index (`videoOverlaySlotSources`). */
757
+ readonly sources: ReadonlyArray<string | null | undefined>
758
+ /** The node's stored settings — never the expanded request. */
759
+ readonly data: VideoOverlayNodeFields
760
+ }
761
+
762
+ /**
763
+ * A value with every object's keys sorted and every null / undefined object
764
+ * field dropped (the assembly reads both as absent); array positions are kept.
765
+ * A jsonb round-trip re-orders object keys, so a node read back from the DB
766
+ * (a schedule / webhook / app run) and the same node in the editor (insertion
767
+ * order) must give one key.
768
+ */
769
+ function canonicalValue(v: unknown): unknown {
770
+ if (Array.isArray(v)) return v.map((x) => (x === undefined ? null : canonicalValue(x)))
771
+ if (v !== null && typeof v === "object") {
772
+ const out: Record<string, unknown> = {}
773
+ for (const k of Object.keys(v).sort()) {
774
+ const x = (v as Record<string, unknown>)[k]
775
+ if (x !== undefined && x !== null) out[k] = canonicalValue(x)
776
+ }
777
+ return out
778
+ }
779
+ return v
780
+ }
781
+
782
+ /**
783
+ * The longest `resultCompositionKey` the REST route accepts. The canvas sends
784
+ * its key with a single-node Run so the job's output_data carries it (a result
785
+ * that lands after a page reload reads fresh); the route stores it opaquely and
786
+ * reads it for nothing else. A full 20-layer composition with long signed URLs
787
+ * fits well inside; a canvas key past it is not sent.
788
+ */
789
+ export const VIDEO_OVERLAY_MAX_COMPOSITION_KEY_LENGTH = 65_536
790
+
791
+ /**
792
+ * The composition key a run stamps on its result (`resultCompositionKey`): the
793
+ * base, every slot's image, the layers, the output aspect, the fit and the pad
794
+ * colour. A later change to any of them marks the result "Result (old)".
795
+ * Canonical — independent of object key order, of absent vs undefined vs null
796
+ * fields, and of trailing empty slots — so the canvas (the node and its
797
+ * single-node Run) and the DAG payload give one key for one composition.
798
+ */
799
+ export function videoOverlayCompositionKey(c: VideoOverlayComposition): string {
800
+ const sources = c.sources.map((s) => (typeof s === "string" && s.length > 0 ? s : null))
801
+ while (sources.length > 0 && sources[sources.length - 1] === null) sources.pop()
802
+ return JSON.stringify(
803
+ canonicalValue([
804
+ c.baseUrl || null,
805
+ sources,
806
+ Array.isArray(c.data.layers) ? c.data.layers : [],
807
+ c.data.outputAspect ?? null,
808
+ c.data.baseFit ?? null,
809
+ c.data.backgroundColor ?? null,
810
+ ]),
811
+ )
812
+ }