@nodaro/shared 3.0.0 → 3.2.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": "3.0.0",
3
+ "version": "3.2.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",
@@ -12,6 +12,7 @@ import {
12
12
  getItemSortId,
13
13
  cleanOrphanedItems,
14
14
  } from "../presentation-utils.js"
15
+ import { AUDIO_PRODUCER_TYPES, VIDEO_PRODUCER_TYPES } from "../producer-types.js"
15
16
  import type { GenericNode, GenericEdge } from "../types.js"
16
17
  import type { PresentationItem } from "../presentation-types.js"
17
18
 
@@ -1135,3 +1136,99 @@ describe("presentation-utils — action-fx", () => {
1135
1136
  expect(getInputFieldSchema("action-fx")).toEqual({ key: "actionFx", type: "select" })
1136
1137
  })
1137
1138
  })
1139
+
1140
+ // ---------------------------------------------------------------------------
1141
+ // Canonical output typing — the presentation classifier vs the producer sets
1142
+ // ---------------------------------------------------------------------------
1143
+
1144
+ /**
1145
+ * `getOutputType` is what a published app renders from and what `/v1` app
1146
+ * schemas and run results declare (`routes/api-tokens.ts`). A node it calls
1147
+ * `"data"` is shown as a JSON blob instead of a player, is skipped by the
1148
+ * lightbox media list, and is announced to API consumers with the wrong type.
1149
+ *
1150
+ * The literal sets in the module are the classifier's own opinion and win; the
1151
+ * tail is derived from the producer vocabularies the canvas validators already
1152
+ * maintain, so a node cannot be a declared video producer and be typed `"data"`
1153
+ * at the same time.
1154
+ */
1155
+ describe("getOutputType — derived from the producer vocabularies", () => {
1156
+ it("types 3D Render Pro as video (it settles with the standard videoUrl)", () => {
1157
+ expect(getOutputType("pro-3d-render")).toBe("video")
1158
+ })
1159
+
1160
+ it("types every declared video producer as video", () => {
1161
+ for (const t of VIDEO_PRODUCER_TYPES) expect([t, getOutputType(t)]).toEqual([t, "video"])
1162
+ })
1163
+
1164
+ it("types every declared audio producer as audio, except the dual-mode ones the literal sets claim", () => {
1165
+ for (const t of AUDIO_PRODUCER_TYPES) {
1166
+ // voice-changer / voice-changer-pro / dubbing can emit video; the
1167
+ // classifier's own list still calls them audio, which is their default.
1168
+ expect([t, getOutputType(t)]).toEqual([t, "audio"])
1169
+ }
1170
+ })
1171
+
1172
+ it("leaves a run-time-decided producer as data — there is no honest static answer", () => {
1173
+ expect(getOutputType("list")).toBe("data")
1174
+ expect(getOutputType("sub-workflow")).toBe("data")
1175
+ expect(getOutputType("split-media")).toBe("data")
1176
+ })
1177
+
1178
+ it("keeps the literal sets authoritative where they and the producer sets disagree", () => {
1179
+ // adjust-volume is in AUDIO_PRODUCER_TYPES and DYNAMIC_PRODUCER_TYPES;
1180
+ // upload-video is a video producer AND an input node.
1181
+ expect(getOutputType("adjust-volume")).toBe("audio")
1182
+ expect(getOutputType("upload-video")).toBe("video")
1183
+ expect(getOutputType("text-prompt")).toBe("text")
1184
+ })
1185
+ })
1186
+
1187
+ describe("published-app / API-token output mapping for 3D Render Pro", () => {
1188
+ const proNode = mkNode("pro", "pro-3d-render", { presentationOutput: true, label: "Render" })
1189
+
1190
+ it("declares the app-schema output type the SDK and MCP read (mirrors routes/api-tokens.ts)", () => {
1191
+ // The route builds `{ nodeId, label, type: getOutputType(node.type) }` for
1192
+ // both the app schema and the run result.
1193
+ const outputs = getOutputNodes([proNode], []).map((n) => ({
1194
+ nodeId: n.id,
1195
+ type: getOutputType(n.type),
1196
+ }))
1197
+ expect(outputs).toEqual([{ nodeId: "pro", type: "video" }])
1198
+ })
1199
+
1200
+ it("is an OUTPUT, not an input — the picker must not offer it as an app input", () => {
1201
+ expect(INPUT_NODE_TYPES.has("pro-3d-render")).toBe(false)
1202
+ expect(getInputNodes([mkNode("pro", "pro-3d-render", { presentationVisible: true })])).toEqual([])
1203
+ })
1204
+ })
1205
+
1206
+ describe("legacy presentationVisible rules for a media producer", () => {
1207
+ it("stays an output when its composition ALSO feeds something downstream", () => {
1208
+ // The Pro node's `composition` handle commonly feeds an editor or a
1209
+ // re-render; the legacy rule drops a node with an outgoing edge unless it
1210
+ // produces media of its own.
1211
+ const nodes = [
1212
+ mkNode("pro", "pro-3d-render", { presentationVisible: true }),
1213
+ mkNode("edit", "edit-3d-scene", {}),
1214
+ ]
1215
+ const out = getOutputNodes(nodes, [mkEdge("pro", "edit")])
1216
+ expect(out.map((n) => n.id)).toEqual(["pro"])
1217
+ })
1218
+
1219
+ it("applies the same rule to the other video producers the literal list forgot", () => {
1220
+ const nodes = [
1221
+ mkNode("gv", "generate-video", { presentationVisible: true }),
1222
+ mkNode("cap", "add-captions", {}),
1223
+ ]
1224
+ expect(getOutputNodes(nodes, [mkEdge("gv", "cap")]).map((n) => n.id)).toEqual(["gv"])
1225
+ })
1226
+
1227
+ it("still drops a non-media node that feeds something downstream", () => {
1228
+ const nodes = [
1229
+ mkNode("txt", "text-prompt", { presentationVisible: true }),
1230
+ mkNode("gv", "generate-video", {}),
1231
+ ]
1232
+ expect(getOutputNodes(nodes, [mkEdge("txt", "gv")])).toEqual([])
1233
+ })
1234
+ })
@@ -0,0 +1,104 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import {
3
+ SCENE3D_DEFAULT_ADVANCED_ENGINE,
4
+ SCENE3D_SUPPORTED_SCHEMA_VERSIONS,
5
+ resolveScene3DAuthoringEngine,
6
+ } from "../index.js"
7
+
8
+ /** Only the fields the resolver reads — it must not need a whole valid plan. */
9
+ const v1Plan = { planType: "3d-scene", schemaVersion: 1 }
10
+ const v2Plan = (engine = "blender-cloud") => ({
11
+ planType: "3d-scene",
12
+ schemaVersion: 2,
13
+ provenance: { engine },
14
+ })
15
+
16
+ describe("resolveScene3DAuthoringEngine", () => {
17
+ it("keeps a plain generate on Basic, with NO extra wire fields", () => {
18
+ const choice = resolveScene3DAuthoringEngine({})
19
+ expect(choice).toEqual({ ok: true, lane: "basic", engine: undefined, fields: {} })
20
+ })
21
+
22
+ it("keeps a v1 edit on Basic", () => {
23
+ const choice = resolveScene3DAuthoringEngine({ plan: v1Plan })
24
+ expect(choice.ok && choice.lane).toBe("basic")
25
+ expect(choice.ok && choice.fields).toEqual({})
26
+ })
27
+
28
+ it("sends an explicitly requested advanced engine plus the accepted versions", () => {
29
+ const choice = resolveScene3DAuthoringEngine({
30
+ requested: "blender-cloud",
31
+ availableEngines: ["blender-cloud"],
32
+ })
33
+ expect(choice.ok && choice.lane).toBe("advanced")
34
+ expect(choice.ok && choice.fields).toEqual({
35
+ engine: "blender-cloud",
36
+ acceptedSceneSchemaVersions: [...SCENE3D_SUPPORTED_SCHEMA_VERSIONS],
37
+ })
38
+ })
39
+
40
+ it("routes a v2 edit to the advanced lane with no engine chosen", () => {
41
+ const choice = resolveScene3DAuthoringEngine({ plan: v2Plan() })
42
+ expect(choice.ok && choice.lane).toBe("advanced")
43
+ expect(choice.ok && choice.engine).toBe(SCENE3D_DEFAULT_ADVANCED_ENGINE)
44
+ })
45
+
46
+ it("keeps a v2 edit on the engine that authored it", () => {
47
+ const choice = resolveScene3DAuthoringEngine({
48
+ plan: v2Plan("blender-local"),
49
+ availableEngines: ["blender-cloud", "blender-local"],
50
+ })
51
+ expect(choice.ok && choice.engine).toBe("blender-local")
52
+ })
53
+
54
+ it("does not send a v2 edit to an authoring engine this install dropped", () => {
55
+ const choice = resolveScene3DAuthoringEngine({
56
+ plan: v2Plan("blender-local"),
57
+ availableEngines: ["blender-cloud"],
58
+ })
59
+ expect(choice.ok && choice.engine).toBe("blender-cloud")
60
+ })
61
+
62
+ it("REFUSES a v2 scene pointed at Basic instead of downgrading it", () => {
63
+ const choice = resolveScene3DAuthoringEngine({ requested: "basic", plan: v2Plan() })
64
+ expect(choice.ok).toBe(false)
65
+ expect(!choice.ok && choice.code).toBe("schema_requires_advanced")
66
+ })
67
+
68
+ it("REFUSES a v2 edit when the install has no advanced engine at all", () => {
69
+ const choice = resolveScene3DAuthoringEngine({ plan: v2Plan(), availableEngines: [] })
70
+ expect(!choice.ok && choice.code).toBe("advanced_unavailable")
71
+ })
72
+
73
+ it("REFUSES an explicit engine this install does not serve — never falls back to Basic", () => {
74
+ const choice = resolveScene3DAuthoringEngine({
75
+ requested: "blender-local",
76
+ availableEngines: ["blender-cloud"],
77
+ })
78
+ expect(!choice.ok && choice.code).toBe("engine_unavailable")
79
+ })
80
+
81
+ it("REFUSES an explicit engine on a v2 scene the same way", () => {
82
+ const choice = resolveScene3DAuthoringEngine({
83
+ requested: "blender-local",
84
+ plan: v2Plan(),
85
+ availableEngines: ["blender-cloud"],
86
+ })
87
+ expect(!choice.ok && choice.code).toBe("engine_unavailable")
88
+ })
89
+
90
+ it("REFUSES an engine name the contract does not know", () => {
91
+ const choice = resolveScene3DAuthoringEngine({ requested: "unknown-renderer" })
92
+ expect(!choice.ok && choice.code).toBe("unknown_engine")
93
+ })
94
+
95
+ it("REFUSES a scene claiming a schema version nothing here can author against", () => {
96
+ const choice = resolveScene3DAuthoringEngine({ plan: { planType: "3d-scene", schemaVersion: 3 } })
97
+ expect(!choice.ok && choice.code).toBe("unsupported_schema_version")
98
+ })
99
+
100
+ it("stays permissive when availability is UNKNOWN — the route refuses honestly", () => {
101
+ const choice = resolveScene3DAuthoringEngine({ requested: "blender-local" })
102
+ expect(choice.ok && choice.engine).toBe("blender-local")
103
+ })
104
+ })
@@ -0,0 +1,36 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import { scene3DPlanV2Schema } from "../scene3d-v2-plan.js"
3
+ import { applyScene3DV2EditOperations } from "../scene3d-v2-edit.js"
4
+ import { computeScene3DPlanV2ContentHash, verifyScene3DPlanV2ContentHash } from "../scene3d-v2-resources.js"
5
+ import { planV2 } from "./scene3d-v2-fixtures.js"
6
+
7
+ describe("baked entity visibility", () => {
8
+ it("round-trips false without adding a default to existing revisions", async () => {
9
+ const original = planV2()
10
+ const hidden = { ...original, objects: original.objects.map(e => ({ ...e, visible: false })) }
11
+ expect(scene3DPlanV2Schema.parse(hidden).objects.every(e => e.visible === false)).toBe(true)
12
+ expect(scene3DPlanV2Schema.parse(original).objects.every(e => !("visible" in e))).toBe(true)
13
+ expect(await computeScene3DPlanV2ContentHash(hidden)).not.toBe(await computeScene3DPlanV2ContentHash(original))
14
+ expect(scene3DPlanV2Schema.safeParse({ ...hidden, objects: hidden.objects.map(e => ({ ...e, visible: "false" })) }).success).toBe(false)
15
+ })
16
+
17
+ it("keeps baked visibility when a temporary show override is added and removed", async () => {
18
+ const original = planV2()
19
+ const hidden = { ...original, overrides: [], objects: original.objects.map(e => ({ ...e, visible: e.id !== "e2" })) }
20
+ hidden.provenance = { ...hidden.provenance, contentHash: await computeScene3DPlanV2ContentHash(hidden) }
21
+ const shown = await applyScene3DV2EditOperations(hidden, [{ op: "set-override", override: {
22
+ kind: "entity-visibility", entityId: "e2", visible: true,
23
+ } }], { expectedRevisionId: hidden.revisionId })
24
+ expect(shown.ok).toBe(true)
25
+ if (!shown.ok) return
26
+ const override = shown.plan.overrides!.find(o => o.kind === "entity-visibility")!
27
+ const reset = await applyScene3DV2EditOperations(shown.plan, [{ op: "remove-override", overrideId: override.id }], {
28
+ expectedRevisionId: shown.plan.revisionId,
29
+ })
30
+ expect(reset.ok).toBe(true)
31
+ if (!reset.ok) return
32
+ expect(reset.plan.objects.find(e => e.id === "e2")?.visible).toBe(false)
33
+ expect(reset.plan.overrides).toEqual([])
34
+ expect(await verifyScene3DPlanV2ContentHash(reset.plan)).toBe(true)
35
+ })
36
+ })
package/src/index.ts CHANGED
@@ -1061,6 +1061,8 @@ export * from "./scene3d-v2.js"
1061
1061
  export * from "./scene3d-v2-plan.js"
1062
1062
  export * from "./scene3d-v2-resources.js"
1063
1063
  export * from "./scene3d-camera-track.js"
1064
+ // --- 3D Render Pro: one durable operation, scene + video in one result ---
1065
+ export * from "./pro-3d-render.js"
1064
1066
 
1065
1067
  // --- transient studio keys — the public share read strips them ---
1066
1068
  export {
@@ -1070,3 +1072,4 @@ export {
1070
1072
  } from "./studio-transient.js"
1071
1073
 
1072
1074
  export * from "./scene3d-v2-edit.js"
1075
+ export * from "./scene3d-authoring-engine.js"
@@ -1121,6 +1121,12 @@ export const ASPECT_RATIO_DIMENSIONS: Record<string, { width: number; height: nu
1121
1121
  "9:16": { width: 1080, height: 1920 },
1122
1122
  "1:1": { width: 1080, height: 1080 },
1123
1123
  "4:5": { width: 1080, height: 1350 },
1124
+ // Ultra-wide. 1680x720 rather than a 1920-wide pair because the Scene3D v2
1125
+ // admission bounds require even integers on both axes and 1920/(21/9) is
1126
+ // odd; 1680x720 is the pair the scene contract names as supported. Additive:
1127
+ // every consumer here is a keyed lookup with a fallback, and a node only
1128
+ // reaches this entry if its own aspect enum offers 21:9 (today, Pro 3D).
1129
+ "21:9": { width: 1680, height: 720 },
1124
1130
  }
1125
1131
 
1126
1132
  /** Motion transfer providers */
@@ -2637,6 +2643,10 @@ export const COMPOSER_PLAN_MAP: Readonly<Record<string, { planType: string; plan
2637
2643
  // render-video routes either one to the `3d-scene` renderer unchanged.
2638
2644
  "generate-3d-scene": { planType: "3d-scene", planField: "scenePlan" },
2639
2645
  "edit-3d-scene": { planType: "3d-scene", planField: "scenePlan" },
2646
+ // 3D Render Pro authors the SAME `scenePlan` revision (v2) alongside its
2647
+ // MP4, so the render-only re-run reads it through this map exactly as it
2648
+ // reads a Basic revision — no second plan lane, no second render path.
2649
+ "pro-3d-render": { planType: "3d-scene", planField: "scenePlan" },
2640
2650
  }
2641
2651
 
2642
2652
  /** Every composer plan-field name, derived from COMPOSER_PLAN_MAP (single source
@@ -46,6 +46,7 @@ export const NODE_MAPPABLE_FIELDS: Readonly<Record<string, readonly string[]>> =
46
46
  "3d-title": ["titlePrompt"],
47
47
  "generate-3d-scene": ["scenePrompt"],
48
48
  "edit-3d-scene": ["editPrompt"],
49
+ "pro-3d-render": ["scenePrompt"],
49
50
  "motion-graphics": ["motionPrompt"],
50
51
  "generate-script": ["styleGuide"],
51
52
  "speech-to-video": ["prompt", "negativePrompt"],
@@ -5,6 +5,7 @@
5
5
 
6
6
  import type { GenericNode, GenericEdge } from "./types.js"
7
7
  import type { PresentationItem } from "./presentation-types.js"
8
+ import { AUDIO_PRODUCER_TYPES, VIDEO_PRODUCER_TYPES } from "./producer-types.js"
8
9
 
9
10
  // ---------------------------------------------------------------------------
10
11
  // Node type sets
@@ -84,6 +85,15 @@ const NON_OUTPUT_TYPES = new Set([
84
85
  "component",
85
86
  ])
86
87
 
88
+ /**
89
+ * Node types that count as an output even when something is wired to their
90
+ * output handle (the legacy `presentationVisible` rule below).
91
+ *
92
+ * A hand-kept list, and one the platform has outgrown: it is unioned at the use
93
+ * site with the producer vocabularies the canvas validators already maintain
94
+ * (see `isMediaProducingType`), so a new media node is covered by the set it
95
+ * must join anyway rather than by remembering to edit this one.
96
+ */
87
97
  const MEDIA_PRODUCING_TYPES = new Set([
88
98
  "generate-image",
89
99
  "edit-image",
@@ -121,6 +131,11 @@ const IMAGE_OUTPUT_TYPES = new Set([
121
131
  "upload-image",
122
132
  ])
123
133
 
134
+ /**
135
+ * Video-output types NOT already declared by `VIDEO_PRODUCER_TYPES` — the two
136
+ * are unioned in `getOutputType`, which is where the reading order (and the
137
+ * reason the literal sets win) is documented.
138
+ */
124
139
  const VIDEO_OUTPUT_TYPES = new Set([
125
140
  "image-to-video", "text-to-video", "video-to-video", "extend-video",
126
141
  "render-video", "video-composer", "after-effects", "lottie-overlay",
@@ -189,7 +204,7 @@ export function getOutputNodes<T extends GenericNode>(
189
204
  // Backwards compat: old flag on output-eligible nodes
190
205
  if (n.data.presentationVisible === true) {
191
206
  if (NON_OUTPUT_TYPES.has(n.type)) return false
192
- return !nodesWithOutgoing.has(n.id) || MEDIA_PRODUCING_TYPES.has(n.type)
207
+ return !nodesWithOutgoing.has(n.id) || isMediaProducingType(n.type)
193
208
  }
194
209
  return false
195
210
  }
@@ -197,16 +212,53 @@ export function getOutputNodes<T extends GenericNode>(
197
212
  })
198
213
  }
199
214
 
200
- /** Map node type to its output media type. */
215
+ /**
216
+ * Map node type to its output media type.
217
+ *
218
+ * The four literal sets above are read FIRST and win: they are the presentation
219
+ * classifier's own opinion, including for dual-mode nodes whose default medium
220
+ * is not their handle set (voice-changer and dubbing are audio here even though
221
+ * they can emit video).
222
+ *
223
+ * Anything they do not name falls through to the producer vocabularies the
224
+ * canvas validators and the orchestrator already maintain
225
+ * (`packages/shared/src/producer-types.ts`). Those sets are what a new media
226
+ * node MUST join for its outputs to connect at all, so deriving the tail from
227
+ * them is what stops this map from silently drifting behind the node catalogue
228
+ * — which it had (3D Render Pro, Generate Video, Generate Video Pro and a dozen
229
+ * ffmpeg nodes all read as `"data"`, so a published app rendered them as a JSON
230
+ * blob and `/v1` app schemas declared the wrong output type).
231
+ *
232
+ * `DYNAMIC_PRODUCER_TYPES` is deliberately NOT consulted: a node whose medium
233
+ * is decided at run time has no static answer, and `"data"` is the honest one.
234
+ */
201
235
  export function getOutputType(nodeType: string | undefined): OutputType {
202
236
  if (!nodeType) return "data"
203
237
  if (IMAGE_OUTPUT_TYPES.has(nodeType)) return "image"
204
238
  if (VIDEO_OUTPUT_TYPES.has(nodeType)) return "video"
205
239
  if (AUDIO_OUTPUT_TYPES.has(nodeType)) return "audio"
206
240
  if (TEXT_OUTPUT_TYPES.has(nodeType)) return "text"
241
+ if (VIDEO_PRODUCER_TYPES.has(nodeType)) return "video"
242
+ if (AUDIO_PRODUCER_TYPES.has(nodeType)) return "audio"
207
243
  return "data"
208
244
  }
209
245
 
246
+ /**
247
+ * Whether a node produces media of its own — the test the legacy
248
+ * `presentationVisible` rule uses to keep a media node as an OUTPUT even when
249
+ * it also feeds something downstream.
250
+ *
251
+ * Same union, same reason as `getOutputType`: the hand-kept list plus the
252
+ * producer vocabularies. Text producers stay where the literal list put them
253
+ * (`generate-script`, `ai-writer`, `llm-chat` are members; `transcribe` and
254
+ * `qa-check` are not) — this is about media, not about every node with a value.
255
+ */
256
+ function isMediaProducingType(nodeType: string): boolean {
257
+ if (MEDIA_PRODUCING_TYPES.has(nodeType)) return true
258
+ const output = getOutputType(nodeType)
259
+ return output === "image" || output === "video" || output === "audio"
260
+ }
261
+
210
262
  /** Extract the result URL or text from a node's data. */
211
263
  export function getNodeResult(
212
264
  nodeData: Record<string, unknown>,