@nodaro/shared 1.13.1 → 1.15.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.13.1",
3
+ "version": "1.15.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",
@@ -0,0 +1,31 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import { GVP_SUPPORTED_PROVIDERS, isGvpSupportedProvider, isSeedance2Provider } from "../model-constants.js"
3
+
4
+ /**
5
+ * Generate/Edit Video Pro provider-selection guard (2026-07-21): the pro
6
+ * nodes offer ONLY these SKUs. The list is intentionally narrower than the
7
+ * SEEDANCE_2_PROVIDERS capability family (mini is a Seedance-2 variant for
8
+ * capability gating but is NOT offered by the pro engine). If a new SKU is
9
+ * blessed for the pro nodes, update this pin together with the docs pages
10
+ * (docs/nodes/ai-video/generate-video-pro.md, edit-video-pro.md).
11
+ */
12
+ describe("GVP_SUPPORTED_PROVIDERS", () => {
13
+ it("is exactly the blessed pro SKUs", () => {
14
+ expect([...GVP_SUPPORTED_PROVIDERS]).toEqual(["seedance-2", "seedance-2-fast"])
15
+ })
16
+
17
+ it("is a strict subset of the Seedance-2 capability family", () => {
18
+ for (const p of GVP_SUPPORTED_PROVIDERS) {
19
+ expect(isSeedance2Provider(p)).toBe(true)
20
+ }
21
+ // mini stays in the family (capabilities) but out of pro selection
22
+ expect(isSeedance2Provider("seedance-2-mini")).toBe(true)
23
+ expect(isGvpSupportedProvider("seedance-2-mini")).toBe(false)
24
+ })
25
+
26
+ it("predicate matches the list and rejects outsiders", () => {
27
+ for (const p of GVP_SUPPORTED_PROVIDERS) expect(isGvpSupportedProvider(p)).toBe(true)
28
+ expect(isGvpSupportedProvider("veo3")).toBe(false)
29
+ expect(isGvpSupportedProvider(undefined)).toBe(false)
30
+ })
31
+ })
@@ -3,6 +3,9 @@ import {
3
3
  windowAnalysisSchema, videoAnalysisResultSchema,
4
4
  deriveSlotRefs, rewriteSlotTokens, unwrapUnresolvedTokens,
5
5
  renderAnalyzedScene, isOversizedScene, aspectRatioFromDims,
6
+ entitySlotSchema, analyzedSceneSchema,
7
+ rewriteSceneBindings, dropUnknownBindings,
8
+ VIDEO_ANALYSIS_MAX_VARIATIONS, VIDEO_ANALYSIS_VARIATION_SLUGS, VIDEO_ANALYSIS_DEFAULT_VARIATION,
6
9
  type EntitySlot,
7
10
  } from "../video-analysis.js"
8
11
 
@@ -48,6 +51,103 @@ describe("token helpers", () => {
48
51
  })
49
52
  })
50
53
 
54
+ const dreamVariation = {
55
+ variationId: "dream",
56
+ label: "Dream self",
57
+ description: "tan man, mustache — flowing white robe, barefoot, hair loose (dream sequences)",
58
+ refImageUrl: "https://cdn.example/frames/hero-dream.jpg",
59
+ }
60
+
61
+ describe("appearance variations (cast-variations spec §4)", () => {
62
+ it("entitySlotSchema round-trips variations[] including refImageUrl", () => {
63
+ const parsed = entitySlotSchema.parse({ ...slot, variations: [dreamVariation] })
64
+ expect(parsed.variations).toEqual([dreamVariation])
65
+ })
66
+ it("absent variations stays absent (no [] materialization)", () => {
67
+ const parsed = entitySlotSchema.parse(slot)
68
+ expect("variations" in parsed && parsed.variations !== undefined).toBe(false)
69
+ })
70
+ it(`rejects more than VIDEO_ANALYSIS_MAX_VARIATIONS (${4}) — window layer rejects, merge folds`, () => {
71
+ expect(VIDEO_ANALYSIS_MAX_VARIATIONS).toBe(4)
72
+ const five = ["dream", "flashback", "disguise", "era", "alt-1"].map((id) => ({ ...dreamVariation, variationId: id }))
73
+ expect(entitySlotSchema.safeParse({ ...slot, variations: five }).success).toBe(false)
74
+ })
75
+ it("rejects the reserved 'default' variationId inside variations[] (D9)", () => {
76
+ expect(VIDEO_ANALYSIS_DEFAULT_VARIATION).toBe("default")
77
+ expect(entitySlotSchema.safeParse({ ...slot, variations: [{ ...dreamVariation, variationId: "default" }] }).success).toBe(false)
78
+ })
79
+ it("rejects a malformed variationId (slug charset only; vocabulary is doctrine-enforced)", () => {
80
+ expect(entitySlotSchema.safeParse({ ...slot, variations: [{ ...dreamVariation, variationId: "Dream Look" }] }).success).toBe(false)
81
+ expect(VIDEO_ANALYSIS_VARIATION_SLUGS).toContain("dream")
82
+ expect(VIDEO_ANALYSIS_VARIATION_SLUGS).toContain("alt-2")
83
+ })
84
+ it("windowAnalysisSchema scenes round-trip slotVariations; absent stays absent", () => {
85
+ const bound = { ...baseScene, slotVariations: { hero: "dream" } }
86
+ const parsed = windowAnalysisSchema.parse({ slots: [{ ...slot, variations: [dreamVariation] }], scenes: [bound, baseScene] })
87
+ expect(parsed.scenes[0].slotVariations).toEqual({ hero: "dream" })
88
+ expect(parsed.scenes[1].slotVariations).toBeUndefined()
89
+ })
90
+ it("analyzedSceneSchema inherits slotVariations from the same base", () => {
91
+ const parsed = analyzedSceneSchema.parse({
92
+ ...baseScene, sceneNumber: 1, visualResolved: "a man juggles", slotRefs: ["hero"], slotVariations: { hero: "dream" },
93
+ })
94
+ expect(parsed.slotVariations).toEqual({ hero: "dream" })
95
+ })
96
+ it("videoAnalysisResultSchema full-document round-trip with both fields", () => {
97
+ const meta = { durationSec: 10, width: 1920, height: 1080, aspectRatio: "16:9" }
98
+ const doc = {
99
+ meta,
100
+ slots: [{ ...slot, variations: [dreamVariation] }],
101
+ scenes: [{ ...baseScene, sceneNumber: 1, visualResolved: "a man juggles", slotRefs: ["hero"], slotVariations: { hero: "dream" } }],
102
+ }
103
+ expect(videoAnalysisResultSchema.parse(doc)).toEqual(doc)
104
+ })
105
+ })
106
+
107
+ describe("variationFolds (cast-variations §4/§6 — review F5)", () => {
108
+ const meta = { durationSec: 10, width: 1920, height: 1080, aspectRatio: "16:9" }
109
+ const doc = {
110
+ meta,
111
+ slots: [slot],
112
+ scenes: [{ ...baseScene, sceneNumber: 1, visualResolved: "a man juggles", slotRefs: ["hero"] }],
113
+ }
114
+ it("videoAnalysisResultSchema round-trips variationFolds — strip-mode consumers keep the §6 fold note", () => {
115
+ const withFolds = { ...doc, variationFolds: [{ slotId: "hero", variationId: "era", label: "Era" }] }
116
+ expect(videoAnalysisResultSchema.parse(withFolds)).toEqual(withFolds)
117
+ })
118
+ it("absent variationFolds stays absent (no [] materialization)", () => {
119
+ const parsed = videoAnalysisResultSchema.parse(doc)
120
+ expect("variationFolds" in parsed && parsed.variationFolds !== undefined).toBe(false)
121
+ })
122
+ })
123
+
124
+ describe("binding rewrite helpers (merge consumes — spec §4)", () => {
125
+ it("rewriteSceneBindings renames slot keys and per-slot variation values", () => {
126
+ expect(rewriteSceneBindings({ "man-2": "dream", other: "era" }, { "man-2": "hero" }, { hero: { dream: "flashback" } }))
127
+ .toEqual({ hero: "flashback", other: "era" })
128
+ })
129
+ it("rewriteSceneBindings passes undefined through", () => {
130
+ expect(rewriteSceneBindings(undefined, { a: "b" })).toBeUndefined()
131
+ })
132
+ it("dropUnknownBindings drops unknown (slot, variation) pairs and reports them", () => {
133
+ const valid = new Map([["hero", new Set(["dream"])]])
134
+ const r = dropUnknownBindings({ hero: "dream", hero2: "dream", other: "ghost" }, valid)
135
+ expect(r.kept).toEqual({ hero: "dream" })
136
+ expect(r.dropped).toEqual([{ slotId: "hero2", variationId: "dream" }, { slotId: "other", variationId: "ghost" }])
137
+ })
138
+ it("dropUnknownBindings treats 'default' as always valid for a known slot", () => {
139
+ const valid = new Map([["hero", new Set<string>()]])
140
+ const r = dropUnknownBindings({ hero: "default" }, valid)
141
+ expect(r.kept).toEqual({ hero: "default" })
142
+ expect(r.dropped).toEqual([])
143
+ })
144
+ it("dropUnknownBindings returns kept: undefined when nothing survives (no {} materialization)", () => {
145
+ const r = dropUnknownBindings({ ghost: "dream" }, new Map())
146
+ expect(r.kept).toBeUndefined()
147
+ expect(r.dropped).toEqual([{ slotId: "ghost", variationId: "dream" }])
148
+ })
149
+ })
150
+
51
151
  describe("misc", () => {
52
152
  it("isOversizedScene flags > 8s only", () => {
53
153
  expect(isOversizedScene(0, 8)).toBe(false)
package/src/index.ts CHANGED
@@ -106,6 +106,8 @@ export {
106
106
  VIDEO_GEN_COLLAPSED_T2V_IDS,
107
107
  resolveVideoProviderForMode,
108
108
  isSeedance2Provider,
109
+ GVP_SUPPORTED_PROVIDERS,
110
+ isGvpSupportedProvider,
109
111
  defaultVideoAspectRatio,
110
112
  CHARACTER_MOTION_PROVIDERS,
111
113
  LOCATION_ATMOSPHERE_PROVIDERS,
@@ -1124,6 +1124,22 @@ export function isSeedance2Provider(provider: string | undefined): boolean {
1124
1124
  return !!provider && SEEDANCE_2_PROVIDERS.has(provider)
1125
1125
  }
1126
1126
 
1127
+ /**
1128
+ * Generate/Edit Video Pro SUPPORT subset — the only SKUs the pro multi-segment
1129
+ * engine currently offers (mini withdrawn from selection, 2026-07-21).
1130
+ * Deliberately distinct from SEEDANCE_2_PROVIDERS: the family set gates
1131
+ * CAPABILITIES (ref limits, i2v params, adaptive aspect) and must keep every
1132
+ * variant; this list gates which SKUs the pro nodes offer in selection. The
1133
+ * pro plugin routes stay tolerant of the full family so previously-saved
1134
+ * workflows keep running — the editor fail-safe snaps stale selections to a
1135
+ * supported SKU instead.
1136
+ */
1137
+ export const GVP_SUPPORTED_PROVIDERS = ["seedance-2", "seedance-2-fast"] as const
1138
+
1139
+ export function isGvpSupportedProvider(provider: string | undefined): boolean {
1140
+ return !!provider && (GVP_SUPPORTED_PROVIDERS as readonly string[]).includes(provider)
1141
+ }
1142
+
1127
1143
  /**
1128
1144
  * Default aspect ratio for a video provider when the node carries no explicit
1129
1145
  * `aspectRatio`. Seedance 2.x defaults to `"adaptive"` (output matches the
@@ -6,7 +6,11 @@
6
6
  * on Instagram's carousel item limits.
7
7
  */
8
8
 
9
- /** Node types that publish to a social platform via POST /v1/social/publish. */
9
+ /** Node types that publish to a social platform via POST /v1/social/publish.
10
+ * The 7 per-platform nodes hardcode their platform via `node.type`;
11
+ * `publish-social` is the UNIFIED node whose platform is derived from the
12
+ * chosen connection (`data.platform`) instead. All shared-set-driven routing
13
+ * (carousel accumulation, caption, refMap gate) covers it automatically. */
10
14
  export const SOCIAL_POST_NODE_TYPES = new Set([
11
15
  "instagram-post",
12
16
  "tiktok-post",
@@ -15,6 +19,7 @@ export const SOCIAL_POST_NODE_TYPES = new Set([
15
19
  "x-post",
16
20
  "facebook-post",
17
21
  "telegram-post",
22
+ "publish-social",
18
23
  ])
19
24
 
20
25
  /** Instagram carousel limits per Meta Graph API.
@@ -25,12 +25,46 @@ export type VideoAnalysisEntitySource = (typeof VIDEO_ANALYSIS_ENTITY_SOURCES)[n
25
25
  /** Matches {slot:<id>} tokens. Distinct from NODE_REF_PATTERN / {image:N} grammars. */
26
26
  export const SLOT_TOKEN_RE = /\{slot:([a-z0-9-]+)\}/g
27
27
 
28
+ /**
29
+ * Appearance variations (cast-variations spec, 2026-07-24): a slot's canonical
30
+ * `description` IS its default look; `variations` enumerates NON-default looks
31
+ * only (dream vs reality, flashback, disguise…). `"default"` is a reserved
32
+ * variationId — used in scene bindings / ledger keys / routing for unbound
33
+ * scenes, never inside `variations[]`. The closed slug vocabulary is
34
+ * doctrine-enforced (the schema pins only the id charset, like `role`);
35
+ * `alt-1`/`alt-2` are the escape hatches.
36
+ */
37
+ export const VIDEO_ANALYSIS_VARIATION_SLUGS = ["dream", "flashback", "disguise", "costume", "transformation", "era", "alt-1", "alt-2"] as const
38
+ /** Max NON-default looks per slot. The window layer REJECTS past the cap (schema-forced retry); the cross-window merge FOLDS at the cap. */
39
+ export const VIDEO_ANALYSIS_MAX_VARIATIONS = 4
40
+ export const VIDEO_ANALYSIS_DEFAULT_VARIATION = "default"
41
+
42
+ export const slotVariationSchema = z.object({
43
+ variationId: z.string().min(1).regex(/^[a-z0-9-]+$/)
44
+ .refine((id) => id !== VIDEO_ANALYSIS_DEFAULT_VARIATION, { message: `"${VIDEO_ANALYSIS_DEFAULT_VARIATION}" is reserved for the slot's canonical look` }),
45
+ label: z.string().min(1),
46
+ /** Full STANDALONE casting-sheet look restating the slot's invariant identity
47
+ * core (face, build, age) — the manifest substitutes it wholesale, so a
48
+ * wardrobe-only delta would silently delete identity from the prompt. */
49
+ description: z.string().min(1),
50
+ /** Per-variation identity reference (auto-cast frame or user sheet). In the
51
+ * schema from day one: every strip-mode round-trip must carry it. */
52
+ refImageUrl: z.string().url().optional(),
53
+ })
54
+ export type SlotVariation = z.infer<typeof slotVariationSchema>
55
+
28
56
  export const entitySlotSchema = z.object({
29
57
  slotId: z.string().min(1).regex(/^[a-z0-9-]+$/),
30
58
  label: z.string().min(1),
31
59
  source: z.enum(VIDEO_ANALYSIS_ENTITY_SOURCES),
32
60
  role: z.string().min(1),
33
61
  description: z.string().min(1),
62
+ /** Auto-cast visual reference — a hosted frame from the analyzed footage
63
+ * where this entity is clearly visible. Optional/additive: producers may
64
+ * omit it; consumers use it as an identity reference for recreation. */
65
+ refImageUrl: z.string().url().optional(),
66
+ /** NON-default looks only; present only when at least one exists. */
67
+ variations: z.array(slotVariationSchema).max(VIDEO_ANALYSIS_MAX_VARIATIONS).optional(),
34
68
  })
35
69
  export type EntitySlot = z.infer<typeof entitySlotSchema>
36
70
 
@@ -57,6 +91,11 @@ const windowSceneBase = z.object({
57
91
  transitionOut: z.enum(["cut", "fade", "wipe", "whip"]).optional(),
58
92
  // Array of concurrent layers (music + speech + sfx together); [] = silence.
59
93
  audio: z.array(audioLayerSchema),
94
+ /** slotId → variationId for slots wearing a NON-default look in this scene
95
+ * (only slots referenced in the scene; absent key ⇒ the default look).
96
+ * Rides windowSceneBase so BOTH the window layer and analyzedSceneSchema
97
+ * inherit it — the out-of-band binding channel (no in-text markers, D6). */
98
+ slotVariations: z.record(z.string(), z.string()).optional(),
60
99
  })
61
100
  // .strip() (default) drops model-emitted oversized/slotRefs — validator-computed only.
62
101
  const windowSceneSchema = windowSceneBase.refine((s) => s.endSec > s.startSec, { message: "endSec must be > startSec" })
@@ -89,6 +128,16 @@ export const videoAnalysisResultSchema = z.object({
89
128
  }),
90
129
  slots: z.array(entitySlotSchema),
91
130
  scenes: z.array(analyzedSceneSchema).min(1),
131
+ /** CAST VARIATIONS (§4 cap handling): looks the analyzer's merge FOLDED into
132
+ * the default at VIDEO_ANALYSIS_MAX_VARIATIONS — recorded, never silent. In
133
+ * the wire schema so strip-mode consumers (the recast client's validated
134
+ * blueprint view) keep it: the §6 "folded into default look" note is the
135
+ * user's only pre-pay defense against a wrong split. */
136
+ variationFolds: z.array(z.object({
137
+ slotId: z.string(),
138
+ variationId: z.string(),
139
+ label: z.string(),
140
+ })).optional(),
92
141
  })
93
142
  export type VideoAnalysisResult = z.infer<typeof videoAnalysisResultSchema>
94
143
 
@@ -113,6 +162,46 @@ export function unwrapUnresolvedTokens(text: string, validIds: Set<string>): { t
113
162
  return { text: out, unresolved }
114
163
  }
115
164
 
165
+ /**
166
+ * Rewrite a scene's `slotVariations` after cross-window slot/variation
167
+ * unification: slot keys map through `slotRenames`, then each variation value
168
+ * maps through `variationRenames[<new slotId>]`. Absent renames pass through.
169
+ */
170
+ export function rewriteSceneBindings(
171
+ sv: Record<string, string> | undefined,
172
+ slotRenames: Record<string, string>,
173
+ variationRenames?: Record<string, Record<string, string>>,
174
+ ): Record<string, string> | undefined {
175
+ if (!sv) return undefined
176
+ const out: Record<string, string> = {}
177
+ for (const [slotId, variationId] of Object.entries(sv)) {
178
+ const newSlot = slotRenames[slotId] ?? slotId
179
+ out[newSlot] = variationRenames?.[newSlot]?.[variationId] ?? variationId
180
+ }
181
+ return out
182
+ }
183
+
184
+ /**
185
+ * Drop bindings whose (slotId, variationId) no longer exists after a merge —
186
+ * the unwrap-rule mirror: never persist a dangling binding, and report what
187
+ * was dropped so the caller can warn. `"default"` is always valid for a known
188
+ * slot. `kept` is undefined when nothing survives (no `{}` materialization).
189
+ */
190
+ export function dropUnknownBindings(
191
+ sv: Record<string, string> | undefined,
192
+ validBySlot: Map<string, Set<string>>,
193
+ ): { kept?: Record<string, string>; dropped: Array<{ slotId: string; variationId: string }> } {
194
+ if (!sv) return { dropped: [] }
195
+ const kept: Record<string, string> = {}
196
+ const dropped: Array<{ slotId: string; variationId: string }> = []
197
+ for (const [slotId, variationId] of Object.entries(sv)) {
198
+ const valid = validBySlot.get(slotId)
199
+ if (valid && (variationId === VIDEO_ANALYSIS_DEFAULT_VARIATION || valid.has(variationId))) kept[slotId] = variationId
200
+ else dropped.push({ slotId, variationId })
201
+ }
202
+ return { kept: Object.keys(kept).length > 0 ? kept : undefined, dropped }
203
+ }
204
+
116
205
  /** Substitute {slot:x}: castMap binding wins, else the slot's description, else literal id. */
117
206
  export function renderAnalyzedScene(scene: { visual: string }, slots: EntitySlot[], castMap?: Record<string, string>): string {
118
207
  const byId = new Map(slots.map((s) => [s.slotId, s]))