@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nodaro/shared",
3
- "version": "1.19.0",
3
+ "version": "1.21.0",
4
4
  "description": "Shared types, model catalog, wire contracts, and structural vocabularies for the Nodaro platform and SDK.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -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)
@@ -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 unchanged; new fast-tier runs use the current fast model.",
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: 3, note: "10-min ceiling (no duration given)" },
1819
- { identifier: "video-analysis:gemini-3-flash:60s", credits: 1 },
1820
- { identifier: "video-analysis:gemini-3-flash:180s", credits: 1 },
1821
- { identifier: "video-analysis:gemini-3-flash:360s", credits: 2 },
1822
- { identifier: "video-analysis:gemini-3-flash:600s", credits: 3, note: "10-min ceiling" },
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: 9, note: "10-min ceiling (no duration given)" },
1836
- { identifier: "video-analysis:gemini-3.6-flash:60s", credits: 2 },
1837
- { identifier: "video-analysis:gemini-3.6-flash:180s", credits: 2 },
1838
- { identifier: "video-analysis:gemini-3.6-flash:360s", credits: 5 },
1839
- { identifier: "video-analysis:gemini-3.6-flash:600s", credits: 9, note: "10-min ceiling" },
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: 11, note: "10-min ceiling (no duration given)" },
1853
- { identifier: "video-analysis:gemini-3.1-pro:60s", credits: 2 },
1854
- { identifier: "video-analysis:gemini-3.1-pro:180s", credits: 3 },
1855
- { identifier: "video-analysis:gemini-3.1-pro:360s", credits: 7 },
1856
- { identifier: "video-analysis:gemini-3.1-pro:600s", credits: 11, note: "10-min ceiling" },
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: 14, note: "10-min ceiling (no duration given)" },
1873
- { identifier: "video-analysis:mixed:60s", credits: 3 },
1874
- { identifier: "video-analysis:mixed:180s", credits: 4 },
1875
- { identifier: "video-analysis:mixed:360s", credits: 9 },
1876
- { identifier: "video-analysis:mixed:600s", credits: 14, note: "10-min ceiling" },
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": 1,
51
- "video-analysis:gemini-3-flash:180s": 1,
52
- "video-analysis:gemini-3-flash:360s": 2,
53
- "video-analysis:gemini-3-flash:600s": 3,
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": 2,
57
- "video-analysis:gemini-3.6-flash:180s": 2,
58
- "video-analysis:gemini-3.6-flash:360s": 5,
59
- "video-analysis:gemini-3.6-flash:600s": 9,
60
- "video-analysis:gemini-3.1-pro:60s": 2,
61
- "video-analysis:gemini-3.1-pro:180s": 3,
62
- "video-analysis:gemini-3.1-pro:360s": 7,
63
- "video-analysis:gemini-3.1-pro:600s": 11,
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": 3,
68
- "video-analysis:mixed:180s": 4,
69
- "video-analysis:mixed:360s": 9,
70
- "video-analysis:mixed:600s": 14,
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
  /**
@@ -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]))