@nodaro/shared 1.20.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 +42 -1
- 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 +41 -2
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/video-analysis.test.ts +69 -1
- 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/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]))
|