@nodaro/shared 2.24.0 → 2.27.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,205 @@
1
+ import { describe, it, expect } from "vitest"
2
+
3
+ import {
4
+ STUDIO_SHOT_TRANSIENT_KEYS,
5
+ STUDIO_TRANSIENT_KEYS,
6
+ stripStudioTransientSettings,
7
+ } from "../studio-transient.js"
8
+
9
+ /**
10
+ * The public projection of `settings.studio` (D12).
11
+ *
12
+ * A shared production is read by anyone holding the link, and the document
13
+ * carries the OWNER's working state beside the film: the recycle bin (every
14
+ * shot, still and clip they deleted, prompts and urls intact), the jobs in
15
+ * flight, and an unsaved editor draft.
16
+ *
17
+ * The fixture below is a real saved document, pinned here byte for byte, and
18
+ * the LEVEL is the whole finding this test exists for: the in-flight markers
19
+ * are written PER SHOT, inside `settings.studio.shots[]`, and a strip that only
20
+ * walks the top level hands a viewer all of them while a fixture shaped to the
21
+ * top level says it does not.
22
+ */
23
+
24
+ const PENDING = {
25
+ jobId: "job-2",
26
+ provider: "seedance-2",
27
+ prompt: "a slow dolly in",
28
+ startedAt: 1_756_000_000_000,
29
+ }
30
+
31
+ const TRASHED = {
32
+ kind: "still",
33
+ id: "trash-1",
34
+ shotId: "s1",
35
+ index: 0,
36
+ deletedAt: "2026-09-01T10:00:00.000Z",
37
+ stillBase: { nodeId: "img-1", provider: "flux-2", prompt: "a lighthouse at dawn" },
38
+ result: { url: "https://cdn/deleted.png" },
39
+ }
40
+
41
+ /** A saved document carrying every marker its writer can write. */
42
+ function written(): Record<string, unknown> {
43
+ return {
44
+ studio: {
45
+ version: 3,
46
+ shots: [
47
+ {
48
+ id: "s1",
49
+ imageNodeId: "img-1",
50
+ stillProvider: "flux-2",
51
+ pendingClips: [{ ...PENDING }],
52
+ },
53
+ ],
54
+ selectedShotId: "s1",
55
+ shotOrder: ["img-1"],
56
+ shared: true,
57
+ freecutDraftUrl: "https://cdn/draft.json",
58
+ trash: [{ ...TRASHED }],
59
+ },
60
+ }
61
+ }
62
+
63
+ /** `settings.studio` of a stripped document. */
64
+ function studioOf(settings: unknown): Record<string, unknown> {
65
+ return (settings as { studio: Record<string, unknown> }).studio
66
+ }
67
+
68
+ describe("stripStudioTransientSettings", () => {
69
+ it("drops the bin and the draft the writer put at the top level", () => {
70
+ const settings = written()
71
+ // The oracle: the document really does carry these two here.
72
+ expect(studioOf(settings).trash).toHaveLength(1)
73
+ expect(studioOf(settings).freecutDraftUrl).toBe("https://cdn/draft.json")
74
+
75
+ const studio = studioOf(stripStudioTransientSettings(settings))
76
+ expect(studio.trash).toBeUndefined()
77
+ expect(studio.freecutDraftUrl).toBeUndefined()
78
+ })
79
+
80
+ it("drops the in-flight markers the writer put PER SHOT", () => {
81
+ const settings = written()
82
+ // The oracle again, and the whole point: `pendingClips` is a SHOT's key.
83
+ const stored = (studioOf(settings).shots as Array<Record<string, unknown>>)[0]
84
+ expect(stored.pendingClips).toEqual([PENDING])
85
+
86
+ const shots = studioOf(stripStudioTransientSettings(settings)).shots as Array<
87
+ Record<string, unknown>
88
+ >
89
+ expect(shots[0].pendingClips).toBeUndefined()
90
+ // ...and the shot itself survives, film intact.
91
+ expect(shots[0].id).toBe("s1")
92
+ expect(shots[0].imageNodeId).toBe("img-1")
93
+ })
94
+
95
+ it("leaves the film — the shots, the order, the share flag — untouched", () => {
96
+ const studio = studioOf(stripStudioTransientSettings(written()))
97
+ expect(studio.version).toBe(3)
98
+ expect(studio.shotOrder).toEqual(["img-1"])
99
+ expect(studio.shared).toBe(true)
100
+ expect(studio.selectedShotId).toBe("s1")
101
+ })
102
+
103
+ it("never mutates the caller's document", () => {
104
+ const settings = written()
105
+ const before = JSON.stringify(settings)
106
+ stripStudioTransientSettings(settings)
107
+ expect(JSON.stringify(settings)).toBe(before)
108
+ })
109
+
110
+ it("hands back the very same object when there is nothing to strip", () => {
111
+ // An ordinary share read of an ordinary production allocates nothing.
112
+ const settings: Record<string, unknown> = {
113
+ studio: { version: 3, shots: [{ id: "s1" }], selectedShotId: "s1", shotOrder: [] },
114
+ }
115
+ expect(stripStudioTransientSettings(settings)).toBe(settings)
116
+ })
117
+
118
+ it("drops a shot's pendingStills — the still marker D5 lands there", () => {
119
+ // `pendingStills` is additive: the generation routes write it onto the same
120
+ // shot entry `pendingClips` rides on, so the strip has to know the key
121
+ // before its writer exists — otherwise the first framing batch in flight
122
+ // ships to every share viewer.
123
+ const settings = written()
124
+ const studio = studioOf(settings)
125
+ const shots = (studio.shots as Array<Record<string, unknown>>).map((s) => ({
126
+ ...s,
127
+ pendingStills: [{ jobId: "job-1", batchId: "batch-1", count: 4 }],
128
+ }))
129
+ const withStills = { ...settings, studio: { ...studio, shots } }
130
+
131
+ const out = studioOf(stripStudioTransientSettings(withStills))
132
+ expect((out.shots as Array<Record<string, unknown>>)[0].pendingStills).toBeUndefined()
133
+ })
134
+
135
+ it("drops a legacy single `pendingClip` too", () => {
136
+ // Pre-concurrent-markers saves wrote one marker under the singular key; the
137
+ // reader still migrates it, so it is still in-flight state a viewer must
138
+ // not receive.
139
+ const settings = written()
140
+ const studio = studioOf(settings)
141
+ const shots = [{ id: "s2", pendingClip: PENDING }]
142
+ const legacy = { ...settings, studio: { ...studio, shots } }
143
+
144
+ const out = studioOf(stripStudioTransientSettings(legacy))
145
+ expect((out.shots as Array<Record<string, unknown>>)[0]).toEqual({ id: "s2" })
146
+ })
147
+
148
+ it("leaves a workflow that is not a production alone", () => {
149
+ const settings = { presentationSettings: { shareReadOnly: true } }
150
+ expect(stripStudioTransientSettings(settings)).toBe(settings)
151
+ expect(stripStudioTransientSettings(null)).toBeNull()
152
+ expect(stripStudioTransientSettings(undefined)).toBeUndefined()
153
+ })
154
+
155
+ it("survives a document whose shots are not what the editor writes", () => {
156
+ // The strip runs on whatever is in the column, including a row written by
157
+ // something that is not the studio editor. It must project, never throw.
158
+ const odd = { studio: { version: 3, shots: ["nonsense", null, 7] } }
159
+ expect(() => stripStudioTransientSettings(odd)).not.toThrow()
160
+ expect(studioOf(stripStudioTransientSettings(odd)).shots).toEqual(["nonsense", null, 7])
161
+ })
162
+
163
+ it("drops the DOCUMENT's own pendingMusic and pendingDraft (D5)", () => {
164
+ // The document-level twin of the per-shot pair: a soundtrack render and a
165
+ // Director run in flight. Same rule — they name jobs on the owner's
166
+ // account, and no viewer can read or land one.
167
+ const settings = written()
168
+ const studio = studioOf(settings)
169
+ const inFlight = {
170
+ ...settings,
171
+ studio: {
172
+ ...studio,
173
+ pendingMusic: { jobId: "job-3", startedAt: 1_756_000_000_000 },
174
+ pendingDraft: { jobId: "job-4", startedAt: 1_756_000_000_000 },
175
+ },
176
+ }
177
+ // The oracle: the document really does carry both before the public read.
178
+ expect(studioOf(inFlight).pendingMusic).toBeDefined()
179
+ expect(studioOf(inFlight).pendingDraft).toBeDefined()
180
+
181
+ const out = studioOf(stripStudioTransientSettings(inFlight))
182
+ expect(out.pendingMusic).toBeUndefined()
183
+ expect(out.pendingDraft).toBeUndefined()
184
+ // ...and the film is still there.
185
+ expect(out.version).toBe(3)
186
+ })
187
+
188
+ it("pins the two lists — a key added to the type alone strips nothing", () => {
189
+ // The lists are the contract: the codec's own strip re-exports them, so a
190
+ // key that falls off here falls off there too, silently, on both sides.
191
+ expect([...STUDIO_TRANSIENT_KEYS]).toEqual([
192
+ "trash",
193
+ "pendingStills",
194
+ "pendingClips",
195
+ "pendingMusic",
196
+ "pendingDraft",
197
+ "freecutDraftUrl",
198
+ ])
199
+ expect([...STUDIO_SHOT_TRANSIENT_KEYS]).toEqual([
200
+ "pendingClips",
201
+ "pendingClip",
202
+ "pendingStills",
203
+ ])
204
+ })
205
+ })
package/src/index.ts CHANGED
@@ -1052,11 +1052,15 @@ export {
1052
1052
  export { ENTITY_NODE_KINDS } from "./entity-node-fields.js"
1053
1053
  export type { EntityNodeKind } from "./entity-node-fields.js"
1054
1054
 
1055
- // --- wire contract of /v1/studio/productions — types only ---
1056
- // The studio production CODEC (the reader/writer of `settings.studio`, the
1057
- // plan format, the catalogs and the reducers) lives in the FSL-licensed
1058
- // `@nodaro/studio-production`. What crosses into this Apache package is the
1059
- // envelope those routes return and the bodies they take, so the SDK can be
1060
- // typed against it — the document's own sub-objects stay named JSON here and
1061
- // are narrowed, and pinned, by the package that owns them.
1062
- export type * from "./studio-production-wire.js"
1055
+ // --- Scene3D previsualization (v1): the frozen wire contract shared by the
1056
+ // authoring LLM jobs, the Three.js/Remotion renderer, the canvas and the
1057
+ // SDK/MCP surface. Structure only — no prompts, no pricing. ---
1058
+ export * from "./scene3d.js"
1059
+ export * from "./scene3d-edit.js"
1060
+
1061
+ // --- transient studio keys — the public share read strips them ---
1062
+ export {
1063
+ STUDIO_TRANSIENT_KEYS,
1064
+ STUDIO_SHOT_TRANSIENT_KEYS,
1065
+ stripStudioTransientSettings,
1066
+ } from "./studio-transient.js"
package/src/llm-models.ts CHANGED
@@ -642,6 +642,12 @@ export type LlmFeature =
642
642
  | "motion-graphics-lottie"
643
643
  | "lottie-overlay"
644
644
  | "3d-title"
645
+ // Scene3D previz authoring — generate-3d-scene AND edit-3d-scene share one
646
+ // feature: both send the model a scene (the edit sends the WHOLE plan in) and
647
+ // both get back structured geometry, so the token profile is the same shape.
648
+ // The deterministic edit lane never reaches an LLM and bills the separate
649
+ // zero-cost `3d-scene-ops` identifier instead.
650
+ | "3d-scene"
645
651
  | "image-to-text"
646
652
  | "describe-to-picker"
647
653
  | "qa-check"
@@ -672,6 +678,7 @@ export const LLM_FEATURE_DEFAULTS: Record<LlmFeature, string> = {
672
678
  "motion-graphics-lottie": "claude-sonnet-4.6",
673
679
  "lottie-overlay": "claude-sonnet-4.6",
674
680
  "3d-title": "claude-sonnet-4.6",
681
+ "3d-scene": "claude-sonnet-4.6",
675
682
  "image-to-text": "claude-sonnet-4.6",
676
683
  "describe-to-picker": "claude-opus-5",
677
684
  "qa-check": "gemini-3.6-flash",
@@ -849,6 +856,9 @@ export const LLM_ROUTE_DEFAULTS: Record<string, LlmRouteDefaults> = {
849
856
  "motion-graphics": { temperature: 0.3, maxTokens: 2048, structuredOutput: true },
850
857
  "motion-graphics-lottie": { temperature: 0.3, maxTokens: 8192, structuredOutput: true },
851
858
  "3d-title": { temperature: 0.4, maxTokens: 3072, structuredOutput: true },
859
+ // A 100-object plan with keyframe tracks is the biggest structured payload
860
+ // any composer feature emits — 3072 (3d-title's cap) truncates it mid-array.
861
+ "3d-scene": { temperature: 0.3, maxTokens: 8192, structuredOutput: true },
852
862
  }
853
863
 
854
864
  /** Route defaults for a feature; `{}` for an unknown one. */
@@ -2632,6 +2632,11 @@ export const COMPOSER_PLAN_MAP: Readonly<Record<string, { planType: string; plan
2632
2632
  "3d-title": { planType: "3d-title", planField: "titlePlan" },
2633
2633
  "motion-graphics": { planType: "motion-graphics", planField: "motionPlan" },
2634
2634
  "composite": { planType: "composite", planField: "compositePlan" },
2635
+ // Scene3D previz (v1) — both the generator and the editor emit the SAME
2636
+ // validated `Scene3DPlan` revision on their `composition` handle, so
2637
+ // render-video routes either one to the `3d-scene` renderer unchanged.
2638
+ "generate-3d-scene": { planType: "3d-scene", planField: "scenePlan" },
2639
+ "edit-3d-scene": { planType: "3d-scene", planField: "scenePlan" },
2635
2640
  }
2636
2641
 
2637
2642
  /** Every composer plan-field name, derived from COMPOSER_PLAN_MAP (single source
@@ -44,6 +44,8 @@ export const NODE_MAPPABLE_FIELDS: Readonly<Record<string, readonly string[]>> =
44
44
  "after-effects": ["effectPrompt"],
45
45
  "lottie-overlay": ["overlayPrompt"],
46
46
  "3d-title": ["titlePrompt"],
47
+ "generate-3d-scene": ["scenePrompt"],
48
+ "edit-3d-scene": ["editPrompt"],
47
49
  "motion-graphics": ["motionPrompt"],
48
50
  "generate-script": ["styleGuide"],
49
51
  "speech-to-video": ["prompt", "negativePrompt"],
@@ -25,13 +25,19 @@ import { PROMPT_PREFIX_KEY, PROMPT_SUFFIX_KEY } from "./prompt-affixes.js"
25
25
  * blob (~tens of KB) + a stale url, re-injecting them on every apply and
26
26
  * bloating the preset row. These keys are in the capture-exclusion set below.
27
27
  */
28
- export const PRESET_APPLY_CLEAR_KEYS: readonly string[] = [...COMPOSER_PLAN_FIELDS, "lottieUrl"]
28
+ export const PRESET_APPLY_CLEAR_KEYS: readonly string[] = [
29
+ ...COMPOSER_PLAN_FIELDS, "lottieUrl",
30
+ // Scene revision history contains old plans and their reference/prompt context.
31
+ // Preserve it in workflows, never capture or resurrect it through a preset.
32
+ "sceneHistory", "scenePendingPlan", "sceneJobBaseRevisionId", "expectedRevisionId",
33
+ "changeSummary", "selectedObjectIds", "lockedObjectIds", "referenceObjectIds", "referenceRoles",
34
+ ]
29
35
 
30
36
  /**
31
37
  * The three keys that carry a preset's PROMPT CONTENT: the prompt itself plus the pre/post text
32
38
  * wrapped around it at run time. A preset "owns prompt content" iff its data defines any of them.
33
39
  */
34
- const PROMPT_CONTENT_KEYS: readonly string[] = ["prompt", PROMPT_PREFIX_KEY, PROMPT_SUFFIX_KEY]
40
+ const PROMPT_CONTENT_KEYS: readonly string[] = ["prompt", "scenePrompt", "editPrompt", PROMPT_PREFIX_KEY, PROMPT_SUFFIX_KEY]
35
41
 
36
42
  /**
37
43
  * The keys applying THIS preset must clear on the node: always `PRESET_APPLY_CLEAR_KEYS`, plus
@@ -0,0 +1,311 @@
1
+ /**
2
+ * Scene3D edit operations — the ONLY way a Scene3D plan changes.
3
+ *
4
+ * Split out of `scene3d.ts` (which owns the shape) because this file owns the
5
+ * TRANSITION: given a plan, a list of operations and the caller's locks, it
6
+ * produces the next immutable revision or an explained refusal. Both edit
7
+ * lanes go through it — the deterministic one (the caller sent operations) and
8
+ * the instruction one (an LLM authored the operations from a sentence) — so
9
+ * locks, staleness and whole-scene validation cannot be enforced twice and
10
+ * differently. The model never writes a plan and never writes code; it writes
11
+ * operations that this function is free to refuse.
12
+ */
13
+ import { z } from "zod"
14
+ import {
15
+ SCENE3D_LIMITS,
16
+ newScene3DRevisionId,
17
+ rotationVec3Schema,
18
+ scaleVec3Schema,
19
+ scene3DCameraKeyframeSchema,
20
+ scene3DColorSchema,
21
+ scene3DDeepEqual,
22
+ scene3DIdSchema,
23
+ scene3DObjectKeyframeSchema,
24
+ scene3DObjectSchema,
25
+ scene3DPlanSchema,
26
+ scene3DPrimitiveSchema,
27
+ sizeVec3Schema,
28
+ vec3Schema,
29
+ type Scene3DObject,
30
+ type Scene3DPlan,
31
+ } from "./scene3d.js"
32
+
33
+
34
+ /** Everything about an object EXCEPT its identity. `id` is deliberately absent
35
+ * (and the schema is strict) so no operation can rename an object out from
36
+ * under a lock, a parent link or a reference. */
37
+ export const scene3DObjectChangesSchema = z
38
+ .object({
39
+ name: z.string().min(1).max(SCENE3D_LIMITS.maxNameLength).optional(),
40
+ primitive: scene3DPrimitiveSchema.optional(),
41
+ /** `null` detaches from the parent; omitted leaves it as-is. */
42
+ parentId: scene3DIdSchema.nullable().optional(),
43
+ dimensions: sizeVec3Schema.optional(),
44
+ position: vec3Schema.optional(),
45
+ rotation: rotationVec3Schema.optional(),
46
+ scale: scaleVec3Schema.optional(),
47
+ color: scene3DColorSchema.optional(),
48
+ keyframes: z.array(scene3DObjectKeyframeSchema).max(SCENE3D_LIMITS.maxKeyframes).optional(),
49
+ })
50
+ .strict()
51
+
52
+ export const scene3DCameraChangesSchema = z
53
+ .object({
54
+ position: vec3Schema.optional(),
55
+ target: vec3Schema.optional(),
56
+ focalLengthMm: z
57
+ .number()
58
+ .min(SCENE3D_LIMITS.minFocalLengthMm)
59
+ .max(SCENE3D_LIMITS.maxFocalLengthMm)
60
+ .optional(),
61
+ sensorWidthMm: z
62
+ .number()
63
+ .min(SCENE3D_LIMITS.minSensorWidthMm)
64
+ .max(SCENE3D_LIMITS.maxSensorWidthMm)
65
+ .optional(),
66
+ keyframes: z.array(scene3DCameraKeyframeSchema).max(SCENE3D_LIMITS.maxKeyframes).optional(),
67
+ })
68
+ .strict()
69
+
70
+ export const scene3DLightingChangesSchema = z
71
+ .object({
72
+ ambientIntensity: z.number().min(0).max(SCENE3D_LIMITS.maxIntensity).optional(),
73
+ keyIntensity: z.number().min(0).max(SCENE3D_LIMITS.maxIntensity).optional(),
74
+ keyPosition: vec3Schema.optional(),
75
+ })
76
+ .strict()
77
+
78
+ export const scene3DEditOperationSchema = z.discriminatedUnion("op", [
79
+ z.object({ op: z.literal("set-object"), objectId: scene3DIdSchema, changes: scene3DObjectChangesSchema }).strict(),
80
+ z.object({ op: z.literal("add-object"), object: scene3DObjectSchema }).strict(),
81
+ z.object({ op: z.literal("remove-object"), objectId: scene3DIdSchema }).strict(),
82
+ z.object({ op: z.literal("set-camera"), changes: scene3DCameraChangesSchema }).strict(),
83
+ z.object({ op: z.literal("set-lighting"), changes: scene3DLightingChangesSchema }).strict(),
84
+ z.object({ op: z.literal("set-background"), color: scene3DColorSchema }).strict(),
85
+ ])
86
+
87
+ export const scene3DEditOperationsSchema = z
88
+ .array(scene3DEditOperationSchema)
89
+ .min(1)
90
+ .max(SCENE3D_LIMITS.maxOperations)
91
+
92
+ export type Scene3DObjectChanges = z.infer<typeof scene3DObjectChangesSchema>
93
+ export type Scene3DCameraChanges = z.infer<typeof scene3DCameraChangesSchema>
94
+ export type Scene3DLightingChanges = z.infer<typeof scene3DLightingChangesSchema>
95
+ export type Scene3DEditOperation = z.infer<typeof scene3DEditOperationSchema>
96
+
97
+ export type Scene3DEditErrorCode =
98
+ /** `expectedRevisionId` did not match the plan handed in. */
99
+ | "stale_revision"
100
+ /** The operation list itself is malformed or over the cap. */
101
+ | "invalid_operations"
102
+ /** An operation targets an object that is not in the scene. */
103
+ | "unknown_object"
104
+ /** `add-object` collided with an existing id. */
105
+ | "duplicate_object"
106
+ /** An operation touched an id the caller declared locked. */
107
+ | "locked_object"
108
+ /** The plan handed in, or the plan the operations produced, is invalid. */
109
+ | "invalid_plan"
110
+
111
+ export interface Scene3DEditOptions {
112
+ /** Optimistic concurrency: reject unless the plan is still this revision. */
113
+ expectedRevisionId?: string
114
+ /** Object ids the caller declared untouchable. Enforced as a POST-condition
115
+ * (see `applyScene3DEditOperations`), which is what makes it total. */
116
+ lockedObjectIds?: readonly string[]
117
+ /** Pin the produced revision id — tests and deterministic replay only. */
118
+ revisionId?: string
119
+ }
120
+
121
+ export type Scene3DEditResult =
122
+ | { ok: true; plan: Scene3DPlan; changedObjectIds: string[]; changeSummary: string }
123
+ | { ok: false; code: Scene3DEditErrorCode; message: string; operationIndex?: number }
124
+
125
+ /** Structural clone that cannot share a reference with its input. `structured-
126
+ * Clone` is not available in every consumer runtime we ship to, and a plan is
127
+ * pure JSON by construction. */
128
+ function clonePlan(plan: Scene3DPlan): Scene3DPlan {
129
+ return JSON.parse(JSON.stringify(plan)) as Scene3DPlan
130
+ }
131
+
132
+ /** One human sentence per operation — the deterministic lane's answer to the
133
+ * LLM lane's `changeSummary`, so both edit paths return the same shape. */
134
+ export function summarizeScene3DOperations(operations: readonly Scene3DEditOperation[]): string {
135
+ const lines = operations.map((operation) => {
136
+ switch (operation.op) {
137
+ case "set-object": {
138
+ const fields = Object.keys(operation.changes)
139
+ return `Updated ${fields.length > 0 ? fields.join(", ") : "nothing"} on "${operation.objectId}"`
140
+ }
141
+ case "add-object":
142
+ return `Added ${operation.object.primitive} "${operation.object.name}" (${operation.object.id})`
143
+ case "remove-object":
144
+ return `Removed "${operation.objectId}"`
145
+ case "set-camera":
146
+ return `Updated camera ${Object.keys(operation.changes).join(", ") || "nothing"}`
147
+ case "set-lighting":
148
+ return `Updated lighting ${Object.keys(operation.changes).join(", ") || "nothing"}`
149
+ case "set-background":
150
+ return `Set background to ${operation.color}`
151
+ }
152
+ })
153
+ return lines.join("; ").slice(0, SCENE3D_LIMITS.maxChangeSummaryLength)
154
+ }
155
+
156
+ function firstIssueMessage(error: z.ZodError): string {
157
+ const issue = error.issues[0]
158
+ if (!issue) return "invalid"
159
+ const path = issue.path.join(".")
160
+ return path ? `${path}: ${issue.message}` : issue.message
161
+ }
162
+
163
+ /**
164
+ * Apply an operation list to a plan, producing a NEW revision.
165
+ *
166
+ * Guarantees, in this order — each one is a distinct failure mode that was
167
+ * cheap to get wrong:
168
+ *
169
+ * 1. The input plan is never mutated (deep clone before the first write).
170
+ * 2. A stale `expectedRevisionId` is refused before anything is applied, so a
171
+ * late async completion can never overwrite a newer manual edit.
172
+ * 3. Operations are schema-validated as a list; the failing INDEX is reported.
173
+ * 4. Locks are enforced as a POST-CONDITION — every locked object must still
174
+ * exist and be deep-equal to the original. Reasoning per-operation would
175
+ * have to anticipate remove + re-add, a reparent from a sibling's `set-
176
+ * object`, and whatever the next operation kind turns out to be; the
177
+ * post-condition covers all of them by construction. (`selectedObjectIds`
178
+ * is CONTEXT for the model, never permission — the caller passes locks
179
+ * explicitly and they are checked here, after the model has spoken.)
180
+ * 5. The WHOLE resulting plan is re-validated, which is what makes "no silent
181
+ * orphaning" free: removing a parent leaves a dangling `parentId` and the
182
+ * plan validator rejects it, as does removing an object a reference points
183
+ * at.
184
+ */
185
+ export function applyScene3DEditOperations(
186
+ plan: Scene3DPlan,
187
+ operations: readonly Scene3DEditOperation[] | unknown,
188
+ options: Scene3DEditOptions = {},
189
+ ): Scene3DEditResult {
190
+ const parsedPlan = scene3DPlanSchema.safeParse(plan)
191
+ if (!parsedPlan.success) {
192
+ return { ok: false, code: "invalid_plan", message: `scenePlan is invalid — ${firstIssueMessage(parsedPlan.error)}` }
193
+ }
194
+ const source = parsedPlan.data as Scene3DPlan
195
+
196
+ if (options.expectedRevisionId !== undefined && options.expectedRevisionId !== source.revisionId) {
197
+ return {
198
+ ok: false,
199
+ code: "stale_revision",
200
+ message: `This scene has moved on — expected revision ${options.expectedRevisionId}, the plan is at ${source.revisionId}.`,
201
+ }
202
+ }
203
+
204
+ const parsedOps = scene3DEditOperationsSchema.safeParse(operations)
205
+ if (!parsedOps.success) {
206
+ const issue = parsedOps.error.issues[0]
207
+ const index = typeof issue?.path[0] === "number" ? (issue.path[0] as number) : undefined
208
+ return {
209
+ ok: false,
210
+ code: "invalid_operations",
211
+ message: `operations are invalid — ${firstIssueMessage(parsedOps.error)}`,
212
+ ...(index === undefined ? {} : { operationIndex: index }),
213
+ }
214
+ }
215
+ const ops = parsedOps.data
216
+
217
+ const next = clonePlan(source)
218
+ const changed = new Set<string>()
219
+
220
+ for (let index = 0; index < ops.length; index++) {
221
+ const operation = ops[index]
222
+ switch (operation.op) {
223
+ case "set-object": {
224
+ const target = next.objects.findIndex((o) => o.id === operation.objectId)
225
+ if (target === -1) {
226
+ return {
227
+ ok: false,
228
+ code: "unknown_object",
229
+ message: `no object "${operation.objectId}" in this scene`,
230
+ operationIndex: index,
231
+ }
232
+ }
233
+ const { parentId, ...rest } = operation.changes
234
+ const updated: Scene3DObject = { ...next.objects[target], ...rest }
235
+ if (parentId !== undefined) {
236
+ if (parentId === null) delete updated.parentId
237
+ else updated.parentId = parentId
238
+ }
239
+ next.objects = next.objects.map((o, i) => (i === target ? updated : o))
240
+ changed.add(operation.objectId)
241
+ break
242
+ }
243
+ case "add-object": {
244
+ if (next.objects.some((o) => o.id === operation.object.id)) {
245
+ return {
246
+ ok: false,
247
+ code: "duplicate_object",
248
+ message: `an object with id "${operation.object.id}" already exists`,
249
+ operationIndex: index,
250
+ }
251
+ }
252
+ next.objects = [...next.objects, operation.object as Scene3DObject]
253
+ changed.add(operation.object.id)
254
+ break
255
+ }
256
+ case "remove-object": {
257
+ if (!next.objects.some((o) => o.id === operation.objectId)) {
258
+ return {
259
+ ok: false,
260
+ code: "unknown_object",
261
+ message: `no object "${operation.objectId}" in this scene`,
262
+ operationIndex: index,
263
+ }
264
+ }
265
+ next.objects = next.objects.filter((o) => o.id !== operation.objectId)
266
+ changed.add(operation.objectId)
267
+ break
268
+ }
269
+ case "set-camera":
270
+ next.camera = { ...next.camera, ...operation.changes }
271
+ break
272
+ case "set-lighting":
273
+ next.lighting = { ...next.lighting, ...operation.changes }
274
+ break
275
+ case "set-background":
276
+ next.backgroundColor = operation.color
277
+ break
278
+ }
279
+ }
280
+
281
+ for (const lockedId of options.lockedObjectIds ?? []) {
282
+ const before = source.objects.find((o) => o.id === lockedId)
283
+ const after = next.objects.find((o) => o.id === lockedId)
284
+ if (before === undefined) continue // not in the scene to begin with — nothing to protect
285
+ if (after === undefined) {
286
+ return { ok: false, code: "locked_object", message: `object "${lockedId}" is locked and cannot be removed` }
287
+ }
288
+ if (!scene3DDeepEqual(before, after)) {
289
+ return { ok: false, code: "locked_object", message: `object "${lockedId}" is locked and cannot be modified` }
290
+ }
291
+ }
292
+
293
+ next.parentRevisionId = source.revisionId
294
+ next.revisionId = options.revisionId ?? newScene3DRevisionId()
295
+
296
+ const validated = scene3DPlanSchema.safeParse(next)
297
+ if (!validated.success) {
298
+ return {
299
+ ok: false,
300
+ code: "invalid_plan",
301
+ message: `the edit would leave the scene invalid — ${firstIssueMessage(validated.error)}`,
302
+ }
303
+ }
304
+
305
+ return {
306
+ ok: true,
307
+ plan: validated.data as Scene3DPlan,
308
+ changedObjectIds: [...changed],
309
+ changeSummary: summarizeScene3DOperations(ops),
310
+ }
311
+ }