@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.
- package/lib/asset-content.js +134 -0
- package/lib/bitmap-font.js +149 -0
- package/lib/canonical.js +98 -0
- package/lib/config-surface.js +88 -0
- package/lib/contact-sheet.js +233 -0
- package/lib/deployment-paths.js +153 -0
- package/lib/index.js +829 -0
- package/lib/json-schema.js +337 -0
- package/lib/png.js +452 -0
- package/lib/projections.js +209 -0
- package/lib/redact.js +53 -0
- package/lib/render-job.js +459 -0
- package/lib/scene-patch.js +947 -0
- package/lib/scene-spec.js +901 -0
- package/lib/schemas/job-result.schema.json +295 -0
- package/lib/schemas/scene-patch.schema.json +1307 -0
- package/lib/schemas/scene-spec.schema.json +791 -0
- package/lib/ui-api.js +695 -0
- package/lib/visual-composition.js +574 -0
- package/lib/visual-issue.js +738 -0
- package/lib/visual-loop.js +401 -0
- package/package.json +19 -0
|
@@ -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
|
+
}
|