@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.
@@ -0,0 +1,466 @@
1
+ /**
2
+ * The `pro-3d-render` ("3D Render Pro") WIRE CONTRACT.
3
+ *
4
+ * ONE durable operation, three ways in. A `source` says WHERE the scene comes
5
+ * from — a new brief, an existing revision, or a completed desktop export —
6
+ * and the settled job carries BOTH halves of the result: the exact composition
7
+ * (`scenePlan`) and the standard video field every downstream consumer already
8
+ * reads (`videoUrl`).
9
+ *
10
+ * The `source` is a strict discriminated union rather than a bag of optional
11
+ * fields, and that is the load-bearing decision here. "Prompt present" and
12
+ * "revisionId present" are not two settings on one request: they select
13
+ * different pipelines with different costs. A flat shape lets a caller send
14
+ * both, or neither, and pushes the "what did they actually mean" decision into
15
+ * whichever surface reads it last — which is how an existing scene silently
16
+ * becomes a paid re-authoring run.
17
+ *
18
+ * The same union is what makes RENDER-ONLY expressible: `{kind:'scene'}` with
19
+ * NO `editPrompt` means "export this revision", and its absence must survive
20
+ * every hop unchanged. Nothing may helpfully substitute an empty string or
21
+ * copy the node's brief into it — that converts a free export into an
22
+ * authoring run the user never asked for.
23
+ *
24
+ * What lives here is only what a client needs to CALL the operation, QUOTE it
25
+ * and READ its result. How the scene is planned, compiled, built, priced or
26
+ * authorized is not part of this contract and is not described here.
27
+ *
28
+ * Deliberately NOT here:
29
+ * - a model chooser. The planner is fixed and server-owned.
30
+ * - a credit number. The cost is resolved server-side and returned by the
31
+ * quote endpoint; a constant in a published package would be a wrong answer
32
+ * shipped to every consumer (see `PRO3D_RENDER_CREDIT_ID`).
33
+ */
34
+ import { z } from "zod"
35
+ import { SCENE3D_LIMITS, type Scene3DReference } from "./scene3d.js"
36
+ import { scene3DAnyPlanSchema, type Scene3DPlan } from "./scene3d-v2-plan.js"
37
+
38
+ /** Canvas/API/MCP node type. */
39
+ export const PRO3D_RENDER_NODE_TYPE = "pro-3d-render"
40
+
41
+ /** Display name. One string, so every surface spells it the same way. */
42
+ export const PRO3D_RENDER_LABEL = "3D Render Pro"
43
+
44
+ /**
45
+ * The credit identifier the operation settles under.
46
+ *
47
+ * An IDENTIFIER, not a price: the number is operator/deployment configuration
48
+ * (a `model_pricing` row), and the per-run ceiling comes from a quote. There is
49
+ * deliberately no fallback constant — a flat default would underprice an
50
+ * operation that plans, builds and renders, and "cheap by accident" is not a
51
+ * failure mode you notice from the outside.
52
+ */
53
+ export const PRO3D_RENDER_CREDIT_ID = "pro-3d-render"
54
+
55
+ // ---------------------------------------------------------------------------
56
+ // Vocabularies
57
+ // ---------------------------------------------------------------------------
58
+
59
+ /**
60
+ * Where the scene is built. `blender-local` is a paired desktop and is refused
61
+ * unless the deployment both enables it and has an engine advertising it — an
62
+ * unknown or unavailable engine is an error, never a downgrade to the cheaper
63
+ * cloud path.
64
+ */
65
+ export const PRO3D_RENDER_ENGINES = ["blender-cloud", "blender-local"] as const
66
+ export type Pro3DRenderEngine = (typeof PRO3D_RENDER_ENGINES)[number]
67
+ export const PRO3D_RENDER_DEFAULT_ENGINE: Pro3DRenderEngine = "blender-cloud"
68
+
69
+ /**
70
+ * Render quality profiles.
71
+ *
72
+ * One today. A surface must advertise only what the installed engine reports
73
+ * (`capabilities().pro.qualityProfiles`) rather than this list — offering a
74
+ * profile the engine cannot serve is a run that fails after the user chose it.
75
+ */
76
+ export const PRO3D_RENDER_QUALITY_PROFILES = ["standard"] as const
77
+ export type Pro3DRenderQuality = (typeof PRO3D_RENDER_QUALITY_PROFILES)[number]
78
+ export const PRO3D_RENDER_DEFAULT_QUALITY: Pro3DRenderQuality = "standard"
79
+
80
+ /** Material/lighting treatment. Clay is the movement-reference default. */
81
+ export const PRO3D_RENDER_STYLES = ["clay"] as const
82
+ export type Pro3DRenderStyle = (typeof PRO3D_RENDER_STYLES)[number]
83
+ export const PRO3D_RENDER_DEFAULT_STYLE: Pro3DRenderStyle = "clay"
84
+
85
+ /**
86
+ * The correction budget: how many repair passes the engine may spend after its
87
+ * first attempt. Displayed to the user because each pass is paid work.
88
+ */
89
+ export const PRO3D_RENDER_MIN_REPAIR_PASSES = 0
90
+ export const PRO3D_RENDER_MAX_REPAIR_PASSES = 2
91
+ export const PRO3D_RENDER_DEFAULT_REPAIR_PASSES = 2
92
+
93
+ /**
94
+ * Aspect ratios the node authors at.
95
+ *
96
+ * `21:9` is not decoration: the acceptance fixture is a 30-second 21:9 scene,
97
+ * so a set that omitted it could not express the case the feature is measured
98
+ * against. Its canonical pixel pair is the contract's explicitly supported
99
+ * 1680×720 (see `ASPECT_RATIO_DIMENSIONS`).
100
+ */
101
+ export const PRO3D_RENDER_ASPECT_RATIOS = ["16:9", "9:16", "1:1", "4:5", "21:9"] as const
102
+ export type Pro3DRenderAspectRatio = (typeof PRO3D_RENDER_ASPECT_RATIOS)[number]
103
+
104
+ /** Same prompt ceiling the Basic authoring routes enforce. */
105
+ export const PRO3D_RENDER_PROMPT_MAX = 8000
106
+
107
+ /**
108
+ * Request bounds shared by every ingress (HTTP route, orchestrator, MCP, SDK).
109
+ *
110
+ * Timing/reference limits reuse the Basic authoring limits verbatim rather
111
+ * than declaring a second set: the two nodes describe the same kind of scene,
112
+ * and two drifting ceilings is how one surface starts accepting what another
113
+ * refuses.
114
+ */
115
+ export const PRO3D_RENDER_LIMITS = {
116
+ promptMax: PRO3D_RENDER_PROMPT_MAX,
117
+ editPromptMax: PRO3D_RENDER_PROMPT_MAX,
118
+ minDurationSeconds: SCENE3D_LIMITS.minDurationSeconds,
119
+ maxDurationSeconds: SCENE3D_LIMITS.maxDurationSeconds,
120
+ minFps: SCENE3D_LIMITS.minFps,
121
+ maxFps: SCENE3D_LIMITS.maxFps,
122
+ maxReferences: SCENE3D_LIMITS.maxReferences,
123
+ /** Opaque ids the caller echoes back (quote, export, connection). */
124
+ maxIdLength: 200,
125
+ /** `Idempotency-Key` bounds — the platform's floor, with a ceiling so an
126
+ * unbounded header can never reach a lookup or a database column. */
127
+ minIdempotencyKeyLength: 8,
128
+ maxIdempotencyKeyLength: 255,
129
+ } as const
130
+
131
+ // ---------------------------------------------------------------------------
132
+ // The source union
133
+ // ---------------------------------------------------------------------------
134
+
135
+ export const PRO3D_RENDER_SOURCE_KINDS = ["prompt", "scene", "local-export"] as const
136
+ export type Pro3DRenderSourceKind = (typeof PRO3D_RENDER_SOURCE_KINDS)[number]
137
+
138
+ /** A new scene, authored from a brief plus optional image/video references. */
139
+ export interface Pro3DRenderPromptSource {
140
+ kind: "prompt"
141
+ prompt: string
142
+ references?: readonly Scene3DReference[]
143
+ }
144
+
145
+ /**
146
+ * An existing immutable revision.
147
+ *
148
+ * `editPrompt` ABSENT is the render-only path — export this revision, spend no
149
+ * authoring or build credits. Its absence is meaningful and must be preserved
150
+ * verbatim; an empty string is not the same request.
151
+ *
152
+ * Retained revisions are authorized through their current scene permissions.
153
+ * `sourceJobId` locates Basic scenes stored only in job history; it is required
154
+ * for that source, but optional for retained scenes (including manual edits).
155
+ */
156
+ export interface Pro3DRenderSceneSource {
157
+ kind: "scene"
158
+ revisionId: string
159
+ sourceJobId?: string
160
+ editPrompt?: string
161
+ }
162
+
163
+ /** A completed export from a paired desktop Blender. */
164
+ export interface Pro3DRenderLocalExportSource {
165
+ kind: "local-export"
166
+ exportId: string
167
+ connectionId: string
168
+ }
169
+
170
+ export type Pro3DRenderSource =
171
+ | Pro3DRenderPromptSource
172
+ | Pro3DRenderSceneSource
173
+ | Pro3DRenderLocalExportSource
174
+
175
+ /** True when this source exports an existing revision without re-authoring it. */
176
+ export function isPro3DRenderRenderOnly(source: Pro3DRenderSource): boolean {
177
+ return source.kind === "scene" && source.editPrompt === undefined
178
+ }
179
+
180
+ /**
181
+ * Which scene-schema version a source PRODUCES, or `null` when only the server
182
+ * can know.
183
+ *
184
+ * A `prompt` or `local-export` source always mints a fresh v2 manifest, so a
185
+ * client that cannot read v2 is refusable for free, before any work. A `scene`
186
+ * source inherits whatever version the named revision already is — the host
187
+ * does not resolve revisions, so demanding v2 there would refuse a perfectly
188
+ * renderable retained v1 scene.
189
+ */
190
+ export function pro3DRenderProducedSchemaVersion(source: Pro3DRenderSource): number | null {
191
+ return source.kind === "scene" ? null : 2
192
+ }
193
+
194
+ // ---------------------------------------------------------------------------
195
+ // Quote
196
+ // ---------------------------------------------------------------------------
197
+
198
+ /** One priced component of a quote. Display copy, not economics. */
199
+ export interface Pro3DRenderQuoteLine {
200
+ code: string
201
+ label: string
202
+ credits: number
203
+ }
204
+
205
+ /**
206
+ * The paired quote's answer.
207
+ *
208
+ * `maxCredits` is a CEILING, not a charge: quoting reserves nothing and spends
209
+ * nothing. `normalizedInputHash` is what run admission re-checks, so a body
210
+ * edited between quote and run is refused rather than executed at a price it
211
+ * was never quoted for.
212
+ */
213
+ export interface Pro3DRenderQuote {
214
+ quoteId: string
215
+ /** ISO-8601. After this the quote is stale and run answers "quote again". */
216
+ expiresAt: string
217
+ maxCredits: number
218
+ breakdown: Pro3DRenderQuoteLine[]
219
+ pricingVersion: string
220
+ capabilitiesVersion: string
221
+ normalizedInputHash: string
222
+ }
223
+
224
+ export const pro3DRenderQuoteSchema = z
225
+ .object({
226
+ quoteId: z.string().min(1),
227
+ expiresAt: z.string().min(1),
228
+ maxCredits: z.number(),
229
+ breakdown: z.array(
230
+ z.object({ code: z.string(), label: z.string(), credits: z.number() }).passthrough(),
231
+ ),
232
+ pricingVersion: z.string(),
233
+ capabilitiesVersion: z.string(),
234
+ normalizedInputHash: z.string().min(1),
235
+ })
236
+ .passthrough()
237
+
238
+ export function isPro3DRenderQuote(value: unknown): value is Pro3DRenderQuote {
239
+ return pro3DRenderQuoteSchema.safeParse(value).success
240
+ }
241
+
242
+ // ---------------------------------------------------------------------------
243
+ // Capabilities
244
+ // ---------------------------------------------------------------------------
245
+
246
+ /**
247
+ * What this deployment can actually serve.
248
+ *
249
+ * Every surface that offers a control reads it from here rather than from the
250
+ * vocabularies above: the constants say what the CONTRACT can express, this
251
+ * says what the INSTALLED engine will accept.
252
+ */
253
+ export interface Pro3DRenderCapabilities {
254
+ available: boolean
255
+ engines: Pro3DRenderEngine[]
256
+ qualityProfiles: Pro3DRenderQuality[]
257
+ styles: Pro3DRenderStyle[]
258
+ aspectRatios: Pro3DRenderAspectRatio[]
259
+ maxRepairPasses: number
260
+ }
261
+
262
+ // ---------------------------------------------------------------------------
263
+ // Result
264
+ // ---------------------------------------------------------------------------
265
+
266
+ export interface Pro3DRenderValidationWarning {
267
+ code: string
268
+ message: string
269
+ shotId?: string
270
+ }
271
+
272
+ export interface Pro3DRenderResultMetadata {
273
+ width: number
274
+ height: number
275
+ fps: number
276
+ frames: number
277
+ duration: number
278
+ }
279
+
280
+ /**
281
+ * The completed job's `output_data`.
282
+ *
283
+ * `videoUrl` is the platform's existing resolved-video field (the contract's
284
+ * `resultUrl` mapped onto the envelope this platform already has), so the node
285
+ * connects to every existing video consumer without a second video result type
286
+ * producer validators cannot parse. `scenePlan` + `sceneRevisionId` are the
287
+ * exact revision that video was rendered from, so a later render-only re-run
288
+ * costs no authoring.
289
+ *
290
+ * Everything else is what the spec requires a caller to be able to act on: the
291
+ * poster to show before playback, the validation report to read warnings from,
292
+ * the renderer/metadata to check the export against a downstream model's
293
+ * limits, and the optional source artifact to offer as a download.
294
+ */
295
+ export interface Pro3DRenderJobOutput {
296
+ videoUrl: string
297
+ scenePlan: Scene3DPlan
298
+ sceneRevisionId: string
299
+ posterAssetId: string
300
+ /** Present when an editable native source was retained for this revision. */
301
+ sourceArtifactId?: string
302
+ validation: {
303
+ status: "passed"
304
+ reportAssetId: string
305
+ warnings: Pro3DRenderValidationWarning[]
306
+ }
307
+ renderer: string
308
+ metadata: Pro3DRenderResultMetadata
309
+ /** Short, user-safe note about what this revision contains. Never diagnostics. */
310
+ changeSummary?: string
311
+ }
312
+
313
+ /**
314
+ * Reader-side schema.
315
+ *
316
+ * Passthrough on purpose: a job row may carry additive metadata a client of
317
+ * this version has never heard of, and refusing the whole result over an
318
+ * unknown key would turn an additive server change into a client outage.
319
+ *
320
+ * The required fields are required because the contract makes them so — this
321
+ * is what a COMPLETE result looks like. Nothing in the platform fabricates
322
+ * them to satisfy the schema; a runtime that has not produced them yet simply
323
+ * does not parse as complete, which is the honest answer.
324
+ */
325
+ export const pro3DRenderJobOutputSchema = z
326
+ .object({
327
+ videoUrl: z.string().min(1),
328
+ scenePlan: scene3DAnyPlanSchema,
329
+ sceneRevisionId: z.string().min(1),
330
+ posterAssetId: z.string().min(1),
331
+ sourceArtifactId: z.string().min(1).optional(),
332
+ validation: z
333
+ .object({
334
+ status: z.literal("passed"),
335
+ reportAssetId: z.string().min(1),
336
+ warnings: z.array(
337
+ z
338
+ .object({
339
+ code: z.string(),
340
+ message: z.string(),
341
+ shotId: z.string().optional(),
342
+ })
343
+ .passthrough(),
344
+ ),
345
+ })
346
+ .passthrough(),
347
+ renderer: z.string().min(1),
348
+ metadata: z
349
+ .object({
350
+ width: z.number().int().positive(),
351
+ height: z.number().int().positive(),
352
+ fps: z.number().positive(),
353
+ frames: z.number().int().positive(),
354
+ duration: z.number().positive(),
355
+ })
356
+ .passthrough(),
357
+ changeSummary: z.string().optional(),
358
+ })
359
+ .passthrough()
360
+
361
+ export function isPro3DRenderJobOutput(value: unknown): value is Pro3DRenderJobOutput {
362
+ return pro3DRenderJobOutputSchema.safeParse(value).success
363
+ }
364
+
365
+ /**
366
+ * The two fields every EXECUTION SURFACE must be able to resolve, whatever
367
+ * else a runtime does or does not attach yet.
368
+ *
369
+ * Separate from the full reader above on purpose: canvas wiring, the DAG
370
+ * extractors and the render-only re-run need "is there a video and a scene
371
+ * here", and gating those on complete metadata would blank a node over a
372
+ * missing poster id.
373
+ */
374
+ export const pro3DRenderCoreOutputSchema = z
375
+ .object({
376
+ videoUrl: z.string().min(1),
377
+ scenePlan: scene3DAnyPlanSchema,
378
+ })
379
+ .passthrough()
380
+
381
+ // ---------------------------------------------------------------------------
382
+ // Source construction, shared by every execution surface
383
+ // ---------------------------------------------------------------------------
384
+
385
+ /** What a canvas node / DAG builder holds before it can name a source. */
386
+ export interface Pro3DRenderSourceInput {
387
+ /** `"scene"` selects the existing-revision path; anything else is a brief. */
388
+ sourceMode?: string
389
+ /** The brief, already resolved and affix-applied by the caller. */
390
+ prompt?: string
391
+ references?: readonly Scene3DReference[]
392
+ /** The revision to export or edit, and the run that produced it. */
393
+ revisionId?: string
394
+ sourceJobId?: string
395
+ /** Absent/blank keeps the render-only path. */
396
+ editPrompt?: string
397
+ }
398
+
399
+ export type Pro3DRenderSourceResult =
400
+ | { ok: true; source: Pro3DRenderSource }
401
+ | { ok: false; message: string }
402
+
403
+ /**
404
+ * Turn node/DAG state into the wire `source`.
405
+ *
406
+ * Shared by BOTH execution engines because the alternative — one copy in the
407
+ * browser executor and one in the orchestrator — is the drift that lets a
408
+ * canvas run and a headless run of the same node mean different things. The
409
+ * refusals are part of that: a scene source missing its correlation must fail
410
+ * the same way on both.
411
+ *
412
+ * A blank `editPrompt` is treated as ABSENT, never as an empty instruction: a
413
+ * user who cleared the box asked for a plain export, and forwarding `""` would
414
+ * buy them an authoring pass.
415
+ */
416
+ export function buildPro3DRenderSource(input: Pro3DRenderSourceInput): Pro3DRenderSourceResult {
417
+ if (input.sourceMode === "scene") {
418
+ const revisionId = input.revisionId?.trim()
419
+ const sourceJobId = input.sourceJobId?.trim()
420
+ if (!revisionId) {
421
+ return { ok: false, message: "no scene to render — wire a 3D scene in, or run this node once." }
422
+ }
423
+ const editPrompt = input.editPrompt?.trim()
424
+ return {
425
+ ok: true,
426
+ source: { kind: "scene", revisionId, ...(sourceJobId ? { sourceJobId } : {}), ...(editPrompt ? { editPrompt } : {}) },
427
+ }
428
+ }
429
+ const prompt = input.prompt?.trim()
430
+ if (!prompt) {
431
+ return { ok: false, message: "no brief — describe the scene, or wire a prompt in." }
432
+ }
433
+ const references = input.references ?? []
434
+ return {
435
+ ok: true,
436
+ source: { kind: "prompt", prompt, ...(references.length > 0 ? { references } : {}) },
437
+ }
438
+ }
439
+
440
+ /**
441
+ * Which timing fields a request may carry.
442
+ *
443
+ * A `scene` source already HAS timing, and the contract forbids silently
444
+ * overriding it — so the node's own duration/fps/aspect are withheld unless
445
+ * the user explicitly asked to re-time, in which case they are sent and the
446
+ * engine decides whether the change is compatible. For a new scene the node's
447
+ * settings simply are the request.
448
+ *
449
+ * Returning an object with the keys omitted (rather than set to `undefined`)
450
+ * matters: these bodies are JSON-serialized, and an explicit `undefined` and a
451
+ * missing key are the same on the wire only by luck of the serializer.
452
+ */
453
+ export function pro3DRenderTimingOverrides(input: {
454
+ source: Pro3DRenderSource
455
+ overrideSourceTiming?: boolean
456
+ durationSeconds?: number
457
+ fps?: number
458
+ aspectRatio?: string
459
+ }): { durationSeconds?: number; fps?: number; aspectRatio?: string } {
460
+ if (input.source.kind === "scene" && !input.overrideSourceTiming) return {}
461
+ const out: { durationSeconds?: number; fps?: number; aspectRatio?: string } = {}
462
+ if (typeof input.durationSeconds === "number") out.durationSeconds = input.durationSeconds
463
+ if (typeof input.fps === "number") out.fps = input.fps
464
+ if (typeof input.aspectRatio === "string") out.aspectRatio = input.aspectRatio
465
+ return out
466
+ }
@@ -89,6 +89,11 @@ export const VIDEO_PRODUCER_TYPES: ReadonlySet<string> = new Set([
89
89
  // Emits generatedVideoUrl so it connects to any downstream video consumer
90
90
  // (e.g. a Seedance video-reference input) by an ordinary edge.
91
91
  "gif-to-video",
92
+ // 3D Render Pro: authors a scene AND exports it in one operation, settling
93
+ // with the standard `videoUrl` field. It is a video producer as much as it
94
+ // is a composition producer — omitting it here is the "cannot connect the
95
+ // outputs" bug, and its `composition` handle is typed separately.
96
+ "pro-3d-render",
92
97
  ])
93
98
 
94
99
  /**
@@ -0,0 +1,215 @@
1
+ /**
2
+ * WHICH authoring lane a Generate/Edit 3D Scene run goes down.
3
+ *
4
+ * There are two, and they are not interchangeable. Basic is the v1 LLM
5
+ * authoring path the platform has always had; Advanced is the installed
6
+ * private engine (`blender-cloud` / `blender-local`) that authors and edits
7
+ * schema-v2 scenes. The wire difference is one field — an `engine` on the body
8
+ * makes `POST /v1/3d-scene/{generate,edit}` hand the request to the private
9
+ * engine instead of the Basic guard.
10
+ *
11
+ * Deciding that is NOT a per-surface choice. The canvas executor and the
12
+ * headless orchestrator run the same nodes, and a node that means "Advanced"
13
+ * in the browser and "Basic" in a scheduled run is a scene authored by a
14
+ * different engine depending on who pressed Run. So the decision lives here,
15
+ * once, and both callers spread the SAME `fields` onto their request body.
16
+ *
17
+ * Three refusals, each of which used to be a silent wrong answer:
18
+ *
19
+ * - a **v2 plan on the Basic lane** is refused, never downgraded. Basic parses
20
+ * `scene3DPlanV1Schema`, so a v2 scene reaches it as a wall of Zod issues
21
+ * (the "Pro composition → Edit 3D Scene 400s" report) — and if it ever did
22
+ * parse, it would author from a scene it cannot represent.
23
+ * - an **explicitly requested engine this install does not have** is refused
24
+ * rather than quietly becoming Basic. Falling back would charge the user for
25
+ * a different pipeline than the one they picked.
26
+ * - an **unknown engine name** is refused before anything is spent.
27
+ */
28
+ import {
29
+ SCENE3D_SCHEMA_VERSION,
30
+ } from "./scene3d.js"
31
+ import {
32
+ SCENE3D_SCHEMA_VERSION_V2,
33
+ SCENE3D_SUPPORTED_SCHEMA_VERSIONS,
34
+ SCENE3D_V2_ENGINES,
35
+ type Scene3DKnownEngine,
36
+ } from "./scene3d-v2.js"
37
+ import { isKnownScene3DEngine, scene3DPlanSchemaVersion } from "./scene3d-v2-plan.js"
38
+
39
+ /** The value that names the Basic lane explicitly. Absent means the same. */
40
+ export const SCENE3D_BASIC_ENGINE = "basic"
41
+
42
+ /** Everything a caller may put in `engine` on a Generate/Edit request. */
43
+ export const SCENE3D_AUTHORING_ENGINES = [SCENE3D_BASIC_ENGINE, ...SCENE3D_V2_ENGINES] as const
44
+ export type Scene3DAuthoringEngine = (typeof SCENE3D_AUTHORING_ENGINES)[number]
45
+
46
+ /**
47
+ * The engine an Advanced run picks when nothing else names one.
48
+ *
49
+ * Hosted cloud, because that is the contract's default lane; `blender-local`
50
+ * is never inferred — it needs a paired desktop and its own deployment flag,
51
+ * so it is only ever used when it was explicitly asked for or when the scene
52
+ * under edit was authored by it and this install still offers it.
53
+ */
54
+ export const SCENE3D_DEFAULT_ADVANCED_ENGINE: Scene3DKnownEngine = "blender-cloud"
55
+
56
+ export function isScene3DAuthoringEngine(value: unknown): value is Scene3DAuthoringEngine {
57
+ return typeof value === "string" && (SCENE3D_AUTHORING_ENGINES as readonly string[]).includes(value)
58
+ }
59
+
60
+ export interface Scene3DEngineChoiceInput {
61
+ /** The node's/caller's explicit selection. `undefined` = "not chosen". */
62
+ requested?: string | null
63
+ /**
64
+ * The plan the run edits, for an edit. Omit for generate.
65
+ *
66
+ * The raw plan rather than a version number on purpose: the caller already
67
+ * holds it, and reading the version here is the ONE place the "v2 never goes
68
+ * to Basic" rule can be enforced for every surface at once.
69
+ */
70
+ plan?: unknown
71
+ /**
72
+ * Advanced engines this install can actually serve, from
73
+ * `GET /v1/3d-scene/capabilities`.
74
+ *
75
+ * `undefined` means NOT KNOWN (the headless orchestrator never asks, and the
76
+ * browser has not had the answer back yet) — which is different from "none".
77
+ * Unknown proceeds and lets the route refuse honestly with
78
+ * `SCENE_CAPABILITY_UNAVAILABLE`; a known-empty list refuses here, before a
79
+ * request that cannot succeed is sent.
80
+ */
81
+ availableEngines?: readonly string[] | undefined
82
+ }
83
+
84
+ /** The extra body fields an Advanced request carries. Empty on Basic, so the
85
+ * Basic request stays byte-identical to what it has always been. */
86
+ export interface Scene3DEngineRequestFields {
87
+ engine?: Scene3DKnownEngine
88
+ /**
89
+ * Which scene schema versions the CALLER can read back.
90
+ *
91
+ * Contract §5: an advanced authoring request declares this so the engine
92
+ * never answers with a revision the caller cannot render. Both of our
93
+ * surfaces read v1 and v2, so both send the same list.
94
+ */
95
+ acceptedSceneSchemaVersions?: number[]
96
+ }
97
+
98
+ export type Scene3DEngineChoiceRefusalCode =
99
+ /** The name is not an engine this contract knows. */
100
+ | "unknown_engine"
101
+ /** Explicitly asked for an engine this install does not serve. */
102
+ | "engine_unavailable"
103
+ /** A v2 scene was pointed at the Basic lane. */
104
+ | "schema_requires_advanced"
105
+ /** The scene claims a version nothing here can author against. */
106
+ | "unsupported_schema_version"
107
+ /** v2 scene, and no Advanced engine installed at all. */
108
+ | "advanced_unavailable"
109
+
110
+ export type Scene3DEngineChoice =
111
+ | { ok: true; lane: "basic"; engine: undefined; fields: Scene3DEngineRequestFields }
112
+ | { ok: true; lane: "advanced"; engine: Scene3DKnownEngine; fields: Scene3DEngineRequestFields }
113
+ | { ok: false; code: Scene3DEngineChoiceRefusalCode; message: string }
114
+
115
+ function advancedFields(engine: Scene3DKnownEngine): Scene3DEngineRequestFields {
116
+ return { engine, acceptedSceneSchemaVersions: [...SCENE3D_SUPPORTED_SCHEMA_VERSIONS] }
117
+ }
118
+
119
+ /** `undefined` (unknown) is permissive; a known list is authoritative. */
120
+ function serves(available: readonly string[] | undefined, engine: string): boolean {
121
+ return available === undefined || available.includes(engine)
122
+ }
123
+
124
+ /** The engine recorded on a v2 plan's provenance, when it is one we know. */
125
+ function planAuthoringEngine(plan: unknown): Scene3DKnownEngine | undefined {
126
+ const provenance = (plan as { provenance?: { engine?: unknown } } | null | undefined)?.provenance
127
+ const engine = provenance?.engine
128
+ return typeof engine === "string" && isKnownScene3DEngine(engine) ? engine : undefined
129
+ }
130
+
131
+ /**
132
+ * Resolve the lane, or refuse with a sentence the user can act on.
133
+ *
134
+ * Pure and synchronous: every caller already holds the three inputs, and the
135
+ * answer must be identical on the canvas and in the orchestrator.
136
+ */
137
+ export function resolveScene3DAuthoringEngine(input: Scene3DEngineChoiceInput): Scene3DEngineChoice {
138
+ const requested = typeof input.requested === "string" && input.requested.trim() !== ""
139
+ ? input.requested.trim()
140
+ : undefined
141
+ if (requested !== undefined && !isScene3DAuthoringEngine(requested)) {
142
+ return {
143
+ ok: false,
144
+ code: "unknown_engine",
145
+ message: `"${requested}" is not a 3D authoring engine — choose Basic, or an advanced engine this install offers.`,
146
+ }
147
+ }
148
+
149
+ // `plan` absent = generate. `null` version = not a Scene3D plan at all, which
150
+ // is the caller's own error to report (they need the plan for other reasons
151
+ // too); this resolver only speaks to versions it can read.
152
+ const version = input.plan === undefined ? undefined : scene3DPlanSchemaVersion(input.plan)
153
+ if (version !== undefined && version !== null && !(SCENE3D_SUPPORTED_SCHEMA_VERSIONS as readonly number[]).includes(version)) {
154
+ return {
155
+ ok: false,
156
+ code: "unsupported_schema_version",
157
+ message: `This scene uses schema version ${version}, which this version of Nodaro cannot edit.`,
158
+ }
159
+ }
160
+ const isV2 = version === SCENE3D_SCHEMA_VERSION_V2
161
+
162
+ if (isV2) {
163
+ if (requested === SCENE3D_BASIC_ENGINE) {
164
+ return {
165
+ ok: false,
166
+ code: "schema_requires_advanced",
167
+ message:
168
+ "This scene was authored by an advanced engine (schema v2) and cannot be edited on the Basic engine — switch this node's engine to the advanced one.",
169
+ }
170
+ }
171
+ // An EXPLICIT choice is never silently swapped for another engine — it is
172
+ // refused, exactly like an explicit unavailable engine on a v1 scene.
173
+ if (requested !== undefined) {
174
+ return serves(input.availableEngines, requested)
175
+ ? { ok: true, lane: "advanced", engine: requested, fields: advancedFields(requested) }
176
+ : unavailable(requested)
177
+ }
178
+ // Nothing was picked. Preference order: the engine that AUTHORED the scene
179
+ // (an edit stays on its own engine unless told otherwise), then the hosted
180
+ // default.
181
+ const preferred: Scene3DKnownEngine[] = []
182
+ const authored = planAuthoringEngine(input.plan)
183
+ if (authored) preferred.push(authored)
184
+ if (!preferred.includes(SCENE3D_DEFAULT_ADVANCED_ENGINE)) preferred.push(SCENE3D_DEFAULT_ADVANCED_ENGINE)
185
+ const engine = preferred.find((candidate) => serves(input.availableEngines, candidate))
186
+ if (!engine) {
187
+ return {
188
+ ok: false,
189
+ code: "advanced_unavailable",
190
+ message:
191
+ "This scene needs an advanced 3D engine to edit, and this install does not have one available.",
192
+ }
193
+ }
194
+ return { ok: true, lane: "advanced", engine, fields: advancedFields(engine) }
195
+ }
196
+
197
+ // v1, or a generate with no plan at all.
198
+ if (requested === undefined || requested === SCENE3D_BASIC_ENGINE) {
199
+ return { ok: true, lane: "basic", engine: undefined, fields: {} }
200
+ }
201
+ const engine = requested as Scene3DKnownEngine
202
+ if (!serves(input.availableEngines, engine)) return unavailable(engine)
203
+ return { ok: true, lane: "advanced", engine, fields: advancedFields(engine) }
204
+ }
205
+
206
+ function unavailable(engine: string): Scene3DEngineChoice {
207
+ return {
208
+ ok: false,
209
+ code: "engine_unavailable",
210
+ message: `The "${engine}" 3D authoring engine is not available on this install.`,
211
+ }
212
+ }
213
+
214
+ /** v1's version constant, re-exported for callers narrowing a plan by hand. */
215
+ export const SCENE3D_BASIC_SCHEMA_VERSION = SCENE3D_SCHEMA_VERSION