@nodaro/shared 1.19.0 → 1.21.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 +79 -38
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +32 -1
- package/dist/index.d.ts +32 -1
- package/dist/index.js +78 -39
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/video-analysis.test.ts +69 -1
- package/src/model-catalog.ts +21 -21
- package/src/video-analysis-pricing.ts +16 -16
- package/src/video-analysis.ts +65 -0
package/package.json
CHANGED
|
@@ -5,8 +5,9 @@ import {
|
|
|
5
5
|
renderAnalyzedScene, isOversizedScene, aspectRatioFromDims,
|
|
6
6
|
entitySlotSchema, analyzedSceneSchema,
|
|
7
7
|
rewriteSceneBindings, dropUnknownBindings,
|
|
8
|
+
rewriteSpeakerSlots, dropUnknownSpeakers,
|
|
8
9
|
VIDEO_ANALYSIS_MAX_VARIATIONS, VIDEO_ANALYSIS_VARIATION_SLUGS, VIDEO_ANALYSIS_DEFAULT_VARIATION,
|
|
9
|
-
type EntitySlot,
|
|
10
|
+
type EntitySlot, type AudioLayer,
|
|
10
11
|
} from "../video-analysis.js"
|
|
11
12
|
|
|
12
13
|
const slot: EntitySlot = { slotId: "hero", label: "Protagonist", source: "wired-character", role: "person", description: "tan man, mustache, black tee" }
|
|
@@ -148,6 +149,73 @@ describe("binding rewrite helpers (merge consumes — spec §4)", () => {
|
|
|
148
149
|
})
|
|
149
150
|
})
|
|
150
151
|
|
|
152
|
+
describe("speech attribution (speakerSlot)", () => {
|
|
153
|
+
const speech = (content: string, over: Partial<AudioLayer> = {}): AudioLayer => ({ mode: "speech", content, ...over })
|
|
154
|
+
|
|
155
|
+
it("rides on speech layers and survives a window round-trip", () => {
|
|
156
|
+
const parsed = windowAnalysisSchema.parse({
|
|
157
|
+
slots: [slot],
|
|
158
|
+
scenes: [{ ...baseScene, audio: [speech("As a kid…", { voice: "male, warm", speakerSlot: "hero" })] }],
|
|
159
|
+
})
|
|
160
|
+
expect(parsed.scenes[0]!.audio[0]!.speakerSlot).toBe("hero")
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it("is NOT refined against mode — a mis-tagged music layer must not fail the whole roll", () => {
|
|
164
|
+
// The window schema IS the enforced decode grammar. Rejecting here would
|
|
165
|
+
// throw away every scene in a window over one stray field; the sanitizer
|
|
166
|
+
// below strips it instead.
|
|
167
|
+
expect(windowAnalysisSchema.safeParse({
|
|
168
|
+
slots: [slot],
|
|
169
|
+
scenes: [{ ...baseScene, audio: [{ mode: "music", content: "synth bed", speakerSlot: "hero" }] }],
|
|
170
|
+
}).success).toBe(true)
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
it("rewriteSpeakerSlots follows a slot through cross-window unification", () => {
|
|
174
|
+
// Slot unification renames the loser id and rewrites {slot:…} tokens and
|
|
175
|
+
// variation bindings; attribution has to move with them or it dangles.
|
|
176
|
+
const audio = [speech("hi", { speakerSlot: "man-2" }), speech("ho", { speakerSlot: "other" })]
|
|
177
|
+
expect(rewriteSpeakerSlots(audio, { "man-2": "hero" }).map((a) => a.speakerSlot)).toEqual(["hero", "other"])
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
it("rewriteSpeakerSlots is copy-on-write when no layer names a renamed slot", () => {
|
|
181
|
+
const audio = [speech("hi", { speakerSlot: "hero" }), { mode: "music" as const, content: "bed" }]
|
|
182
|
+
expect(rewriteSpeakerSlots(audio, { ghost: "other" })).toBe(audio)
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
it("dropUnknownSpeakers strips attribution to a slot that no longer exists", () => {
|
|
186
|
+
const r = dropUnknownSpeakers([speech("hi", { speakerSlot: "ghost" })], new Set(["hero"]))
|
|
187
|
+
expect(r.audio[0]).not.toHaveProperty("speakerSlot")
|
|
188
|
+
expect(r.dropped).toEqual(["ghost"])
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
it("dropUnknownSpeakers strips attribution from music/sfx — nobody is speaking", () => {
|
|
192
|
+
const r = dropUnknownSpeakers(
|
|
193
|
+
[{ mode: "sfx", content: "door slam", speakerSlot: "hero" }],
|
|
194
|
+
new Set(["hero"]),
|
|
195
|
+
)
|
|
196
|
+
expect(r.audio[0]).not.toHaveProperty("speakerSlot")
|
|
197
|
+
expect(r.dropped).toEqual(["hero"])
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
it("dropUnknownSpeakers keeps a valid speaker and every other layer field", () => {
|
|
201
|
+
const audio = [speech("As a kid…", { voice: "male, warm", speakerSlot: "hero" })]
|
|
202
|
+
const r = dropUnknownSpeakers(audio, new Set(["hero"]))
|
|
203
|
+
expect(r.audio).toBe(audio) // copy-on-write: untouched input returned as-is
|
|
204
|
+
expect(r.dropped).toEqual([])
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
it("attribution alone must NOT keep a slot alive — that is the phantom narrator", () => {
|
|
208
|
+
// A slot referenced only as a speaker is a voice with no body (doctrine §5).
|
|
209
|
+
// deriveSlotRefs reads {slot:…} tokens from `visual` ONLY, so an
|
|
210
|
+
// attribution-only slot stays invisible to the reference sweep and gets
|
|
211
|
+
// dropped — then dropUnknownSpeakers removes the dangling attribution.
|
|
212
|
+
expect(deriveSlotRefs("a lunar plain, no one in frame")).toEqual([])
|
|
213
|
+
const r = dropUnknownSpeakers([speech("that's me!", { speakerSlot: "creator" })], new Set())
|
|
214
|
+
expect(r.audio[0]).not.toHaveProperty("speakerSlot")
|
|
215
|
+
expect(r.dropped).toEqual(["creator"])
|
|
216
|
+
})
|
|
217
|
+
})
|
|
218
|
+
|
|
151
219
|
describe("misc", () => {
|
|
152
220
|
it("isOversizedScene flags > 8s only", () => {
|
|
153
221
|
expect(isOversizedScene(0, 8)).toBe(false)
|
package/src/model-catalog.ts
CHANGED
|
@@ -1812,14 +1812,14 @@ const VIDEO_MODELS: Record<string, ModelCatalogEntry> = {
|
|
|
1812
1812
|
family: "Nodaro",
|
|
1813
1813
|
label: "Video Analysis (Fast — legacy)",
|
|
1814
1814
|
series: "Video Analysis",
|
|
1815
|
-
description: "Legacy fast-tier analysis model (pre-2026-07). Kept so stored raw-model configs keep running and pricing
|
|
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: 9, note: "10-min ceiling (no duration given)" },
|
|
1819
|
+
{ identifier: "video-analysis:gemini-3-flash:60s", credits: 2 },
|
|
1820
|
+
{ identifier: "video-analysis:gemini-3-flash:180s", credits: 3 },
|
|
1821
|
+
{ identifier: "video-analysis:gemini-3-flash:360s", credits: 6 },
|
|
1822
|
+
{ identifier: "video-analysis:gemini-3-flash:600s", credits: 9, 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: 25, note: "10-min ceiling (no duration given)" },
|
|
1836
|
+
{ identifier: "video-analysis:gemini-3.6-flash:60s", credits: 5 },
|
|
1837
|
+
{ identifier: "video-analysis:gemini-3.6-flash:180s", credits: 6 },
|
|
1838
|
+
{ identifier: "video-analysis:gemini-3.6-flash:360s", credits: 15 },
|
|
1839
|
+
{ identifier: "video-analysis:gemini-3.6-flash:600s", credits: 25, 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: 33, note: "10-min ceiling (no duration given)" },
|
|
1853
|
+
{ identifier: "video-analysis:gemini-3.1-pro:60s", credits: 6 },
|
|
1854
|
+
{ identifier: "video-analysis:gemini-3.1-pro:180s", credits: 8 },
|
|
1855
|
+
{ identifier: "video-analysis:gemini-3.1-pro:360s", credits: 20 },
|
|
1856
|
+
{ identifier: "video-analysis:gemini-3.1-pro:600s", credits: 33, 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: 57, note: "10-min ceiling (no duration given)" },
|
|
1873
|
+
{ identifier: "video-analysis:mixed:60s", credits: 10 },
|
|
1874
|
+
{ identifier: "video-analysis:mixed:180s", credits: 13 },
|
|
1875
|
+
{ identifier: "video-analysis:mixed:360s", credits: 35 },
|
|
1876
|
+
{ identifier: "video-analysis:mixed:600s", credits: 57, note: "10-min ceiling" },
|
|
1877
1877
|
],
|
|
1878
1878
|
},
|
|
1879
1879
|
}
|
|
@@ -47,27 +47,27 @@ export const VIDEO_ANALYSIS_WINDOW = { LEN: WINDOW_LEN, STRIDE: WINDOW_STRIDE, O
|
|
|
47
47
|
*/
|
|
48
48
|
export const VIDEO_ANALYSIS_BUCKET_CREDITS: Record<string, number> = {
|
|
49
49
|
// 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":
|
|
50
|
+
"video-analysis:gemini-3-flash:60s": 2,
|
|
51
|
+
"video-analysis:gemini-3-flash:180s": 3,
|
|
52
|
+
"video-analysis:gemini-3-flash:360s": 6,
|
|
53
|
+
"video-analysis:gemini-3-flash:600s": 9,
|
|
54
54
|
// Current fast tier — regenerated from the private formula for its backing
|
|
55
55
|
// 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":
|
|
56
|
+
"video-analysis:gemini-3.6-flash:60s": 5,
|
|
57
|
+
"video-analysis:gemini-3.6-flash:180s": 6,
|
|
58
|
+
"video-analysis:gemini-3.6-flash:360s": 15,
|
|
59
|
+
"video-analysis:gemini-3.6-flash:600s": 25,
|
|
60
|
+
"video-analysis:gemini-3.1-pro:60s": 6,
|
|
61
|
+
"video-analysis:gemini-3.1-pro:180s": 8,
|
|
62
|
+
"video-analysis:gemini-3.1-pro:360s": 20,
|
|
63
|
+
"video-analysis:gemini-3.1-pro:600s": 33,
|
|
64
64
|
// Mixed tiers (`mixed` + `mixed-fast`) share ONE credit family — they are
|
|
65
65
|
// variants of the same engine plan (plan internals live in the private
|
|
66
66
|
// 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":
|
|
67
|
+
"video-analysis:mixed:60s": 10,
|
|
68
|
+
"video-analysis:mixed:180s": 13,
|
|
69
|
+
"video-analysis:mixed:360s": 35,
|
|
70
|
+
"video-analysis:mixed:600s": 57,
|
|
71
71
|
}
|
|
72
72
|
|
|
73
73
|
/**
|
package/src/video-analysis.ts
CHANGED
|
@@ -78,6 +78,27 @@ const audioLayerSchema = z.object({
|
|
|
78
78
|
mode: z.enum(["speech", "music", "sfx"]),
|
|
79
79
|
content: z.string().min(1),
|
|
80
80
|
voice: z.string().optional(),
|
|
81
|
+
/**
|
|
82
|
+
* SPEECH ONLY — `slotId` of the on-screen speaker saying these words.
|
|
83
|
+
*
|
|
84
|
+
* `voice` casts a voice ("male, proud triumphant shouting"); this says WHO it
|
|
85
|
+
* belongs to, so a recreation can route the line to the right character
|
|
86
|
+
* instead of guessing. Usually one person speaks per scene and the guess is
|
|
87
|
+
* right, which is exactly why the cases with two speakers over one cut fail
|
|
88
|
+
* silently without this field.
|
|
89
|
+
*
|
|
90
|
+
* Optional by design and deliberately NOT refined against `mode` here: the
|
|
91
|
+
* window schema is the enforced decode grammar, and rejecting a whole roll
|
|
92
|
+
* because the model tagged a music layer would be a hair-trigger failure. A
|
|
93
|
+
* speaker on a non-speech layer, or one naming a slot that no longer exists,
|
|
94
|
+
* is stripped structurally by `dropUnknownSpeakers` — the same
|
|
95
|
+
* unwrap/drop/sweep philosophy the slot-token and binding channels use.
|
|
96
|
+
*
|
|
97
|
+
* An unseen narrator gets NO speaker: a voice with no body is never a slot
|
|
98
|
+
* (doctrine §5), so attribution here would resurrect the phantom-entity
|
|
99
|
+
* defect that `stripOrphanSlots` exists to kill.
|
|
100
|
+
*/
|
|
101
|
+
speakerSlot: z.string().optional(),
|
|
81
102
|
})
|
|
82
103
|
export type AudioLayer = z.infer<typeof audioLayerSchema>
|
|
83
104
|
|
|
@@ -202,6 +223,50 @@ export function dropUnknownBindings(
|
|
|
202
223
|
return { kept: Object.keys(kept).length > 0 ? kept : undefined, dropped }
|
|
203
224
|
}
|
|
204
225
|
|
|
226
|
+
/**
|
|
227
|
+
* Rewrite speech attribution after cross-window slot unification — the
|
|
228
|
+
* `rewriteSceneBindings` counterpart for the `audio` channel. Slot unification
|
|
229
|
+
* renames a loser id to its survivor and rewrites `{slot:…}` tokens and
|
|
230
|
+
* variation bindings; an un-rewritten `speakerSlot` would be left pointing at an
|
|
231
|
+
* id that no longer exists. Copy-on-write: returns the input array untouched
|
|
232
|
+
* when no layer names a renamed slot.
|
|
233
|
+
*/
|
|
234
|
+
export function rewriteSpeakerSlots(audio: AudioLayer[], slotRenames: Record<string, string>): AudioLayer[] {
|
|
235
|
+
if (!audio.some((a) => a.speakerSlot !== undefined && slotRenames[a.speakerSlot])) return audio
|
|
236
|
+
return audio.map((a) => {
|
|
237
|
+
const to = a.speakerSlot !== undefined ? slotRenames[a.speakerSlot] : undefined
|
|
238
|
+
return to ? { ...a, speakerSlot: to } : a
|
|
239
|
+
})
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Strip attribution that no scene can honour — the `dropUnknownBindings` mirror
|
|
244
|
+
* for the `audio` channel. Two cases, both model sloppiness rather than errors
|
|
245
|
+
* worth failing a roll over:
|
|
246
|
+
* - a `speakerSlot` on a `music`/`sfx` layer (nobody is speaking)
|
|
247
|
+
* - a `speakerSlot` naming a slot that is not in the final list
|
|
248
|
+
*
|
|
249
|
+
* MUST run AFTER the orphan-slot sweep, and attribution must NEVER count as a
|
|
250
|
+
* slot reference for that sweep: a slot reachable only as a speaker is a voice
|
|
251
|
+
* with no body — precisely the invented-narrator entity doctrine §5 forbids. The
|
|
252
|
+
* two passes compose to remove both the phantom slot and the dangling
|
|
253
|
+
* attribution pointing at it.
|
|
254
|
+
*/
|
|
255
|
+
export function dropUnknownSpeakers(
|
|
256
|
+
audio: AudioLayer[],
|
|
257
|
+
validSlotIds: Set<string>,
|
|
258
|
+
): { audio: AudioLayer[]; dropped: string[] } {
|
|
259
|
+
const dropped: string[] = []
|
|
260
|
+
const out = audio.map((a) => {
|
|
261
|
+
if (a.speakerSlot === undefined) return a
|
|
262
|
+
if (a.mode === "speech" && validSlotIds.has(a.speakerSlot)) return a
|
|
263
|
+
dropped.push(a.speakerSlot)
|
|
264
|
+
const { speakerSlot: _drop, ...rest } = a
|
|
265
|
+
return rest
|
|
266
|
+
})
|
|
267
|
+
return { audio: dropped.length > 0 ? out : audio, dropped }
|
|
268
|
+
}
|
|
269
|
+
|
|
205
270
|
/** Substitute {slot:x}: castMap binding wins, else the slot's description, else literal id. */
|
|
206
271
|
export function renderAnalyzedScene(scene: { visual: string }, slots: EntitySlot[], castMap?: Record<string, string>): string {
|
|
207
272
|
const byId = new Map(slots.map((s) => [s.slotId, s]))
|