@nodaro/shared 3.10.0 → 3.11.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.
@@ -282,8 +282,23 @@ export const entitySlotSchema = z.object({
282
282
  refRejectedReason: z.string().optional(),
283
283
  /** NON-default looks only; present only when at least one exists. */
284
284
  variations: z.array(slotVariationSchema).max(VIDEO_ANALYSIS_MAX_VARIATIONS).optional(),
285
+ /**
286
+ * WHOSE THIS OBJECT IS (2026-09-17). An object slot that is worn, held,
287
+ * carried, driven or ridden by a cast person or creature names that slot
288
+ * here, with the relation as a short passive phrase ending in "by" ("worn
289
+ * by", "held by", "driven by"). The link says the object slot IS the one on
290
+ * that person — not a second one: a recast that saw "Man in Blue Silk
291
+ * Shirt" AND "Blue Silk Shirt" as unrelated slots rendered two shirts.
292
+ * Object slots only; a free-standing prop, the product on a table, a place
293
+ * or a person never carries it. Optional/additive: producers may omit it.
294
+ */
295
+ owner: z.object({
296
+ slotId: z.string().min(1).regex(/^[a-z0-9-]+$/),
297
+ relation: z.string().min(1),
298
+ }).optional(),
285
299
  })
286
300
  export type EntitySlot = z.infer<typeof entitySlotSchema>
301
+ export type EntitySlotOwner = NonNullable<EntitySlot["owner"]>
287
302
 
288
303
  /**
289
304
  * The sound LAYER vocabulary — what kind of thing this layer is.
@@ -0,0 +1,228 @@
1
+ /**
2
+ * START/END FRAME FIT — the pure geometry half.
3
+ *
4
+ * WHY THIS EXISTS (measured on ~40 production renders, 2026-09-16):
5
+ * a start frame whose pixel size is not the model's own output canvas gets
6
+ * reshaped by the provider, and Seedance 2.5 does it VISIBLY — frame 0 is the
7
+ * user's image verbatim, then from frame 1 the generated frames are rescaled
8
+ * ~2% on ONE axis (x1.000 y1.02). Five of ten runs with a 940x1672 image
9
+ * snapped; three of three with the same image resized to 720x1280 did not.
10
+ *
11
+ * So: reshape the frame ourselves, to the size the model was going to render
12
+ * anyway. The canvas comes from measurement (`video-output-canvas.ts`), never
13
+ * from arithmetic, and when a combination has never been measured the fit
14
+ * DEGRADES rather than guesses.
15
+ *
16
+ * This module is pure — no I/O, no sharp, no network. `prepareVideoFrames` in
17
+ * the backend does the pixels and the upload.
18
+ */
19
+ import { getModel } from "./model-catalog.js"
20
+ import { resolveOutputCanvas, type VideoOutputCanvas } from "./video-output-canvas.js"
21
+
22
+ /** How much of the frame we are allowed to reshape. */
23
+ export const FRAME_FITS = ["original", "ratio", "resolution"] as const
24
+ export type FrameFit = (typeof FRAME_FITS)[number]
25
+
26
+ /** `resolution` — the measured canvas — is the default everywhere. */
27
+ export const DEFAULT_FRAME_FIT: FrameFit = "resolution"
28
+
29
+ /** How the frame is handed to the model. */
30
+ export const FRAME_DELIVERIES = ["auto", "frame", "reference"] as const
31
+ export type FrameDelivery = (typeof FRAME_DELIVERIES)[number]
32
+
33
+ export const DEFAULT_FRAME_DELIVERY: FrameDelivery = "auto"
34
+
35
+ /**
36
+ * Stretching an image to a ratio it is nowhere near would squash the subject,
37
+ * so past this gap the frame is centre-cropped to the target ratio FIRST and
38
+ * only then resized. 5% covers every "1K image into a 720p canvas" case we
39
+ * measured (940x1672 into 9:16 is a 0.05% gap) while refusing to squash a
40
+ * square photo into 9:16 (a 78% gap).
41
+ */
42
+ export const FRAME_FIT_STRETCH_TOLERANCE = 0.05
43
+
44
+ /**
45
+ * Models whose frame mode is measurably worse than reference delivery.
46
+ *
47
+ * The Seedance 2.0 family crop-zooms the frame 2% and drifts 11-26% darker
48
+ * within six frames in frame mode; delivered as a reference with the opening-
49
+ * frame sentence, the same models hold a flat look from frame 0 and keep the
50
+ * image's true geometry. Every OTHER model measured (Seedance 2.5, Gemini Omni
51
+ * video + flash, Veo 3.1, Wan 3.0, Minimax H3) reproduces the opening frame
52
+ * better in frame mode, so the default stays `frame` for anything absent here.
53
+ */
54
+ export const FRAME_DELIVERY_BY_PROVIDER: Readonly<Record<string, Exclude<FrameDelivery, "auto">>> = {
55
+ "seedance-2": "reference",
56
+ "seedance-2-fast": "reference",
57
+ "seedance-2-mini": "reference",
58
+ }
59
+
60
+ /** Aspect tokens that name no concrete shape — the model picks. */
61
+ const OPEN_ASPECT_TOKENS = new Set(["adaptive", "auto"])
62
+
63
+ /** `"16:9"` → 1.777…; anything unparseable → `undefined`. */
64
+ export function parseAspectToken(token: string | undefined): number | undefined {
65
+ if (!token) return undefined
66
+ const m = /^(\d+(?:\.\d+)?)\s*[:x/]\s*(\d+(?:\.\d+)?)$/.exec(token.trim())
67
+ if (!m) return undefined
68
+ const w = Number(m[1]); const h = Number(m[2])
69
+ if (!(w > 0) || !(h > 0)) return undefined
70
+ return w / h
71
+ }
72
+
73
+ /**
74
+ * The aspect the fit should target.
75
+ *
76
+ * An explicit ratio is used as-is. `adaptive` / `Auto` (and an absent value,
77
+ * which the seedance family sends as adaptive whenever a frame is present) has
78
+ * no shape of its own: the provider will follow the IMAGE, so we snap the
79
+ * image's own ratio to the nearest one the model lists and target that. A model
80
+ * with no declared ratio list and an open token gives `undefined` — no fit.
81
+ */
82
+ export function resolveFrameFitAspect(args: {
83
+ provider: string | undefined
84
+ requestedAspect: string | undefined
85
+ sourceWidth: number
86
+ sourceHeight: number
87
+ }): string | undefined {
88
+ const requested = args.requestedAspect?.trim()
89
+ if (requested && !OPEN_ASPECT_TOKENS.has(requested.toLowerCase())) return requested
90
+ const ratios = args.provider ? getModel(args.provider)?.aspectRatios : undefined
91
+ if (!ratios?.length || !(args.sourceWidth > 0) || !(args.sourceHeight > 0)) return undefined
92
+ const source = args.sourceWidth / args.sourceHeight
93
+ let best: { token: string; gap: number } | undefined
94
+ for (const token of ratios) {
95
+ const value = parseAspectToken(token)
96
+ if (value === undefined) continue // skips "adaptive"/"Auto" members
97
+ const gap = Math.abs(Math.log(value / source))
98
+ if (!best || gap < best.gap) best = { token, gap }
99
+ }
100
+ return best?.token
101
+ }
102
+
103
+ /** What `prepareVideoFrames` should do to one frame. `null` = nothing. */
104
+ export interface FrameFitPlan {
105
+ /** Final pixel size to produce. */
106
+ readonly width: number
107
+ readonly height: number
108
+ /** Centre-crop applied BEFORE the resize (only past the stretch tolerance). */
109
+ readonly crop?: { readonly left: number; readonly top: number; readonly width: number; readonly height: number }
110
+ /** Why the plan exists — carried into logs and job output for traceability. */
111
+ readonly reason: "resolution" | "ratio"
112
+ }
113
+
114
+ /** Round to an even number ≥ 2 — odd dimensions break yuv420p encoders. */
115
+ function even(value: number): number {
116
+ return Math.max(2, Math.round(value / 2) * 2)
117
+ }
118
+
119
+ /**
120
+ * The smallest change that makes `width x height` exactly `aspect`: keep the
121
+ * longer side, move the shorter one. Rounding to even can leave a sub-pixel
122
+ * residue, which is why the caller compares ratios with a tolerance rather than
123
+ * for equality.
124
+ */
125
+ export function minimalRatioDimensions(width: number, height: number, aspect: number): { width: number; height: number } {
126
+ const current = width / height
127
+ if (current > aspect) {
128
+ // too wide → bring the height up (keep the long side, the width)
129
+ return { width: even(width), height: even(width / aspect) }
130
+ }
131
+ return { width: even(height * aspect), height: even(height) }
132
+ }
133
+
134
+ /**
135
+ * The centre-crop that turns `width x height` into exactly `aspect`, dropping
136
+ * the overhang on the long axis.
137
+ */
138
+ export function centreCropToAspect(width: number, height: number, aspect: number): { left: number; top: number; width: number; height: number } {
139
+ const current = width / height
140
+ if (current > aspect) {
141
+ const w = even(height * aspect)
142
+ return { left: Math.max(0, Math.round((width - w) / 2)), top: 0, width: Math.min(width, w), height }
143
+ }
144
+ const h = even(width / aspect)
145
+ return { left: 0, top: Math.max(0, Math.round((height - h) / 2)), width, height: Math.min(height, h) }
146
+ }
147
+
148
+ /**
149
+ * Turn a request into a concrete plan for ONE frame, or `null` when the frame
150
+ * should be sent untouched.
151
+ *
152
+ * Degradation ladder — a missing measurement must never invent geometry:
153
+ * `resolution` with no measured canvas → behaves as `ratio`
154
+ * `ratio` with no resolvable aspect → no fit
155
+ * any fit whose target equals the source (within a pixel) → no fit
156
+ */
157
+ export function computeFrameFitPlan(args: {
158
+ fit: FrameFit
159
+ provider: string | undefined
160
+ resolution: string | undefined
161
+ /** The aspect as the request carries it: a ratio, `adaptive`/`Auto`, or absent. */
162
+ aspect: string | undefined
163
+ sourceWidth: number
164
+ sourceHeight: number
165
+ /** Overrides the measured table (tests, and a caller that already looked up). */
166
+ canvas?: VideoOutputCanvas
167
+ tolerance?: number
168
+ }): FrameFitPlan | null {
169
+ const { fit, sourceWidth, sourceHeight } = args
170
+ if (fit === "original") return null
171
+ if (!(sourceWidth > 0) || !(sourceHeight > 0)) return null
172
+
173
+ const aspectToken = resolveFrameFitAspect({
174
+ provider: args.provider,
175
+ requestedAspect: args.aspect,
176
+ sourceWidth,
177
+ sourceHeight,
178
+ })
179
+
180
+ const canvas = fit === "resolution"
181
+ ? args.canvas ?? resolveOutputCanvas(args.provider, args.resolution, aspectToken)
182
+ : undefined
183
+
184
+ const targetAspect = canvas ? canvas[0] / canvas[1] : parseAspectToken(aspectToken)
185
+ if (targetAspect === undefined || !(targetAspect > 0)) return null
186
+
187
+ const sourceAspect = sourceWidth / sourceHeight
188
+ const gap = Math.abs(sourceAspect - targetAspect) / targetAspect
189
+ const tolerance = args.tolerance ?? FRAME_FIT_STRETCH_TOLERANCE
190
+ const crop = gap > tolerance ? centreCropToAspect(sourceWidth, sourceHeight, targetAspect) : undefined
191
+
192
+ const target = canvas
193
+ ? { width: canvas[0], height: canvas[1] }
194
+ : minimalRatioDimensions(
195
+ crop ? crop.width : sourceWidth,
196
+ crop ? crop.height : sourceHeight,
197
+ targetAspect,
198
+ )
199
+
200
+ const unchanged = target.width === sourceWidth && target.height === sourceHeight && !crop
201
+ if (unchanged) return null
202
+
203
+ return {
204
+ width: target.width,
205
+ height: target.height,
206
+ ...(crop ? { crop } : {}),
207
+ reason: canvas ? "resolution" : "ratio",
208
+ }
209
+ }
210
+
211
+ /**
212
+ * Frame or reference? `auto` reads the per-provider default measured above.
213
+ * Reference delivery is only possible on models that accept reference images —
214
+ * on the others `reference` collapses back to `frame` rather than dropping the
215
+ * frame on the floor.
216
+ */
217
+ export function resolveFrameDelivery(args: {
218
+ provider: string | undefined
219
+ requested: FrameDelivery | undefined
220
+ supportsReferenceImages: boolean
221
+ }): Exclude<FrameDelivery, "auto"> {
222
+ const requested = args.requested ?? DEFAULT_FRAME_DELIVERY
223
+ const wanted = requested === "auto"
224
+ ? (args.provider ? FRAME_DELIVERY_BY_PROVIDER[args.provider] ?? "frame" : "frame")
225
+ : requested
226
+ if (wanted === "reference" && !args.supportsReferenceImages) return "frame"
227
+ return wanted
228
+ }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Real output canvases per (video model, resolution, aspect ratio).
3
+ *
4
+ * Every entry is a pixel size read off a finished render with ffprobe — the
5
+ * geometry a model hands back, never a rate or a price. None of it is
6
+ * arithmetic either, because the providers do not follow arithmetic:
7
+ *
8
+ * - minimax-h3 at 768P returns 768x1344 (0.5714) for a 9:16 request,
9
+ * - seedance-2 at 480p 16:9 returns 864x496 (1.742) while seedance-2-5
10
+ * returns 854x480 (1.778) for the same request,
11
+ * - grok returns 736x400 (1.84), seedance 1.0 at 1080p 1:1 returns 1440x1440.
12
+ *
13
+ * A formula would get all four wrong, which is why `resolveOutputCanvas`
14
+ * answers `undefined` for anything not measured: callers must then leave the
15
+ * frame alone rather than invent geometry (see `computeFrameFitPlan`).
16
+ *
17
+ * HOW TO EXTEND: run `node tools/harvest-output-canvas.mjs` (it pages the admin
18
+ * jobs API, groups completed video jobs by provider/resolution/aspect and probes
19
+ * real outputs) and paste new rows here. Take rows from REFERENCE or
20
+ * TEXT-TO-VIDEO jobs only: in frame mode the adaptive models (the seedance and
21
+ * wan families) size the output from the INPUT image, so those rows describe the
22
+ * input that was sent, not the canvas the model would choose on its own.
23
+ *
24
+ * Seeded 2026-09-16 from 3000 completed production jobs.
25
+ */
26
+
27
+ /** `[width, height]` in pixels. */
28
+ export type VideoOutputCanvas = readonly [number, number]
29
+
30
+ /** provider → resolution (lower-cased) → aspect token → canvas. */
31
+ export const VIDEO_OUTPUT_CANVAS: Readonly<
32
+ Record<string, Readonly<Record<string, Readonly<Record<string, VideoOutputCanvas>>>>>
33
+ > = {
34
+ "seedance-2-5": {
35
+ "480p": { "16:9": [854, 480], "9:16": [480, 854] },
36
+ "720p": { "16:9": [1280, 720], "9:16": [720, 1280], "1:1": [960, 960] },
37
+ "1080p": { "16:9": [1920, 1080] },
38
+ },
39
+ // The 2.0 family is NOT the same as 2.5 at 480p — 864x496 vs 854x480.
40
+ "seedance-2": {
41
+ "480p": { "16:9": [864, 496], "9:16": [496, 864] },
42
+ "720p": { "16:9": [1280, 720], "9:16": [720, 1280] },
43
+ },
44
+ "seedance-2-fast": {
45
+ "480p": { "16:9": [864, 496], "9:16": [496, 864] },
46
+ "720p": { "16:9": [1280, 720], "9:16": [720, 1280] },
47
+ },
48
+ "seedance-2-mini": {
49
+ "480p": { "16:9": [864, 496], "9:16": [496, 864] },
50
+ "720p": { "16:9": [1280, 720], "9:16": [720, 1280] },
51
+ },
52
+ "gemini-omni-video": {
53
+ "720p": { "16:9": [1280, 720], "9:16": [720, 1280] },
54
+ "1080p": { "16:9": [1920, 1080] },
55
+ },
56
+ "gemini-omni-flash": {
57
+ "720p": { "16:9": [1280, 720], "9:16": [720, 1280] },
58
+ },
59
+ "veo3.1": {
60
+ "720p": { "9:16": [720, 1280] },
61
+ "1080p": { "16:9": [1920, 1080] },
62
+ },
63
+ "wan-3": {
64
+ "480p": { "16:9": [832, 480] },
65
+ "720p": { "16:9": [1280, 720], "9:16": [720, 1280] },
66
+ },
67
+ "wan-3-prime": {
68
+ "720p": { "16:9": [1280, 720] },
69
+ },
70
+ // 768P's "9:16" is 0.5714, not 0.5625 — the single clearest reason this table
71
+ // exists.
72
+ "minimax-h3": {
73
+ "768p": { "9:16": [768, 1344] },
74
+ "2k": { "16:9": [2560, 1440], "9:16": [1440, 2560] },
75
+ },
76
+ seedance: {
77
+ "1080p": {
78
+ "16:9": [1920, 1080],
79
+ "9:16": [1080, 1920],
80
+ "1:1": [1440, 1440],
81
+ "3:4": [1248, 1664],
82
+ },
83
+ },
84
+ }
85
+
86
+ /**
87
+ * The canvas a model renders for this request, or `undefined` when we have never
88
+ * measured that combination. `undefined` means "leave the frame alone".
89
+ *
90
+ * Resolution matching is case-insensitive (`768P`, `2K`, `720p` all appear in
91
+ * the wild). An aspect of `adaptive` / `Auto` has no canvas of its own — the
92
+ * caller resolves it to a concrete ratio first (`resolveFrameFitAspect`).
93
+ */
94
+ export function resolveOutputCanvas(
95
+ provider: string | undefined,
96
+ resolution: string | undefined,
97
+ aspect: string | undefined,
98
+ ): VideoOutputCanvas | undefined {
99
+ if (!provider || !resolution || !aspect) return undefined
100
+ return VIDEO_OUTPUT_CANVAS[provider]?.[resolution.toLowerCase()]?.[aspect]
101
+ }
102
+
103
+ /** Every measured combination, for tests and for the harvest script's diff. */
104
+ export function measuredCanvasCombinations(): Array<{
105
+ provider: string
106
+ resolution: string
107
+ aspect: string
108
+ canvas: VideoOutputCanvas
109
+ }> {
110
+ const out: Array<{ provider: string; resolution: string; aspect: string; canvas: VideoOutputCanvas }> = []
111
+ for (const [provider, byResolution] of Object.entries(VIDEO_OUTPUT_CANVAS)) {
112
+ for (const [resolution, byAspect] of Object.entries(byResolution)) {
113
+ for (const [aspect, canvas] of Object.entries(byAspect)) {
114
+ out.push({ provider, resolution, aspect, canvas })
115
+ }
116
+ }
117
+ }
118
+ return out
119
+ }