@deepblend/dsh-blender-contracts 0.1.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,947 @@
1
+ /**
2
+ * ScenePatch v1 — validation, and the pure function that applies one.
3
+ *
4
+ * `applyPatchToSpec` MUTATES NOTHING. It copies the parts of the spec a patch
5
+ * touches and returns a new document. That single property is what makes the
6
+ * rest of the revision machinery trustworthy: if validation, compilation or
7
+ * rendering fails at any later point, the previous revision's in-memory spec was
8
+ * never modified, so "失败不污染当前 Revision" (SPEC §13.2) is a structural
9
+ * guarantee rather than something the error path has to remember to undo.
10
+ *
11
+ * Operations are applied in order and atomically: the FIRST failing operation
12
+ * aborts the whole patch and the caller receives an error, never a partial
13
+ * result. A patch that half-applied would be worse than one that failed, because
14
+ * the failure would be invisible in the resulting scene.
15
+ *
16
+ * Owner: DeepBlend Studio — M1
17
+ */
18
+
19
+ import { compileSchema, formatIssues } from './json-schema.js'
20
+ import { collectionForKind, sceneSpecDigest, specHash } from './scene-spec.js'
21
+ import scenePatchSchema from './schemas/scene-patch.schema.json' with { type: 'json' }
22
+
23
+ const validatePatchStructure = compileSchema(scenePatchSchema, { id: 'scene-patch.schema.json' })
24
+
25
+ /** Operation names in the order they are documented, for stable tool output. */
26
+ export const SCENE_OPERATION_NAMES = Object.freeze([
27
+ 'entity.transform.update',
28
+ 'entity.visibility.set',
29
+ 'entity.tags.set',
30
+ 'entity.add',
31
+ 'entity.remove',
32
+ 'entity.material.set',
33
+ 'material.add',
34
+ 'material.parameter.update',
35
+ 'light.add',
36
+ 'light.update',
37
+ 'light.remove',
38
+ 'camera.add',
39
+ 'camera.update',
40
+ 'camera.remove',
41
+ 'animation.track.set',
42
+ 'animation.track.remove',
43
+ 'shot.set',
44
+ 'shot.remove',
45
+ 'project.frameRange.set',
46
+ 'render.profile.set',
47
+ 'world.set',
48
+ 'asset.add',
49
+ 'asset.remove',
50
+ // APPENDED, like the two above and for the same reason: the twenty-one before them are quoted in this order
51
+ // by the manual and by `blender_scene_patch`'s own description, so inserting a name in the middle would
52
+ // renumber every index the checks below assert. This one arrived with the operation itself — the schema
53
+ // accepted `material.texture.set` while this list (and therefore the manual and the tool's description) still
54
+ // had 23 names, which `scene-patch.test.mjs`'s schema-vs-constant check is the only check able to see.
55
+ 'material.texture.set',
56
+ ])
57
+
58
+ /** The `id` grammar shared with SceneSpec. */const ID_PATTERN = /^[a-zA-Z][a-zA-Z0-9._-]*$/
59
+
60
+ /**
61
+ * @typedef {object} PatchIssue
62
+ * @property {string} code
63
+ * @property {string} path
64
+ * @property {string} message
65
+ */
66
+
67
+ /**
68
+ * Structural validation of a patch document on its own.
69
+ *
70
+ * This is the cheap check the tool layer runs BEFORE touching a project: a
71
+ * syntactically broken patch should be rejected without reading any project
72
+ * state, so a caller cannot use a malformed patch to probe the filesystem.
73
+ *
74
+ * @param {unknown} patch
75
+ * @returns {{ ok: boolean, errors: PatchIssue[], summary: string }}
76
+ */
77
+ export function validateScenePatch(patch) {
78
+ /** @type {PatchIssue[]} */
79
+ const errors = []
80
+
81
+ const structural = validatePatchStructure(patch)
82
+ for (const issue of structural) {
83
+ errors.push({ code: 'PATCH_SCHEMA_INVALID', path: issue.path, message: `${issue.message} [${issue.keyword}]` })
84
+ }
85
+ if (errors.length > 0) {
86
+ return { ok: false, errors, summary: formatIssues(structural) }
87
+ }
88
+
89
+ const document = /** @type {any} */ (patch)
90
+
91
+ // NOTE: `baseRevision` needs no semantic check here. The schema's `pattern`
92
+ // already rejects every string `^r[0-9]{4,}$` would, and this function returns
93
+ // early on structural errors — so a second check would be unreachable code that
94
+ // reads like a guarantee. It lived here briefly and was removed for that
95
+ // reason; the rejection is asserted via PATCH_SCHEMA_INVALID[pattern].
96
+ if (typeof document.idempotencyKey === 'string' && document.idempotencyKey.trim().length === 0) {
97
+ errors.push({
98
+ code: 'PATCH_IDEMPOTENCY_KEY_BLANK',
99
+ path: 'idempotencyKey',
100
+ message: 'an idempotency key must not be blank — it is the only thing preventing a retried patch from applying twice',
101
+ })
102
+ }
103
+
104
+ document.operations.forEach((operation, position) => {
105
+ const at = `operations[${position}]`
106
+ const op = operation.op
107
+
108
+ if (op === 'entity.transform.update') {
109
+ const supplied = ['location', 'rotationEuler', 'scale'].filter(key => operation[key] !== undefined)
110
+ if (supplied.length === 0) {
111
+ errors.push({
112
+ code: 'PATCH_OPERATION_EMPTY',
113
+ path: at,
114
+ message: 'entity.transform.update must supply at least one of location, rotationEuler or scale',
115
+ })
116
+ }
117
+ }
118
+ if (op === 'light.update') {
119
+ const supplied = ['transform', 'energy', 'color', 'size', 'spotSize', 'spotBlend', 'angle']
120
+ .filter(key => operation[key] !== undefined)
121
+ if (supplied.length === 0) {
122
+ errors.push({
123
+ code: 'PATCH_OPERATION_EMPTY',
124
+ path: at,
125
+ message: 'light.update must supply at least one field to change',
126
+ })
127
+ }
128
+ }
129
+ if (op === 'camera.update') {
130
+ // This field list is the THIRD copy of `camera.update`'s vocabulary — the JSON
131
+ // Schema's `$defs.camera` (which `camera.add` uses), the `camera.update` branch's
132
+ // own property list, and here. Adding `role` to the first two and not this one
133
+ // produced a rejection that named the wrong problem ("must supply at least one
134
+ // field to change" for a patch that supplied one) from a copy nobody remembered
135
+ // existed. It is now a named constant that a test asserts against the schema.
136
+ const supplied = CAMERA_UPDATE_FIELDS.filter(key => operation[key] !== undefined)
137
+ if (supplied.length === 0) {
138
+ errors.push({
139
+ code: 'PATCH_OPERATION_EMPTY',
140
+ path: at,
141
+ message: `camera.update must supply at least one field to change: ${CAMERA_UPDATE_FIELDS.join(', ')}`,
142
+ })
143
+ }
144
+ if (operation.targetEntityId !== undefined && operation.targetPoint !== undefined) {
145
+ errors.push({
146
+ code: 'PATCH_CAMERA_TARGET_AMBIGUOUS',
147
+ path: at,
148
+ message: 'camera.update must set either targetEntityId or targetPoint, not both',
149
+ })
150
+ }
151
+ }
152
+ for (const key of ['entityId', 'materialId', 'lightId', 'cameraId', 'trackId', 'shotId']) {
153
+ const value = operation[key]
154
+ if (typeof value === 'string' && !ID_PATTERN.test(value)) {
155
+ errors.push({
156
+ code: 'PATCH_ID_INVALID',
157
+ path: `${at}.${key}`,
158
+ message: `"${value}" is not a valid id (must match ${ID_PATTERN.source})`,
159
+ })
160
+ }
161
+ }
162
+ })
163
+
164
+ return { ok: errors.length === 0, errors, summary: errors.length === 0 ? 'valid' : errors.map(entry => `${entry.path}: ${entry.message}`).join('\n') }
165
+ }
166
+
167
+ /**
168
+ * Every field `camera.update` may change.
169
+ *
170
+ * Exported so a test can assert it against the JSON Schema's own property list. Three
171
+ * copies of one vocabulary is two too many, and the failure mode when they disagree is
172
+ * not a crash — it is a rejection that names the wrong problem.
173
+ */
174
+ export const CAMERA_UPDATE_FIELDS = Object.freeze([
175
+ 'lens', 'sensorWidth', 'clipping', 'transform', 'targetEntityId', 'targetPoint', 'fStop', 'role',
176
+ ])
177
+
178
+ /** Build an anchored error for a failed operation. */
179
+ function operationError(index, code, message) {
180
+ const error = new Error(`operations[${index}] (${code}): ${message}`)
181
+ // @ts-expect-error - attaching structured detail to a plain Error for the caller.
182
+ error.patchIssue = { code, path: `operations[${index}]`, message }
183
+ return error
184
+ }
185
+
186
+ /**
187
+ * The optional collections, as arrays.
188
+ *
189
+ * EVERY collection except `entities` and `cameras` may be ABSENT rather than empty —
190
+ * `applyPatchToSpec` is careful to keep it that way, because an absent key is a
191
+ * different document from `[]` (see `collection()` below). The cost of that care is
192
+ * that a lookup has to tolerate absence, and MEASURED, it did not:
193
+ *
194
+ * material.add on a spec with no Materials -> TypeError: Cannot read
195
+ * light.add on a spec with no Lights -> properties of undefined
196
+ * asset.add on a spec with no Assets -> (reading 'findIndex')
197
+ *
198
+ * An uncoded `TypeError` reaching the model is the one failure shape SPEC §9.4 rules
199
+ * out — every refusal is supposed to be a branchable result with a stable code, and a
200
+ * stack trace means "this is a bug". It was a bug, and a reachable one: the minimal
201
+ * scaffold `blender_project_create` starts from declares no lights and no materials, so
202
+ * the FIRST `light.add` on a fresh project hit it.
203
+ *
204
+ * Fixed at the helpers rather than at the three call sites, because the next operation
205
+ * added would otherwise have to remember.
206
+ */
207
+ const asCollection = collection => (Array.isArray(collection) ? collection : [])
208
+
209
+ /** Find an entry by id inside a collection, or `-1`. */
210
+ function indexOfId(collection, id) {
211
+ return asCollection(collection).findIndex(entry => entry.id === id)
212
+ }
213
+
214
+ /** Insert or replace by id, keeping the collection sorted by id for a stable diff. */
215
+ function upsertById(collection, entry) {
216
+ // Sorted by id, always. Patch operations arrive in whatever order the caller
217
+ // found convenient, and an author-controlled array order would make two
218
+ // documents that describe the same scene serialize differently — which the
219
+ // digest and every golden fixture would then disagree about.
220
+ return [...asCollection(collection).filter(candidate => candidate.id !== entry.id), entry]
221
+ .sort((left, right) => (left.id < right.id ? -1 : left.id > right.id ? 1 : 0))
222
+ }
223
+
224
+ /** Remove by id. */
225
+ function removeById(collection, id) {
226
+ return asCollection(collection).filter(entry => entry.id !== id)
227
+ }
228
+
229
+ /**
230
+ * Assign a collection back, keeping ABSENCE where it belonged.
231
+ *
232
+ * `removeById` on the last entry yields `[]`, and writing that back would turn "this
233
+ * project declares no assets" into "this project declares an empty list of assets" —
234
+ * the distinction `collection()` above exists to preserve, and the one that makes a
235
+ * no-op patch leave the document canonically identical.
236
+ *
237
+ * @param {any} target
238
+ * @param {string} key
239
+ * @param {object[]} remaining
240
+ */
241
+ function assignCollection(target, key, remaining) {
242
+ if (remaining.length === 0) delete target[key]
243
+ else target[key] = remaining
244
+ }
245
+
246
+ /**
247
+ * A shallow copy of `object` without `key`.
248
+ *
249
+ * Used where a patch must CLEAR a field that the schema forbids setting to null
250
+ * (an absent key and an explicit null mean different things). The source object
251
+ * is never mutated: `applyPatchToSpec` must leave its input byte-identical, and
252
+ * `delete` on a live object is exactly how that guarantee gets broken.
253
+ */
254
+ function withoutKey(object, key) {
255
+ const copy = { ...object }
256
+ delete copy[key]
257
+ return copy
258
+ }
259
+
260
+ /**
261
+ * @typedef {object} AppliedOperation
262
+ * @property {string} op
263
+ * @property {string} target
264
+ * @property {string} summary
265
+ * @property {string[]} changedPaths
266
+ */
267
+
268
+ /**
269
+ * @typedef {object} ApplyPatchResult
270
+ * @property {object} spec - the new spec. The input spec is untouched.
271
+ * @property {AppliedOperation[]} operations - per-operation audit records.
272
+ * @property {string} digestBefore - scene digest of the input
273
+ * @property {string} digestAfter - scene digest of the output
274
+ * @property {string} specHashBefore - whole-document hash of the input
275
+ * @property {string} specHashAfter - whole-document hash of the output
276
+ */
277
+
278
+ /**
279
+ * Apply a patch to an ALREADY COMPILED spec (as produced by `compileSceneSpec`,
280
+ * so every default is materialised and no operation has to reason about
281
+ * absent-vs-default).
282
+ *
283
+ * Throws on the first failing operation. The thrown error carries
284
+ * `patchIssue` with a stable `code`, and `spec` is guaranteed unmodified because
285
+ * every branch builds a new array before mutating anything.
286
+ *
287
+ * @param {object} spec
288
+ * @param {object} patch
289
+ * @returns {ApplyPatchResult}
290
+ */
291
+ export function applyPatchToSpec(spec, patch) {
292
+ const digestBefore = sceneSpecDigest(spec)
293
+ const specHashBefore = specHash(spec)
294
+
295
+ /**
296
+ * A copy of an optional collection, or `undefined` when it holds nothing.
297
+ *
298
+ * PRESERVING ABSENCE IS THE POINT. Materialising `assets: []` for a spec that
299
+ * had no `assets` key is invisible to the scene digest but visible in the
300
+ * stored bytes, so a patch that changed nothing would still rewrite the
301
+ * document and change its `specHash` — breaking the property that a no-op patch
302
+ * leaves the spec canonically identical. It also loses information: an absent
303
+ * key is "this project declares no assets", and `assets: []` is a different
304
+ * document that happens to mean the same thing today.
305
+ */
306
+ const collection = (target, key) => {
307
+ const value = target[key]
308
+ if (value !== undefined && value.length > 0) target[key] = [...value]
309
+ }
310
+
311
+ /** @type {any} */
312
+ let next = {
313
+ ...spec,
314
+ entities: [...spec.entities],
315
+ cameras: [...spec.cameras],
316
+ renderProfiles: { ...spec.renderProfiles },
317
+ }
318
+ for (const key of ['assets', 'materials', 'lights', 'shots', 'animationTracks']) {
319
+ collection(next, key)
320
+ }
321
+
322
+ /** @type {AppliedOperation[]} */
323
+ const applied = []
324
+
325
+ patch.operations.forEach((operation, index) => {
326
+ const op = operation.op
327
+ const fail = (code, message) => { throw operationError(index, code, message) }
328
+
329
+ switch (op) {
330
+ // ---- entities -------------------------------------------------------
331
+ case 'entity.transform.update': {
332
+ const at = indexOfId(next.entities, operation.entityId)
333
+ if (at < 0) fail('PATCH_TARGET_MISSING', `no entity "${operation.entityId}" exists in this scene`)
334
+ const entity = next.entities[at]
335
+ /** @type {string[]} */
336
+ const changed = []
337
+ /** @type {Record<string, unknown>} */
338
+ const transform = { ...entity.transform }
339
+ for (const component of ['location', 'rotationEuler', 'scale']) {
340
+ if (operation[component] === undefined) continue
341
+ if (operation[component].every((value, axis) => value === entity.transform[component][axis])) continue
342
+ transform[component] = [...operation[component]]
343
+ changed.push(`entities.${entity.id}.transform.${component}`)
344
+ }
345
+ next.entities = [...next.entities]
346
+ next.entities[at] = { ...entity, transform }
347
+ applied.push({
348
+ op,
349
+ target: entity.id,
350
+ summary: changed.length === 0
351
+ ? `entity "${entity.id}" transform already matched the requested values`
352
+ : `updated ${changed.map(path => path.split('.').pop()).join(', ')} of entity "${entity.id}"`,
353
+ changedPaths: changed,
354
+ })
355
+ break
356
+ }
357
+
358
+ case 'entity.visibility.set': {
359
+ const at = indexOfId(next.entities, operation.entityId)
360
+ if (at < 0) fail('PATCH_TARGET_MISSING', `no entity "${operation.entityId}" exists in this scene`)
361
+ const entity = next.entities[at]
362
+ next.entities = [...next.entities]
363
+ next.entities[at] = { ...entity, visible: operation.visible }
364
+ applied.push({
365
+ op,
366
+ target: entity.id,
367
+ summary: `entity "${entity.id}" is now ${operation.visible ? 'visible' : 'hidden'}`,
368
+ changedPaths: [`entities.${entity.id}.visible`],
369
+ })
370
+ break
371
+ }
372
+
373
+ case 'entity.tags.set': {
374
+ const at = indexOfId(next.entities, operation.entityId)
375
+ if (at < 0) fail('PATCH_TARGET_MISSING', `no entity "${operation.entityId}" exists in this scene`)
376
+ const entity = next.entities[at]
377
+ const tags = operation.tags
378
+ // Tags are how a scene states intent that geometry cannot, so setting them is a
379
+ // real scene change and not metadata: `environment` decides what may occlude,
380
+ // `hero-product` marks the subject, `subject-part` says an entity is part of the
381
+ // subject's own body. Until this operation existed, the only way to change a tag
382
+ // was to recreate the project — the same gap `role` had for cameras.
383
+ //
384
+ // An EMPTY list removes the key rather than storing `tags: []`. Absent means
385
+ // "this entity declares no intent", and an empty array would be a second way to
386
+ // say the same thing — which is how two documents that mean the same thing end
387
+ // up serializing differently (D21, and the digest that follows from it).
388
+ next.entities = [...next.entities]
389
+ next.entities[at] = tags.length === 0
390
+ ? withoutKey(entity, 'tags')
391
+ : { ...entity, tags: [...tags] }
392
+ const before = Array.isArray(entity.tags) ? entity.tags : []
393
+ applied.push({
394
+ op,
395
+ target: entity.id,
396
+ summary: tags.length === 0
397
+ ? `entity "${entity.id}" no longer declares any tags (was ${before.join(', ') || 'none'})`
398
+ : `entity "${entity.id}" tags are now ${tags.join(', ')}` +
399
+ (before.length > 0 ? ` (were ${before.join(', ')})` : ''),
400
+ changedPaths: [`entities.${entity.id}.tags`],
401
+ })
402
+ break
403
+ }
404
+
405
+ case 'entity.add': {
406
+ if (indexOfId(next.entities, operation.entity.id) >= 0) {
407
+ fail('PATCH_TARGET_EXISTS', `entity "${operation.entity.id}" already exists; use entity.transform.update instead`)
408
+ }
409
+ const entity = operation.entity
410
+ if (entity.assetId !== undefined && indexOfId(next.assets, entity.assetId) < 0) {
411
+ fail('PATCH_REFERENCE_MISSING', `entity "${entity.id}" references asset "${entity.assetId}", which this scene does not declare`)
412
+ }
413
+ if (entity.materialId !== undefined && indexOfId(next.materials, entity.materialId) < 0) {
414
+ fail('PATCH_REFERENCE_MISSING', `entity "${entity.id}" references material "${entity.materialId}", which this scene does not declare`)
415
+ }
416
+ next.entities = upsertById(next.entities, entity)
417
+ applied.push({
418
+ op,
419
+ target: entity.id,
420
+ summary: `added ${entity.type} entity "${entity.id}"`,
421
+ changedPaths: [`entities.${entity.id}`],
422
+ })
423
+ break
424
+ }
425
+
426
+ case 'entity.remove': {
427
+ if (indexOfId(next.entities, operation.entityId) < 0) {
428
+ fail('PATCH_TARGET_MISSING', `no entity "${operation.entityId}" exists in this scene`)
429
+ }
430
+ const dependents = []
431
+ if (next.cameras.some(camera => camera.targetEntityId === operation.entityId)) {
432
+ dependents.push('camera target')
433
+ }
434
+ if (next.animationTracks.some(track => track.targetEntityId === operation.entityId)) {
435
+ dependents.push('animation track')
436
+ }
437
+ if (dependents.length > 0) {
438
+ fail(
439
+ 'PATCH_TARGET_IN_USE',
440
+ `entity "${operation.entityId}" is still referenced by ${dependents.join(' and ')}; ` +
441
+ 'remove or re-point those in the same patch, before this operation',
442
+ )
443
+ }
444
+ next.entities = removeById(next.entities, operation.entityId)
445
+ applied.push({
446
+ op,
447
+ target: operation.entityId,
448
+ summary: `removed entity "${operation.entityId}"`,
449
+ changedPaths: [`entities.${operation.entityId}`],
450
+ })
451
+ break
452
+ }
453
+
454
+ case 'entity.material.set': {
455
+ const at = indexOfId(next.entities, operation.entityId)
456
+ if (at < 0) fail('PATCH_TARGET_MISSING', `no entity "${operation.entityId}" exists in this scene`)
457
+ if (operation.materialId !== null && indexOfId(next.materials, operation.materialId) < 0) {
458
+ fail('PATCH_REFERENCE_MISSING', `material "${operation.materialId}" is not declared in this scene`)
459
+ }
460
+ const entity = next.entities[at]
461
+ next.entities = [...next.entities]
462
+ if (operation.materialId === null) {
463
+ const { materialId, ...rest } = entity
464
+ next.entities[at] = rest
465
+ } else {
466
+ next.entities[at] = { ...entity, materialId: operation.materialId }
467
+ }
468
+ applied.push({
469
+ op,
470
+ target: entity.id,
471
+ summary: operation.materialId === null
472
+ ? `entity "${entity.id}" now uses the default material`
473
+ : `entity "${entity.id}" now uses material "${operation.materialId}"`,
474
+ changedPaths: [`entities.${entity.id}.materialId`],
475
+ })
476
+ break
477
+ }
478
+
479
+ // ---- materials ------------------------------------------------------
480
+ case 'material.add': {
481
+ if (indexOfId(next.materials, operation.material.id) >= 0) {
482
+ fail('PATCH_TARGET_EXISTS', `material "${operation.material.id}" already exists; use material.parameter.update instead`)
483
+ }
484
+ next.materials = upsertById(next.materials, {
485
+ shader: 'principled',
486
+ parameters: {},
487
+ ...operation.material,
488
+ })
489
+ applied.push({
490
+ op,
491
+ target: operation.material.id,
492
+ summary: `added ${operation.material.shader} material "${operation.material.id}"`,
493
+ changedPaths: [`materials.${operation.material.id}`],
494
+ })
495
+ break
496
+ }
497
+
498
+ case 'material.parameter.update': {
499
+ const at = indexOfId(next.materials, operation.materialId)
500
+ if (at < 0) fail('PATCH_TARGET_MISSING', `no material "${operation.materialId}" exists in this scene`)
501
+ const material = next.materials[at]
502
+ const previous = material.parameters?.[operation.parameter]
503
+ next.materials = [...next.materials]
504
+ next.materials[at] = {
505
+ ...material,
506
+ parameters: { ...material.parameters, [operation.parameter]: operation.value },
507
+ }
508
+ applied.push({
509
+ op,
510
+ target: material.id,
511
+ summary: `material "${material.id}" ${operation.parameter}: ${
512
+ previous === undefined ? '(default)' : JSON.stringify(previous)
513
+ } → ${JSON.stringify(operation.value)}`,
514
+ changedPaths: [`materials.${material.id}.parameters.${operation.parameter}`],
515
+ })
516
+ break
517
+ }
518
+
519
+ case 'material.texture.set': {
520
+ const at = indexOfId(next.materials, operation.materialId)
521
+ if (at < 0) fail('PATCH_TARGET_MISSING', `no material "${operation.materialId}" exists in this scene`)
522
+ const material = next.materials[at]
523
+ const previous = material.texture
524
+ const describe = (texture) => (texture ? `${texture.type}@${texture.scale}` : '(none)')
525
+ next.materials = [...next.materials]
526
+ if (operation.texture === null) {
527
+ const { texture, ...rest } = material
528
+ next.materials[at] = rest
529
+ } else {
530
+ next.materials[at] = { ...material, texture: operation.texture }
531
+ }
532
+ applied.push({
533
+ op,
534
+ target: material.id,
535
+ summary: `material "${material.id}" texture: ${describe(previous)} → ${describe(operation.texture)}`,
536
+ changedPaths: [`materials.${material.id}.texture`],
537
+ })
538
+ break
539
+ }
540
+
541
+ // ---- lights ---------------------------------------------------------
542
+ case 'light.add': {
543
+ if (indexOfId(next.lights, operation.light.id) >= 0) {
544
+ fail('PATCH_TARGET_EXISTS', `light "${operation.light.id}" already exists; use light.update instead`)
545
+ }
546
+ next.lights = upsertById(next.lights, operation.light)
547
+ applied.push({
548
+ op,
549
+ target: operation.light.id,
550
+ summary: `added ${operation.light.type} light "${operation.light.id}"`,
551
+ changedPaths: [`lights.${operation.light.id}`],
552
+ })
553
+ break
554
+ }
555
+
556
+ case 'light.update': {
557
+ const at = indexOfId(next.lights, operation.lightId)
558
+ if (at < 0) fail('PATCH_TARGET_MISSING', `no light "${operation.lightId}" exists in this scene`)
559
+ const light = next.lights[at]
560
+ const { op: _op, lightId: _lightId, transform, ...scalars } = operation
561
+ /** @type {Record<string, unknown>} */
562
+ const patchFields = {}
563
+ for (const [key, value] of Object.entries(scalars)) {
564
+ if (value === undefined) continue
565
+ patchFields[key] = value
566
+ }
567
+ if (transform !== undefined) {
568
+ patchFields.transform = {
569
+ ...light.transform,
570
+ ...Object.fromEntries(Object.entries(transform).filter(([, value]) => value !== undefined)),
571
+ }
572
+ }
573
+ next.lights = [...next.lights]
574
+ next.lights[at] = { ...light, ...patchFields }
575
+ applied.push({
576
+ op,
577
+ target: light.id,
578
+ summary: `updated light "${light.id}": ${Object.keys(patchFields).join(', ')}`,
579
+ changedPaths: Object.keys(patchFields).map(key => `lights.${light.id}.${key}`),
580
+ })
581
+ break
582
+ }
583
+
584
+ case 'light.remove': {
585
+ if (indexOfId(next.lights, operation.lightId) < 0) {
586
+ fail('PATCH_TARGET_MISSING', `no light "${operation.lightId}" exists in this scene`)
587
+ }
588
+ next.lights = removeById(next.lights, operation.lightId)
589
+ applied.push({
590
+ op,
591
+ target: operation.lightId,
592
+ summary: `removed light "${operation.lightId}"`,
593
+ changedPaths: [`lights.${operation.lightId}`],
594
+ })
595
+ break
596
+ }
597
+
598
+ // ---- cameras --------------------------------------------------------
599
+ case 'camera.add': {
600
+ if (indexOfId(next.cameras, operation.camera.id) >= 0) {
601
+ fail('PATCH_TARGET_EXISTS', `camera "${operation.camera.id}" already exists; use camera.update instead`)
602
+ }
603
+ if (operation.camera.targetEntityId !== undefined
604
+ && indexOfId(next.entities, operation.camera.targetEntityId) < 0) {
605
+ fail('PATCH_REFERENCE_MISSING', `camera "${operation.camera.id}" targets entity "${operation.camera.targetEntityId}", which does not exist`)
606
+ }
607
+ next.cameras = upsertById(next.cameras, operation.camera)
608
+ applied.push({
609
+ op,
610
+ target: operation.camera.id,
611
+ summary: `added camera "${operation.camera.id}"`,
612
+ changedPaths: [`cameras.${operation.camera.id}`],
613
+ })
614
+ break
615
+ }
616
+
617
+ case 'camera.update': {
618
+ const at = indexOfId(next.cameras, operation.cameraId)
619
+ if (at < 0) fail('PATCH_TARGET_MISSING', `no camera "${operation.cameraId}" exists in this scene`)
620
+ if (operation.targetEntityId !== undefined
621
+ && indexOfId(next.entities, operation.targetEntityId) < 0) {
622
+ fail('PATCH_REFERENCE_MISSING', `camera "${operation.cameraId}" targets entity "${operation.targetEntityId}", which does not exist`)
623
+ }
624
+ const camera = next.cameras[at]
625
+ const { op: _op, cameraId: _cameraId, transform, ...scalars } = operation
626
+ /** @type {Record<string, unknown>} */
627
+ const patchFields = {}
628
+ for (const [key, value] of Object.entries(scalars)) {
629
+ if (value === undefined) continue
630
+ patchFields[key] = value
631
+ }
632
+ if (transform !== undefined) {
633
+ patchFields.transform = {
634
+ ...camera.transform,
635
+ ...Object.fromEntries(Object.entries(transform).filter(([, value]) => value !== undefined)),
636
+ }
637
+ }
638
+ // Switching aim source must not leave the other one behind: a camera
639
+ // carrying both a target entity and a target point is rejected by
640
+ // SceneSpec validation (SCENE_CAMERA_TARGET_AMBIGUOUS). Clearing the old
641
+ // key therefore has to happen on the CAMERA.
642
+ //
643
+ // It previously deleted from `patchFields`, which can never hold the key
644
+ // being cleared — validateScenePatch rejects one operation that supplies
645
+ // both — so the deletion was a no-op and a successfully-applied patch
646
+ // produced a document that then FAILED SceneSpec validation. There is a
647
+ // red regression test for this in scene-patch.test.mjs.
648
+ const clearedAim = operation.targetEntityId !== undefined
649
+ ? 'targetPoint'
650
+ : operation.targetPoint !== undefined ? 'targetEntityId' : null
651
+ const mergedAim = clearedAim === null ? camera : withoutKey(camera, clearedAim)
652
+ next.cameras = [...next.cameras]
653
+ next.cameras[at] = { ...mergedAim, ...patchFields }
654
+ applied.push({
655
+ op,
656
+ target: camera.id,
657
+ summary: `updated camera "${camera.id}": ${Object.keys(patchFields).join(', ')}` +
658
+ (clearedAim === null ? '' : `, cleared ${clearedAim}`),
659
+ changedPaths: [
660
+ ...Object.keys(patchFields).map(key => `cameras.${camera.id}.${key}`),
661
+ ...clearedAim === null ? [] : [`cameras.${camera.id}.${clearedAim}`],
662
+ ],
663
+ })
664
+ break
665
+ }
666
+
667
+ case 'camera.remove': {
668
+ if (indexOfId(next.cameras, operation.cameraId) < 0) {
669
+ fail('PATCH_TARGET_MISSING', `no camera "${operation.cameraId}" exists in this scene`)
670
+ }
671
+ const usingShot = next.shots.find(shot => shot.cameraId === operation.cameraId)
672
+ if (usingShot !== undefined) {
673
+ fail(
674
+ 'PATCH_TARGET_IN_USE',
675
+ `camera "${operation.cameraId}" is used by shot "${usingShot.id}"; remove that shot in the same patch, before this operation`,
676
+ )
677
+ }
678
+ next.cameras = removeById(next.cameras, operation.cameraId)
679
+ applied.push({
680
+ op,
681
+ target: operation.cameraId,
682
+ summary: `removed camera "${operation.cameraId}"`,
683
+ changedPaths: [`cameras.${operation.cameraId}`],
684
+ })
685
+ break
686
+ }
687
+
688
+ // ---- animation ------------------------------------------------------
689
+ case 'animation.track.set': {
690
+ // The target's collection comes from `targetKind`, and this is the SECOND
691
+ // place that has to know the rule — `validateSceneSpec` is the other, and
692
+ // the compiler is the third. They disagreed the moment kinds existed: the
693
+ // schema accepted a camera track and this branch still resolved it against
694
+ // `entities`, so a legal patch was refused with a message about a missing
695
+ // entity. Resolution goes through one helper for exactly that reason.
696
+ const kind = operation.track.targetKind ?? 'entity'
697
+ if (indexOfId(collectionForKind(next, kind), operation.track.targetEntityId) < 0) {
698
+ fail(
699
+ 'PATCH_REFERENCE_MISSING',
700
+ `animation track "${operation.track.id}" targets ${kind} "${operation.track.targetEntityId}", which does not exist`,
701
+ )
702
+ }
703
+ const replacing = indexOfId(next.animationTracks, operation.track.id) >= 0
704
+ next.animationTracks = upsertById(next.animationTracks, operation.track)
705
+ applied.push({
706
+ op,
707
+ target: operation.track.id,
708
+ summary: `${replacing ? 'replaced' : 'added'} animation track "${operation.track.id}" ` +
709
+ `(${kind} ${operation.track.targetEntityId}, ${operation.track.property}, ` +
710
+ `${operation.track.keyframes.length} keyframes)`,
711
+ changedPaths: [`animationTracks.${operation.track.id}`],
712
+ })
713
+ break
714
+ }
715
+
716
+ case 'animation.track.remove': {
717
+ if (indexOfId(next.animationTracks, operation.trackId) < 0) {
718
+ fail('PATCH_TARGET_MISSING', `no animation track "${operation.trackId}" exists in this scene`)
719
+ }
720
+ next.animationTracks = removeById(next.animationTracks, operation.trackId)
721
+ applied.push({
722
+ op,
723
+ target: operation.trackId,
724
+ summary: `removed animation track "${operation.trackId}"`,
725
+ changedPaths: [`animationTracks.${operation.trackId}`],
726
+ })
727
+ break
728
+ }
729
+
730
+ // ---- shots ----------------------------------------------------------
731
+ case 'shot.set': {
732
+ if (indexOfId(next.cameras, operation.shot.cameraId) < 0) {
733
+ fail('PATCH_REFERENCE_MISSING', `shot "${operation.shot.id}" uses camera "${operation.shot.cameraId}", which does not exist`)
734
+ }
735
+ const replacing = indexOfId(next.shots, operation.shot.id) >= 0
736
+ next.shots = upsertById(next.shots, operation.shot)
737
+ applied.push({
738
+ op,
739
+ target: operation.shot.id,
740
+ summary: `${replacing ? 'replaced' : 'added'} shot "${operation.shot.id}" on camera "${operation.shot.cameraId}"`,
741
+ changedPaths: [`shots.${operation.shot.id}`],
742
+ })
743
+ break
744
+ }
745
+
746
+ case 'shot.remove': {
747
+ if (indexOfId(next.shots, operation.shotId) < 0) {
748
+ fail('PATCH_TARGET_MISSING', `no shot "${operation.shotId}" exists in this scene`)
749
+ }
750
+ next.shots = removeById(next.shots, operation.shotId)
751
+ applied.push({
752
+ op,
753
+ target: operation.shotId,
754
+ summary: `removed shot "${operation.shotId}"`,
755
+ changedPaths: [`shots.${operation.shotId}`],
756
+ })
757
+ break
758
+ }
759
+
760
+ // ---- project and profiles -------------------------------------------
761
+ case 'project.frameRange.set': {
762
+ if (operation.frameEnd <= operation.frameStart) {
763
+ fail('PATCH_FRAME_RANGE_INVALID', `frameEnd (${operation.frameEnd}) must be greater than frameStart (${operation.frameStart})`)
764
+ }
765
+ /** @type {Record<string, unknown>} */
766
+ const nextProject = { ...next.project, frameStart: operation.frameStart, frameEnd: operation.frameEnd }
767
+ if (operation.fps !== undefined) nextProject.fps = operation.fps
768
+ const changed = [
769
+ `project.frameStart: ${next.project.frameStart} → ${operation.frameStart}`,
770
+ `project.frameEnd: ${next.project.frameEnd} → ${operation.frameEnd}`,
771
+ ]
772
+ next = { ...next, project: nextProject }
773
+ applied.push({
774
+ op,
775
+ target: next.project.id,
776
+ summary: changed.join(', '),
777
+ changedPaths: ['project.frameStart', 'project.frameEnd'],
778
+ })
779
+ break
780
+ }
781
+
782
+ case 'render.profile.set': {
783
+ const existing = next.renderProfiles[operation.profileName]
784
+ // A render profile is a COST DECLARATION, and `resolution` is the part of
785
+ // it that decides how much a render costs. So it is never half-changed:
786
+ // the incoming resolution replaces the existing one outright, and a patch
787
+ // that omits it keeps the existing one rather than deleting it.
788
+ //
789
+ // Both of the previous behaviours here were wrong. Assigning
790
+ // `operation.profile.resolution` unconditionally left an own key set to
791
+ // `undefined`, which passes as "success" and then fails SceneSpec
792
+ // validation (resolution is required). And defining a NEW profile without
793
+ // a resolution threw a bare TypeError while formatting the summary, which
794
+ // the tool layer could not map to a stable error code.
795
+ const resolution = operation.profile.resolution ?? existing?.resolution
796
+ if (resolution === undefined) {
797
+ fail(
798
+ 'PATCH_PROFILE_UNRESOLVED_RESOLUTION',
799
+ `render.profile.set for "${operation.profileName}" supplies no resolution and there is no ` +
800
+ 'existing profile to inherit one from; a render profile must state its resolution because ' +
801
+ 'resolution is what decides the render cost',
802
+ )
803
+ }
804
+ const merged = { ...existing, ...operation.profile, resolution }
805
+ next.renderProfiles = { ...next.renderProfiles, [operation.profileName]: merged }
806
+ applied.push({
807
+ op,
808
+ target: operation.profileName,
809
+ summary: existing === undefined
810
+ ? `defined the ${operation.profileName} render profile (${merged.engine}, ${resolution.join('x')}` +
811
+ `${merged.samples === undefined ? '' : `, ${merged.samples} samples`})`
812
+ : `updated the ${operation.profileName} render profile` +
813
+ `${operation.profile.resolution === undefined ? ' (kept its resolution)' : ` (${resolution.join('x')})`}`,
814
+ changedPaths: [`renderProfiles.${operation.profileName}`],
815
+ })
816
+ break
817
+ }
818
+
819
+ case 'world.set': {
820
+ // The world REPLACES rather than merges, matching how a caller thinks about
821
+ // "make the background black": a half-updated world is how the hidden grey
822
+ // constant survived this long in the first place.
823
+ const previous = next.world
824
+ next.world = { ...operation.world }
825
+ applied.push({
826
+ op,
827
+ target: 'world',
828
+ summary: previous === undefined
829
+ ? `set the scene world (color ${JSON.stringify(next.world.color)}, strength ${next.world.strength ?? 'default'})`
830
+ : 'replaced the scene world',
831
+ changedPaths: ['world'],
832
+ })
833
+ break
834
+ }
835
+
836
+ // ---- assets ---------------------------------------------------------
837
+ //
838
+ // WHY THESE TWO EXIST. `assets` has been part of the SceneSpec since M1 —
839
+ // validated, digest-stable, compiled by the right importer per type — and there
840
+ // was no way to ADD one after the project was created. A spec could carry assets
841
+ // only if the document that created the project already had them, so
842
+ // `blender_asset_ingest` would have had nothing to attach its result to. The
843
+ // vocabulary and the producer are one feature, and this is the half that was
844
+ // missing.
845
+ case 'asset.add': {
846
+ if (indexOfId(next.assets, operation.asset.id) >= 0) {
847
+ fail('PATCH_TARGET_EXISTS', `asset "${operation.asset.id}" already exists in this scene`)
848
+ }
849
+ // The path guard lives in the SceneSpec's semantic validator and runs again on
850
+ // the committed document, but a patch that would be refused there is refused
851
+ // HERE with a patch code, so the caller learns which operation was wrong
852
+ // instead of getting a whole-document verdict.
853
+ const assetPath = operation.asset.path
854
+ if (assetPath.startsWith('/') || assetPath.split('/').includes('..')) {
855
+ fail(
856
+ 'PATCH_OPERATION_INVALID',
857
+ `asset "${operation.asset.id}" names the path "${assetPath}"; asset paths are relative to the ` +
858
+ 'project root and may not contain ".." (SPEC §15.2)',
859
+ )
860
+ }
861
+ next.assets = upsertById(next.assets, { ...operation.asset })
862
+ applied.push({
863
+ op,
864
+ target: operation.asset.id,
865
+ summary: `added ${operation.asset.type} asset "${operation.asset.id}" at ${assetPath}`,
866
+ changedPaths: [`assets.${operation.asset.id}`],
867
+ })
868
+ break
869
+ }
870
+
871
+ case 'asset.remove': {
872
+ if (indexOfId(next.assets, operation.assetId) < 0) {
873
+ fail('PATCH_TARGET_MISSING', `no asset "${operation.assetId}" exists in this scene`)
874
+ }
875
+ // The rule `entity.remove` follows, for the same reason: an entity that
876
+ // instantiates this asset would be left pointing at nothing, and the compiler
877
+ // would then refuse a scene the patch claimed to have produced.
878
+ const dependents = next.entities.filter(entity => entity.assetId === operation.assetId)
879
+ if (dependents.length > 0) {
880
+ fail(
881
+ 'PATCH_TARGET_IN_USE',
882
+ `asset "${operation.assetId}" is still instantiated by ${dependents.map(entity => `"${entity.id}"`).join(', ')}; ` +
883
+ 'remove those entities in the same patch, before this operation',
884
+ )
885
+ }
886
+ assignCollection(next, 'assets', removeById(next.assets, operation.assetId))
887
+ applied.push({
888
+ op,
889
+ target: operation.assetId,
890
+ summary: `removed asset "${operation.assetId}"`,
891
+ changedPaths: [`assets.${operation.assetId}`],
892
+ })
893
+ break
894
+ }
895
+
896
+ default:
897
+ fail('PATCH_OPERATION_UNKNOWN', `"${op}" is not a ScenePatch v1 operation`)
898
+ }
899
+ })
900
+
901
+ return {
902
+ spec: next,
903
+ operations: applied,
904
+ digestBefore,
905
+ digestAfter: sceneSpecDigest(next),
906
+ specHashBefore,
907
+ specHashAfter: specHash(next),
908
+ }
909
+ }
910
+
911
+ /**
912
+ * The `operation-manifest.json` written beside every revision (SPEC §8.4).
913
+ *
914
+ * @param {object} input
915
+ * @param {AppliedOperation[]} input.operations
916
+ * @param {object} input.request - the accepted patch, recorded verbatim.
917
+ * @param {{ revision: string, baseRevision: string, digestBefore: string, digestAfter: string,
918
+ * specHashBefore?: string, specHashAfter?: string }} input.revision
919
+ * @param {import('./scene-spec.js').SceneSpecIssue[]} input.notices
920
+ * @returns {Record<string, unknown>}
921
+ */
922
+ export function buildOperationManifest({ operations, request, revision, notices }) {
923
+ return {
924
+ schemaVersion: 'deepblend.operation-manifest/v1',
925
+ revision: revision.revision,
926
+ baseRevision: revision.baseRevision,
927
+ digestBefore: revision.digestBefore,
928
+ digestAfter: revision.digestAfter,
929
+ specHashBefore: revision.specHashBefore ?? null,
930
+ specHashAfter: revision.specHashAfter ?? null,
931
+ // Two different questions, two different answers. `sceneChanged` excludes
932
+ // project.title/goal by design (see sceneSpecDigest); `specChanged` covers the
933
+ // whole document, so the frame range counts.
934
+ sceneChanged: revision.digestBefore !== revision.digestAfter,
935
+ specChanged: (revision.specHashBefore ?? null) !== null
936
+ && revision.specHashBefore !== revision.specHashAfter,
937
+ idempotencyKey: request.idempotencyKey,
938
+ actor: request.actor ?? null,
939
+ stage: request.stage ?? null,
940
+ note: request.note ?? null,
941
+ savedCheckpoint: request.saveCheckpoint !== false,
942
+ renderedPreview: request.renderPreview === true,
943
+ operationCount: operations.length,
944
+ operations,
945
+ notices,
946
+ }
947
+ }