@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.
- package/dist/index.cjs +442 -6
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +423 -3
- package/dist/index.d.ts +423 -3
- package/dist/index.js +400 -7
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/producer-types.test.ts +5 -0
- package/src/__tests__/video-overlay-docs.test.ts +39 -0
- package/src/__tests__/video-overlay.test.ts +572 -0
- package/src/catalog-projection.ts +20 -0
- package/src/i18n/action-fx.ja.ts +17 -17
- package/src/i18n/aesthetic.ja.ts +26 -20
- package/src/i18n/animals.ja.ts +49 -49
- package/src/i18n/atmosphere.ja.ts +19 -19
- package/src/i18n/backdrop.ja.ts +13 -13
- package/src/i18n/camera-format.ja.ts +3 -3
- package/src/i18n/camera-motions.ja.ts +15 -15
- package/src/i18n/character-fx.ja.ts +12 -12
- package/src/i18n/character-motion.ja.ts +72 -72
- package/src/i18n/color-look.ja.ts +19 -19
- package/src/i18n/composition-effects.ja.ts +6 -6
- package/src/i18n/era.ja.ts +11 -11
- package/src/i18n/exposure-settings.ja.ts +10 -10
- package/src/i18n/framing.ja.ts +11 -11
- package/src/i18n/furniture.ja.ts +22 -22
- package/src/i18n/held-prop.ja.ts +7 -7
- package/src/i18n/instrumentation.ja.ts +41 -41
- package/src/i18n/lens.ja.ts +5 -5
- package/src/i18n/lighting.ja.ts +21 -21
- package/src/i18n/materials.ja.ts +4 -4
- package/src/i18n/mood.ja.ts +18 -18
- package/src/i18n/music-genre.ja.ts +16 -16
- package/src/i18n/music-mood.ja.ts +18 -18
- package/src/i18n/person.ar.ts +1 -0
- package/src/i18n/person.de.ts +1 -0
- package/src/i18n/person.es.ts +1 -0
- package/src/i18n/person.fr.ts +1 -0
- package/src/i18n/person.he.ts +1 -0
- package/src/i18n/person.hi.ts +1 -0
- package/src/i18n/person.ja.ts +179 -178
- package/src/i18n/person.ko.ts +1 -0
- package/src/i18n/person.pt-BR.ts +1 -0
- package/src/i18n/person.ru.ts +1 -0
- package/src/i18n/person.zh-CN.ts +1 -0
- package/src/i18n/photo-genre.ja.ts +11 -11
- package/src/i18n/photographer.ja.ts +17 -17
- package/src/i18n/pose.ja.ts +14 -14
- package/src/i18n/post-process-effects.ja.ts +5 -5
- package/src/i18n/render-quality.ja.ts +9 -9
- package/src/i18n/setting.ar.ts +2 -0
- package/src/i18n/setting.de.ts +2 -0
- package/src/i18n/setting.es.ts +2 -0
- package/src/i18n/setting.fr.ts +2 -0
- package/src/i18n/setting.he.ts +2 -0
- package/src/i18n/setting.hi.ts +2 -0
- package/src/i18n/setting.ja.ts +16 -14
- package/src/i18n/setting.ko.ts +2 -0
- package/src/i18n/setting.pt-BR.ts +2 -0
- package/src/i18n/setting.ru.ts +2 -0
- package/src/i18n/setting.zh-CN.ts +2 -0
- package/src/i18n/style.ja.ts +10 -10
- package/src/i18n/styling.ar.ts +2 -0
- package/src/i18n/styling.de.ts +2 -0
- package/src/i18n/styling.es.ts +2 -0
- package/src/i18n/styling.fr.ts +2 -0
- package/src/i18n/styling.he.ts +2 -0
- package/src/i18n/styling.hi.ts +2 -0
- package/src/i18n/styling.ja.ts +58 -56
- package/src/i18n/styling.ko.ts +2 -0
- package/src/i18n/styling.pt-BR.ts +2 -0
- package/src/i18n/styling.ru.ts +2 -0
- package/src/i18n/styling.zh-CN.ts +2 -0
- package/src/i18n/temporal.ja.ts +5 -5
- package/src/i18n/transitions.ja.ts +12 -12
- package/src/i18n/vehicles.ja.ts +29 -29
- package/src/i18n/voice-character.ja.ts +27 -27
- package/src/i18n/voice-delivery.ja.ts +28 -28
- package/src/i18n/weapons.ja.ts +19 -19
- package/src/index.ts +2 -0
- package/src/producer-types.ts +3 -0
- package/src/trigger-node-types.ts +3 -0
- 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
|
+
}
|