@nodaro/shared 1.21.0 → 1.22.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 +219 -49
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +319 -15
- package/dist/index.d.ts +319 -15
- package/dist/index.js +210 -50
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/llm-models.test.ts +138 -0
- package/src/__tests__/video-analysis-catalog-sync.test.ts +63 -0
- package/src/__tests__/video-analysis-pricing.test.ts +5 -4
- package/src/__tests__/video-analysis.test.ts +133 -1
- package/src/index.ts +3 -0
- package/src/llm-models.ts +141 -11
- package/src/model-catalog.ts +20 -20
- package/src/video-analysis-pricing.ts +21 -18
- package/src/video-analysis.ts +191 -1
package/src/model-catalog.ts
CHANGED
|
@@ -1815,11 +1815,11 @@ const VIDEO_MODELS: Record<string, ModelCatalogEntry> = {
|
|
|
1815
1815
|
description: "Legacy fast-tier analysis model (pre-2026-07). Kept so stored raw-model configs keep running and keep pricing under their own identifier; new fast-tier runs use the current fast model.",
|
|
1816
1816
|
useCases: ["video-analysis", "shot-list", "fast"],
|
|
1817
1817
|
pricing: [
|
|
1818
|
-
{ identifier: "video-analysis:gemini-3-flash", credits:
|
|
1819
|
-
{ identifier: "video-analysis:gemini-3-flash:60s", credits:
|
|
1820
|
-
{ identifier: "video-analysis:gemini-3-flash:180s", credits:
|
|
1821
|
-
{ identifier: "video-analysis:gemini-3-flash:360s", credits:
|
|
1822
|
-
{ identifier: "video-analysis:gemini-3-flash:600s", credits:
|
|
1818
|
+
{ identifier: "video-analysis:gemini-3-flash", credits: 30, note: "10-min ceiling (no duration given)" },
|
|
1819
|
+
{ identifier: "video-analysis:gemini-3-flash:60s", credits: 6 },
|
|
1820
|
+
{ identifier: "video-analysis:gemini-3-flash:180s", credits: 7 },
|
|
1821
|
+
{ identifier: "video-analysis:gemini-3-flash:360s", credits: 18 },
|
|
1822
|
+
{ identifier: "video-analysis:gemini-3-flash:600s", credits: 30, note: "10-min ceiling" },
|
|
1823
1823
|
],
|
|
1824
1824
|
},
|
|
1825
1825
|
"gemini-3.6-flash-video-analysis": {
|
|
@@ -1832,11 +1832,11 @@ const VIDEO_MODELS: Record<string, ModelCatalogEntry> = {
|
|
|
1832
1832
|
description: "Analyze a video into a structured shot list (scenes, camera, audio) — fast, economy tier. Billed per duration bucket.",
|
|
1833
1833
|
useCases: ["video-analysis", "shot-list", "fast"],
|
|
1834
1834
|
pricing: [
|
|
1835
|
-
{ identifier: "video-analysis:gemini-3.6-flash", credits:
|
|
1836
|
-
{ identifier: "video-analysis:gemini-3.6-flash:60s", credits:
|
|
1837
|
-
{ identifier: "video-analysis:gemini-3.6-flash:180s", credits:
|
|
1838
|
-
{ identifier: "video-analysis:gemini-3.6-flash:360s", credits:
|
|
1839
|
-
{ identifier: "video-analysis:gemini-3.6-flash:600s", credits:
|
|
1835
|
+
{ identifier: "video-analysis:gemini-3.6-flash", credits: 81, note: "10-min ceiling (no duration given)" },
|
|
1836
|
+
{ identifier: "video-analysis:gemini-3.6-flash:60s", credits: 14 },
|
|
1837
|
+
{ identifier: "video-analysis:gemini-3.6-flash:180s", credits: 19 },
|
|
1838
|
+
{ identifier: "video-analysis:gemini-3.6-flash:360s", credits: 49 },
|
|
1839
|
+
{ identifier: "video-analysis:gemini-3.6-flash:600s", credits: 81, note: "10-min ceiling" },
|
|
1840
1840
|
],
|
|
1841
1841
|
},
|
|
1842
1842
|
"gemini-3.1-pro-video-analysis": {
|
|
@@ -1849,11 +1849,11 @@ const VIDEO_MODELS: Record<string, ModelCatalogEntry> = {
|
|
|
1849
1849
|
description: "Analyze a video into a structured shot list (scenes, camera, audio) — higher-fidelity, default tier. Billed per duration bucket.",
|
|
1850
1850
|
useCases: ["video-analysis", "shot-list", "cinematic"],
|
|
1851
1851
|
pricing: [
|
|
1852
|
-
{ identifier: "video-analysis:gemini-3.1-pro", credits:
|
|
1853
|
-
{ identifier: "video-analysis:gemini-3.1-pro:60s", credits:
|
|
1854
|
-
{ identifier: "video-analysis:gemini-3.1-pro:180s", credits:
|
|
1855
|
-
{ identifier: "video-analysis:gemini-3.1-pro:360s", credits:
|
|
1856
|
-
{ identifier: "video-analysis:gemini-3.1-pro:600s", credits:
|
|
1852
|
+
{ identifier: "video-analysis:gemini-3.1-pro", credits: 120, note: "10-min ceiling (no duration given)" },
|
|
1853
|
+
{ identifier: "video-analysis:gemini-3.1-pro:60s", credits: 21 },
|
|
1854
|
+
{ identifier: "video-analysis:gemini-3.1-pro:180s", credits: 27 },
|
|
1855
|
+
{ identifier: "video-analysis:gemini-3.1-pro:360s", credits: 72 },
|
|
1856
|
+
{ identifier: "video-analysis:gemini-3.1-pro:600s", credits: 120, note: "10-min ceiling" },
|
|
1857
1857
|
],
|
|
1858
1858
|
},
|
|
1859
1859
|
// Both mixed tiers are variants of the same advanced multi-engine analysis
|
|
@@ -1869,11 +1869,11 @@ const VIDEO_MODELS: Record<string, ModelCatalogEntry> = {
|
|
|
1869
1869
|
description: "Our most advanced analysis tier — multiple analysis engines combined into one result for maximum completeness and accuracy. Billed per duration bucket.",
|
|
1870
1870
|
useCases: ["video-analysis", "shot-list", "premium", "most-complete"],
|
|
1871
1871
|
pricing: [
|
|
1872
|
-
{ identifier: "video-analysis:mixed", credits:
|
|
1873
|
-
{ identifier: "video-analysis:mixed:60s", credits:
|
|
1874
|
-
{ identifier: "video-analysis:mixed:180s", credits:
|
|
1875
|
-
{ identifier: "video-analysis:mixed:360s", credits:
|
|
1876
|
-
{ identifier: "video-analysis:mixed:600s", credits:
|
|
1872
|
+
{ identifier: "video-analysis:mixed", credits: 200, note: "10-min ceiling (no duration given)" },
|
|
1873
|
+
{ identifier: "video-analysis:mixed:60s", credits: 34 },
|
|
1874
|
+
{ identifier: "video-analysis:mixed:180s", credits: 46 },
|
|
1875
|
+
{ identifier: "video-analysis:mixed:360s", credits: 120 },
|
|
1876
|
+
{ identifier: "video-analysis:mixed:600s", credits: 200, note: "10-min ceiling" },
|
|
1877
1877
|
],
|
|
1878
1878
|
},
|
|
1879
1879
|
}
|
|
@@ -24,8 +24,11 @@
|
|
|
24
24
|
* `VIDEO_CLIP_CREDITS` uses in `film-pricing.ts`. It is what the frontend's
|
|
25
25
|
* client-side cost preview (`estimateNodeCredits` in
|
|
26
26
|
* workflow-editor/types.ts) reads instead of calling the formula directly.
|
|
27
|
-
*
|
|
28
|
-
*
|
|
27
|
+
* The formula's own test in `@nodaroai/cloud-plugins`
|
|
28
|
+
* (`src/plugins/video-analysis/__tests__/cost.test.ts`) cross-checks this table
|
|
29
|
+
* against it and fails on drift. There is deliberately NO app-side formula to
|
|
30
|
+
* check against — it was moved private in 2026-07 and the old backend test
|
|
31
|
+
* went with it.
|
|
29
32
|
*/
|
|
30
33
|
|
|
31
34
|
export const VIDEO_ANALYSIS_DURATION_BUCKETS = [60, 180, 360, 600] as const
|
|
@@ -47,27 +50,27 @@ export const VIDEO_ANALYSIS_WINDOW = { LEN: WINDOW_LEN, STRIDE: WINDOW_STRIDE, O
|
|
|
47
50
|
*/
|
|
48
51
|
export const VIDEO_ANALYSIS_BUCKET_CREDITS: Record<string, number> = {
|
|
49
52
|
// Legacy fast-tier model (pre-2026-07) — kept for stored raw-id configs.
|
|
50
|
-
"video-analysis:gemini-3-flash:60s":
|
|
51
|
-
"video-analysis:gemini-3-flash:180s":
|
|
52
|
-
"video-analysis:gemini-3-flash:360s":
|
|
53
|
-
"video-analysis:gemini-3-flash:600s":
|
|
53
|
+
"video-analysis:gemini-3-flash:60s": 6,
|
|
54
|
+
"video-analysis:gemini-3-flash:180s": 7,
|
|
55
|
+
"video-analysis:gemini-3-flash:360s": 18,
|
|
56
|
+
"video-analysis:gemini-3-flash:600s": 30,
|
|
54
57
|
// Current fast tier — regenerated from the private formula for its backing
|
|
55
58
|
// model; higher than the legacy fast schedule but still ≤ pro per bucket.
|
|
56
|
-
"video-analysis:gemini-3.6-flash:60s":
|
|
57
|
-
"video-analysis:gemini-3.6-flash:180s":
|
|
58
|
-
"video-analysis:gemini-3.6-flash:360s":
|
|
59
|
-
"video-analysis:gemini-3.6-flash:600s":
|
|
60
|
-
"video-analysis:gemini-3.1-pro:60s":
|
|
61
|
-
"video-analysis:gemini-3.1-pro:180s":
|
|
62
|
-
"video-analysis:gemini-3.1-pro:360s":
|
|
63
|
-
"video-analysis:gemini-3.1-pro:600s":
|
|
59
|
+
"video-analysis:gemini-3.6-flash:60s": 14,
|
|
60
|
+
"video-analysis:gemini-3.6-flash:180s": 19,
|
|
61
|
+
"video-analysis:gemini-3.6-flash:360s": 49,
|
|
62
|
+
"video-analysis:gemini-3.6-flash:600s": 81,
|
|
63
|
+
"video-analysis:gemini-3.1-pro:60s": 21,
|
|
64
|
+
"video-analysis:gemini-3.1-pro:180s": 27,
|
|
65
|
+
"video-analysis:gemini-3.1-pro:360s": 72,
|
|
66
|
+
"video-analysis:gemini-3.1-pro:600s": 120,
|
|
64
67
|
// Mixed tiers (`mixed` + `mixed-fast`) share ONE credit family — they are
|
|
65
68
|
// variants of the same engine plan (plan internals live in the private
|
|
66
69
|
// analysis plugin). Admin-tunable via model_pricing like every other row.
|
|
67
|
-
"video-analysis:mixed:60s":
|
|
68
|
-
"video-analysis:mixed:180s":
|
|
69
|
-
"video-analysis:mixed:360s":
|
|
70
|
-
"video-analysis:mixed:600s":
|
|
70
|
+
"video-analysis:mixed:60s": 34,
|
|
71
|
+
"video-analysis:mixed:180s": 46,
|
|
72
|
+
"video-analysis:mixed:360s": 120,
|
|
73
|
+
"video-analysis:mixed:600s": 200,
|
|
71
74
|
}
|
|
72
75
|
|
|
73
76
|
/**
|
package/src/video-analysis.ts
CHANGED
|
@@ -20,6 +20,148 @@
|
|
|
20
20
|
import { z } from "zod"
|
|
21
21
|
|
|
22
22
|
export const VIDEO_ANALYSIS_MAX_SCENE_SEC = 8
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Camera VIEWPOINT — where the camera is relative to the subject. The axis
|
|
26
|
+
* `shotType` does not carry, and the one the analyzer had been improvising.
|
|
27
|
+
*
|
|
28
|
+
* Two failures this fixes, both from real jobs:
|
|
29
|
+
*
|
|
30
|
+
* 1. `shotType` is framing SIZE (Wide … Extreme Close-Up), so a true angle had
|
|
31
|
+
* nowhere to go and landed in the MOVEMENT field instead:
|
|
32
|
+
* `"camera": "low angle static"`. That loses the angle to anything reading
|
|
33
|
+
* `camera` and pollutes the movement vocabulary.
|
|
34
|
+
* 2. The relational viewpoints — `Over-the-Shoulder`, `POV` — were conventions
|
|
35
|
+
* inside the `shotType` list, competing with the sizes for one slot. So an
|
|
36
|
+
* over-the-shoulder MEDIUM had to pick one and threw the other away. They
|
|
37
|
+
* belong here, leaving `shotType` free to state the size: an OTS medium is
|
|
38
|
+
* `shotType: "Medium"` + `angle: "over-the-shoulder"`, which is strictly more
|
|
39
|
+
* than either field could carry alone.
|
|
40
|
+
*
|
|
41
|
+
* A closed enum rather than free text precisely because improvisation is the
|
|
42
|
+
* failure being fixed. Absent means EYE-LEVEL — the overwhelming default, so
|
|
43
|
+
* omitting it costs nothing on most shots (the same "absence is the default"
|
|
44
|
+
* shape as `transitionOut` and appearance variations).
|
|
45
|
+
*
|
|
46
|
+
* `from-behind` and `over-the-shoulder` also carry real meaning downstream: a
|
|
47
|
+
* face is not visible in either, which is what auto-cast needs to know before
|
|
48
|
+
* choosing one as an identity reference.
|
|
49
|
+
*/
|
|
50
|
+
export const VIDEO_ANALYSIS_SHOT_ANGLES = [
|
|
51
|
+
// Vertical placement and roll — the classical "angles".
|
|
52
|
+
"eye-level", "low", "high", "overhead", "worms-eye", "dutch",
|
|
53
|
+
// Relational viewpoints — where the camera sits with respect to the subject.
|
|
54
|
+
"over-the-shoulder", "pov", "profile", "from-behind",
|
|
55
|
+
] as const
|
|
56
|
+
export type VideoAnalysisShotAngle = (typeof VIDEO_ANALYSIS_SHOT_ANGLES)[number]
|
|
57
|
+
|
|
58
|
+
/** Viewpoints in which the subject's FACE is not visible — so a frame shot this
|
|
59
|
+
* way is a poor identity reference however good its framing otherwise is. */
|
|
60
|
+
export const VIDEO_ANALYSIS_FACELESS_ANGLES: ReadonlySet<string> = new Set(["over-the-shoulder", "from-behind"])
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Effects applied to the PICTURE of a shot. An array — a shot can be grainy and
|
|
64
|
+
* vignetted at once — and absent when the image is clean, which is most shots.
|
|
65
|
+
*
|
|
66
|
+
* Scoped deliberately to things done to the IMAGE, and NOT to compositing that
|
|
67
|
+
* asserts what is in the shot (picture-in-picture, split screen). That line
|
|
68
|
+
* matters: a real job invented `{slot:creator} overlay talking to camera` across
|
|
69
|
+
* nine scenes for a man who is never seen, so a field for "there is an inset of a
|
|
70
|
+
* person here" would hand that fabrication a legitimate home. An effect is
|
|
71
|
+
* verifiable in the pixels; a claim about who is inset is not.
|
|
72
|
+
*
|
|
73
|
+
* `dissolve` and `fade` are NOT here either — they are edits BETWEEN shots and
|
|
74
|
+
* belong to `transitionOut`.
|
|
75
|
+
*/
|
|
76
|
+
export const VIDEO_ANALYSIS_VISUAL_EFFECTS = [
|
|
77
|
+
"blur", "pixelate", "glitch", "grain", "vignette", "flash", "distortion", "double-exposure",
|
|
78
|
+
] as const
|
|
79
|
+
export type VideoAnalysisVisualEffect = (typeof VIDEO_ANALYSIS_VISUAL_EFFECTS)[number]
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Visible edit INTO the next shot.
|
|
83
|
+
*
|
|
84
|
+
* `dissolve` (a cross-fade from one image to the other) is distinct from `fade`
|
|
85
|
+
* (through black or white). Collapsing both onto `fade` — as this enum did — makes
|
|
86
|
+
* a recreation render the wrong edit, and the two look nothing alike.
|
|
87
|
+
*/
|
|
88
|
+
export const VIDEO_ANALYSIS_TRANSITIONS = ["cut", "fade", "dissolve", "wipe", "whip"] as const
|
|
89
|
+
export type VideoAnalysisTransition = (typeof VIDEO_ANALYSIS_TRANSITIONS)[number]
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Time manipulation — slow motion, ramps, timelapse, freeze, reverse.
|
|
93
|
+
*
|
|
94
|
+
* Previously unrepresentable anywhere in the schema, so a recreation rendered
|
|
95
|
+
* every shot at normal speed no matter what the footage did. It is a first-class
|
|
96
|
+
* lever in every video model and a real editing decision in most action footage.
|
|
97
|
+
*
|
|
98
|
+
* `"normal"` is deliberately NOT a member: absence is normal speed, so there is
|
|
99
|
+
* exactly one way to say "nothing unusual here" and the field costs nothing on
|
|
100
|
+
* the majority of shots.
|
|
101
|
+
*/
|
|
102
|
+
export const VIDEO_ANALYSIS_SPEED_EFFECTS = ["slow-motion", "ramp-in", "ramp-out", "timelapse", "freeze", "reverse"] as const
|
|
103
|
+
export type VideoAnalysisSpeedEffect = (typeof VIDEO_ANALYSIS_SPEED_EFFECTS)[number]
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The CLIP-LEVEL look — one source of truth for the properties that belong to
|
|
107
|
+
* the whole piece rather than any one shot.
|
|
108
|
+
*
|
|
109
|
+
* Colour grade, camera format and lens character were previously only ever prose
|
|
110
|
+
* inside each scene's `visual`, which meant a 43-scene analysis re-decided the
|
|
111
|
+
* grade forty-three independent times with nothing holding them consistent. That
|
|
112
|
+
* is the same drift problem entity slots solve for people: state it once, apply it
|
|
113
|
+
* everywhere. A recreation reads this alongside every scene.
|
|
114
|
+
*
|
|
115
|
+
* Every field optional — an analyzer that cannot read the format should say
|
|
116
|
+
* nothing rather than guess, and a per-scene deviation still belongs in that
|
|
117
|
+
* scene's `visual` prose.
|
|
118
|
+
*/
|
|
119
|
+
export const clipLookSchema = z.object({
|
|
120
|
+
/**
|
|
121
|
+
* The rendering MEDIUM — "live-action photoreal", "2D anime", "stop-motion
|
|
122
|
+
* claymation", "3D render", "oil painting", "pixel art".
|
|
123
|
+
*
|
|
124
|
+
* The most consequential field in this object, and orthogonal to every other
|
|
125
|
+
* one: two shots with identical grade, lens, lighting and framing still look
|
|
126
|
+
* nothing alike when one is live action and the other is an oil painting. Get
|
|
127
|
+
* this wrong and a recreation renders the whole piece in the wrong medium,
|
|
128
|
+
* which no amount of correct grade or lighting can rescue.
|
|
129
|
+
*
|
|
130
|
+
* Mirrors the product's Style picker, whose catalog defines exactly this axis
|
|
131
|
+
* and states its independence from lighting, colour-look, atmosphere and lens.
|
|
132
|
+
* A clip that genuinely changes medium partway (live action with an animated
|
|
133
|
+
* insert) states the deviation in that scene's `visual`, as with `lighting`.
|
|
134
|
+
*/
|
|
135
|
+
style: z.string().optional(),
|
|
136
|
+
/** Colour grade / palette — "muted teal-and-orange, crushed blacks". */
|
|
137
|
+
grade: z.string().optional(),
|
|
138
|
+
/** Camera or film FORMAT and stock — "anamorphic digital", "16mm film grain". */
|
|
139
|
+
format: z.string().optional(),
|
|
140
|
+
/** Lens character — "wide-angle, shallow depth of field throughout". */
|
|
141
|
+
lens: z.string().optional(),
|
|
142
|
+
/** Overall lighting style — "hard single-source daylight, deep shadow". */
|
|
143
|
+
lighting: z.string().optional(),
|
|
144
|
+
/** What KIND of piece this is — "cinematic trailer", "talking-head vlog". */
|
|
145
|
+
genre: z.string().optional(),
|
|
146
|
+
/**
|
|
147
|
+
* The visual INFLUENCE the piece clearly evokes — a cinematographer, director,
|
|
148
|
+
* photographer or named aesthetic ("shot like Deakins", "Wes Anderson
|
|
149
|
+
* symmetry", "80s Kodachrome editorial").
|
|
150
|
+
*
|
|
151
|
+
* The highest-leverage field here by a distance: a couple of words transfer a
|
|
152
|
+
* whole aesthetic that would otherwise take a paragraph of grade, lens and
|
|
153
|
+
* lighting prose to approximate — which is exactly why the product already
|
|
154
|
+
* exposes it as a curated picker ("Photographer / Artist") whose catalog ships
|
|
155
|
+
* `in the style of …` prompt hints. The analyzer had no way to read it back.
|
|
156
|
+
*
|
|
157
|
+
* Deliberately conservative: OMIT unless the footage genuinely evokes a
|
|
158
|
+
* well-known, nameable style. A confident misattribution is worse than silence,
|
|
159
|
+
* because it drags an entire wrong aesthetic into every regenerated shot — so
|
|
160
|
+
* describing the look in `grade`/`lighting` always beats guessing a name.
|
|
161
|
+
*/
|
|
162
|
+
influence: z.string().optional(),
|
|
163
|
+
})
|
|
164
|
+
export type ClipLook = z.infer<typeof clipLookSchema>
|
|
23
165
|
export const VIDEO_ANALYSIS_ENTITY_SOURCES = ["wired-character", "wired-object", "wired-location", "wired-creature"] as const
|
|
24
166
|
export type VideoAnalysisEntitySource = (typeof VIDEO_ANALYSIS_ENTITY_SOURCES)[number]
|
|
25
167
|
/** Matches {slot:<id>} tokens. Distinct from NODE_REF_PATTERN / {image:N} grammars. */
|
|
@@ -108,8 +250,27 @@ const windowSceneBase = z.object({
|
|
|
108
250
|
label: z.string().min(1),
|
|
109
251
|
shotType: z.string().min(1),
|
|
110
252
|
camera: z.string(),
|
|
253
|
+
/** Camera VIEWPOINT. Absent ⇒ eye-level. Keeps `camera` to pure MOVEMENT and
|
|
254
|
+
* frees `shotType` to state the SIZE even on an over-the-shoulder or POV. */
|
|
255
|
+
angle: z.enum(VIDEO_ANALYSIS_SHOT_ANGLES).optional(),
|
|
256
|
+
/** Time manipulation. Absent ⇒ normal speed. */
|
|
257
|
+
speed: z.enum(VIDEO_ANALYSIS_SPEED_EFFECTS).optional(),
|
|
111
258
|
visual: z.string().min(1),
|
|
112
|
-
|
|
259
|
+
/**
|
|
260
|
+
* Text burned into the PICTURE of this shot — titles, captions, lower-thirds,
|
|
261
|
+
* subtitles — verbatim, in its original script. Absent when the frame carries
|
|
262
|
+
* none.
|
|
263
|
+
*
|
|
264
|
+
* Doctrine already asks for on-screen text inside `visual` prose, but a
|
|
265
|
+
* recreation needs to know discretely whether to RENDER text at all, and
|
|
266
|
+
* `translateOnScreenTextToEnglish` had no structured field to land in. It is
|
|
267
|
+
* also the signal the auto-cast frame judge reads: picture text belonging to an
|
|
268
|
+
* earlier scene's speech means the shot is replayed footage under a voice-over.
|
|
269
|
+
*/
|
|
270
|
+
onScreenText: z.string().optional(),
|
|
271
|
+
/** Effects on this shot's PICTURE. Absent ⇒ a clean image. */
|
|
272
|
+
effects: z.array(z.enum(VIDEO_ANALYSIS_VISUAL_EFFECTS)).optional(),
|
|
273
|
+
transitionOut: z.enum(VIDEO_ANALYSIS_TRANSITIONS).optional(),
|
|
113
274
|
// Array of concurrent layers (music + speech + sfx together); [] = silence.
|
|
114
275
|
audio: z.array(audioLayerSchema),
|
|
115
276
|
/** slotId → variationId for slots wearing a NON-default look in this scene
|
|
@@ -125,6 +286,9 @@ export type WindowScene = z.infer<typeof windowSceneSchema>
|
|
|
125
286
|
/** What the MODEL emits per window (strict-JSON footer schema). scenes has NO min. */
|
|
126
287
|
export const windowAnalysisSchema = z.object({
|
|
127
288
|
language: z.string().optional(),
|
|
289
|
+
/** The clip-level look as read from THIS window. Merge folds the windows
|
|
290
|
+
* field-by-field (first non-empty wins), like `language`. */
|
|
291
|
+
look: clipLookSchema.optional(),
|
|
128
292
|
slots: z.array(entitySlotSchema),
|
|
129
293
|
scenes: z.array(windowSceneSchema),
|
|
130
294
|
})
|
|
@@ -147,6 +311,10 @@ export const videoAnalysisResultSchema = z.object({
|
|
|
147
311
|
title: z.string().optional(),
|
|
148
312
|
language: z.string().optional(),
|
|
149
313
|
}),
|
|
314
|
+
/** Clip-level look, merged across windows. Deliberately a sibling of `meta`
|
|
315
|
+
* rather than a member: `meta` is probed fact (ffprobe dimensions, probed
|
|
316
|
+
* duration), while this is the model's reading of the photography. */
|
|
317
|
+
look: clipLookSchema.optional(),
|
|
150
318
|
slots: z.array(entitySlotSchema),
|
|
151
319
|
scenes: z.array(analyzedSceneSchema).min(1),
|
|
152
320
|
/** CAST VARIATIONS (§4 cap handling): looks the analyzer's merge FOLDED into
|
|
@@ -267,6 +435,28 @@ export function dropUnknownSpeakers(
|
|
|
267
435
|
return { audio: dropped.length > 0 ? out : audio, dropped }
|
|
268
436
|
}
|
|
269
437
|
|
|
438
|
+
/**
|
|
439
|
+
* Fold each window's reading of the clip look into one, FIELD BY FIELD: the first
|
|
440
|
+
* window that had something to say about a field wins it.
|
|
441
|
+
*
|
|
442
|
+
* Per-field rather than first-window-wins-everything because the windows see
|
|
443
|
+
* different footage — an opening window may read the grade confidently while only
|
|
444
|
+
* a later one contains the shot that reveals the format. Mirrors how `language` is
|
|
445
|
+
* resolved across windows rather than taken from window 0.
|
|
446
|
+
*
|
|
447
|
+
* Returns undefined when no window said anything, so an analysis with nothing to
|
|
448
|
+
* report omits the field rather than shipping an empty object.
|
|
449
|
+
*/
|
|
450
|
+
export function mergeClipLook(looks: ReadonlyArray<ClipLook | undefined>): ClipLook | undefined {
|
|
451
|
+
const out: Record<string, string> = {}
|
|
452
|
+
for (const look of looks) {
|
|
453
|
+
for (const [k, v] of Object.entries(look ?? {})) {
|
|
454
|
+
if (typeof v === "string" && v.trim() && !out[k]) out[k] = v.trim()
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
return Object.keys(out).length > 0 ? (out as ClipLook) : undefined
|
|
458
|
+
}
|
|
459
|
+
|
|
270
460
|
/** Substitute {slot:x}: castMap binding wins, else the slot's description, else literal id. */
|
|
271
461
|
export function renderAnalyzedScene(scene: { visual: string }, slots: EntitySlot[], castMap?: Record<string, string>): string {
|
|
272
462
|
const byId = new Map(slots.map((s) => [s.slotId, s]))
|