@deepblend/dsh-blender-contracts 0.2.20 → 0.3.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,208 @@
1
+ /** The current Host-facing execution seam; no process or service is created by this type module. */
2
+ import type { Buffer } from 'node:buffer';
3
+ import type { RenderProfile, Vec3 } from './schema-types.js';
4
+ import type { BlenderError } from './sdk.js';
5
+
6
+ export type EngineKey = RenderProfile['engine'];
7
+ export interface RuntimeWarning { code: string; message: string; detail?: unknown }
8
+ export interface BlenderExecutableReport { requested: string; resolved: string | null; found: boolean; advice?: string | null }
9
+ export interface BlenderEngineProbe { assignable: boolean; readback: string | null; error: string | null }
10
+ export interface BlenderGpuBackend { supported: boolean; gpuDevices: string[]; cpuDevices: string[]; error: string | null }
11
+ export interface BlenderGpuReport {
12
+ availableBackends: string[]; preferredBackend: string | null; gpuDeviceNames: string[]; cpuDeviceNames: string[];
13
+ backendSupport: Record<string, BlenderGpuBackend>;
14
+ }
15
+ export interface BlenderRenderSmokeTest { attempted: boolean; engine: string | null; ok: boolean; bytes: number; error: string | null }
16
+ export interface BlenderCapabilities {
17
+ protocolVersion: string; installed: boolean; executable: BlenderExecutableReport;
18
+ blenderVersion: string | null; blenderVersionTuple: number[] | null; pythonVersion: string | null;
19
+ buildHash: string | null; binaryPath: string | null; renderEngines: Record<string, BlenderEngineProbe>;
20
+ bestAvailableEngine: string | null; renderEngineEnumItems: string[]; gpu: BlenderGpuReport; gpuAvailable: boolean;
21
+ exportFormats: string[]; importFormats: string[]; unavailableFormats: string[];
22
+ renderSmokeTest: BlenderRenderSmokeTest | null; cyclesSmokeTest: BlenderRenderSmokeTest | null;
23
+ textBlockApi: boolean; frameApi: boolean; hostPlatform: string | null; warnings: RuntimeWarning[];
24
+ probedAt: number; durationMs: number; commandLine: string | null;
25
+ }
26
+ export interface BlenderBootstrapRequest { action: string; payload?: Record<string, unknown>; jobId?: string }
27
+ export interface BlenderBootstrapEnvelope<Result = Record<string, unknown>> {
28
+ protocolVersion: string; jobId: string | null; action: string; status: 'success' | 'error';
29
+ capabilities: Record<string, unknown> | null; result: Result | null;
30
+ error: { code: string; message: string; detail?: unknown } | null;
31
+ warnings: RuntimeWarning[]; notices: RuntimeWarning[];
32
+ }
33
+ export interface RuntimeOptions { signal?: AbortSignal }
34
+ export interface WorkingDirectory<Result = Record<string, unknown>> {
35
+ directory: string; envelope: BlenderBootstrapEnvelope<Result>; jobId: string | undefined;
36
+ }
37
+ export interface BootstrapWorkingDirectory extends WorkingDirectory { requestPath: string; resultPath: string }
38
+ export type WorkingDirectoryCallback<Result = Record<string, unknown>> = (info: WorkingDirectory<Result>) => void | Promise<void>;
39
+ export interface MaterialSlot { index: number; materialName: string | null; materialId?: string | null; usedPolygonCount?: number }
40
+ export interface ImageFacts { width: number; height: number; channels: number; isFloat: boolean; colorSpace: string }
41
+ export interface UvMapFacts<Source extends 'mesh-data' | 'evaluated-mesh'> {
42
+ name: string; activeRender: boolean; loopCount: number; finite: boolean; source: Source;
43
+ }
44
+ export interface CompiledObject {
45
+ name: string; type: string; deepblendId?: string | null; partId?: string; parentPartId?: string | null;
46
+ materialSlots?: MaterialSlot[]; sourceMaterialSlots?: Array<{ index: number; materialName: string | null }>;
47
+ worldBounds?: { min: Vec3; max: Vec3 } | null; boundsFrame?: number; renderVisible?: boolean;
48
+ uvMaps?: Array<UvMapFacts<'mesh-data'>>; evaluatedUvMaps?: Array<UvMapFacts<'evaluated-mesh'>> | null;
49
+ boundsUnavailable?: 'viewport-disabled' | 'empty-or-invalid-bounds'; evaluatedUvMapsUnavailable?: 'viewport-disabled';
50
+ [key: string]: unknown;
51
+ }
52
+ /** Blender's measured configuration; additional measured fields may be added. */
53
+ export interface MeasuredRenderConfig {
54
+ engine: string; resolution: number[]; samples: number | null;
55
+ exposure?: number; viewTransform?: string; look?: string; gamma?: number;
56
+ [key: string]: unknown;
57
+ }
58
+ export interface CompileReport {
59
+ validation: { ok: boolean; errors: unknown[]; [key: string]: unknown };
60
+ sceneFingerprint: { totalPolygons: number; [key: string]: unknown };
61
+ objects?: CompiledObject[]; renderConfig?: MeasuredRenderConfig;
62
+ world?: { declared: boolean; color: number[]; strength: number;
63
+ environment?: { assetId: string; rotation?: number; image: ImageFacts; [key: string]: unknown }; [key: string]: unknown };
64
+ [key: string]: unknown;
65
+ }
66
+ export interface PreviewReport {
67
+ outputPath: string; bytes: number; width: number; height: number; frame: number; engine: string; cameraId: string;
68
+ fps?: number; frameStart?: number; frameEnd?: number; cameraName?: string; lens?: number;
69
+ renderConfig?: MeasuredRenderConfig; objects?: CompiledObject[]; [key: string]: unknown;
70
+ }
71
+ export interface RuntimeActionResult<Report> {
72
+ report: Report; envelope: BlenderBootstrapEnvelope<Report>; durationMs: number; stdout: string; stderr: string;
73
+ }
74
+ export interface CompileSceneRequest extends RuntimeOptions {
75
+ sceneSpecPath: string; projectRoot?: string; profile?: string; jobId?: string;
76
+ /** false runs a separate batch process, preserving a user's live Blender session. */
77
+ session?: boolean;
78
+ /** Copy or move result.blend before resolving; never retain a scratch path as a durable artifact. */
79
+ onWorkingDirectory?: WorkingDirectoryCallback<CompileReport>;
80
+ }
81
+ export interface RenderOverrides { engine?: EngineKey; width?: number; height?: number; samples?: number }
82
+ export interface RenderPreviewRequest extends RuntimeOptions, RenderOverrides {
83
+ checkpointPath: string; outputPath: string; cameraId?: string; frame?: number; jobId?: string;
84
+ session?: boolean;
85
+ onWorkingDirectory?: (info: BootstrapWorkingDirectory) => void | Promise<void>;
86
+ }
87
+ export interface RenderView { id: string; role?: string | null; cameraId?: string; frame?: number }
88
+ export interface ObjectMeasurement {
89
+ id: string; viewId: string; visiblePixels: number; silhouettePixels: number; visibleFraction: number;
90
+ occludedFraction: number; frameCoverage: number; silhouetteCoverage: number; inFrame: boolean;
91
+ bbox: unknown; centroid: unknown; occludedBy: unknown[]; part: boolean; [key: string]: unknown;
92
+ }
93
+ export interface ViewReport {
94
+ viewId: string; role: string | null; outputPath: string; bytes: number; width: number; height: number;
95
+ frame: number; cameraId: string; cameraName: string; lens: number; engine: string;
96
+ metrics: { width: number; height: number; objects: ObjectMeasurement[]; luminance: Record<string, unknown> };
97
+ /** Measured for this frame before diagnostic mask renders. */
98
+ renderConfig?: MeasuredRenderConfig;
99
+ cameraFacts?: MeasuredCameraFacts;
100
+ }
101
+ export interface MeasuredCameraFacts {
102
+ frame: number; matrixWorld: number[][]; type: 'PERSP' | 'ORTHO' | 'PANO'; lens: number; orthoScale: number;
103
+ sensorWidth: number; sensorHeight: number; sensorFit: string; shift: number[]; clip: number[];
104
+ dof: { enabled: boolean; focusDistance: number; focusObject: { name: string; entityId: string | null; matrixWorld: number[][] } | null;
105
+ focusSubtarget: string | null; apertureFstop: number; apertureBlades: number; apertureRotation: number; apertureRatio: number };
106
+ }
107
+ export interface ViewsReport { views: ViewReport[]; renderConfig?: MeasuredRenderConfig; objects?: CompiledObject[]; [key: string]: unknown }
108
+ export interface RenderViewsRequest extends RuntimeOptions, RenderOverrides {
109
+ checkpointPath: string; views: RenderView[]; track?: string[]; parts?: string[]; jobId?: string;
110
+ /** false isolates both rendering and measurements from a user's live session. */
111
+ session?: boolean;
112
+ }
113
+ export interface RenderViewsResult {
114
+ report: ViewsReport; pngs: Record<string, Buffer>; envelope: BlenderBootstrapEnvelope<ViewsReport>; durationMs: number;
115
+ }
116
+ export interface CollectedOutputReader {
117
+ readFrom(fromByte: number): { text: string; nextOffset: number; lossy: boolean; spillPath?: string };
118
+ }
119
+ /** Host-required subset of the DSH subprocess handle; implementations may expose additional fields. */
120
+ export interface FrameSequenceHandle {
121
+ readonly done: Promise<{ exitCode: number | null; signal: string | null }>;
122
+ readonly collected?: { readonly stdout?: CollectedOutputReader; readonly stderr?: CollectedOutputReader };
123
+ /** Synchronous, idempotent initiation of managed process termination. Await done afterwards. */
124
+ terminate(): void;
125
+ waitForExit?(signal?: AbortSignal): Promise<boolean>;
126
+ }
127
+ export interface FrameSequenceRequest extends RuntimeOptions {
128
+ checkpointPath: string; frames: number[]; jobDirectory: string; cameraId?: string; profileName?: string;
129
+ profile?: RenderProfile; frameRange?: [number, number]; jobId?: string; attemptToken?: string;
130
+ filePrefix?: string; padding?: number;
131
+ }
132
+ export interface FrameSequenceRun {
133
+ handle: FrameSequenceHandle; jobDirectory: string; framesDirectory: string; requestPath: string; planPath: string;
134
+ resultPath: string; eventsPath: string; processPath: string; argv: string[]; startedAt: number;
135
+ attemptToken: string | null; executable: string;
136
+ }
137
+ /** Optional fsynced journal extension for attribution; omitted evidence stays unknown. */
138
+ export interface FrameConfiguredEvent {
139
+ type: 'render_config'; attemptToken: string; renderConfig: MeasuredRenderConfig;
140
+ }
141
+ export interface FrameWrittenEvent {
142
+ type: 'frame'; frame: number; bytes: number; ms: number;
143
+ /** The matching configured event must precede this completion record. */
144
+ attemptToken: string;
145
+ /** SHA256 of the complete PNG bytes after structural verification. */
146
+ sha256: string;
147
+ }
148
+ export type FrameProvenanceEvent = FrameConfiguredEvent | FrameWrittenEvent;
149
+ /** Configuration read back by the renderer, with the launch identity that requested it. */
150
+ export interface FrameRenderSource {
151
+ id: string; attempt: number; revisionId: string; cameraId: string | null; checkpointSha256: string | null;
152
+ requestedSamples: number | null; effectiveProfile: RenderProfile; renderConfig: MeasuredRenderConfig;
153
+ }
154
+ export interface FrameSourceBinding { frame: number; sourceId: string; sha256: string }
155
+ /** Shared by canonical jobs and delivery manifests; known means configuration/PNG evidence, not per-pixel samples. */
156
+ export interface FrameProvenanceSummary {
157
+ schemaVersion: 'deepblend.frame-sources/v1';
158
+ coverage: 'complete' | 'partial' | 'unknown'; configuration: 'uniform' | 'mixed' | 'unknown';
159
+ groups: Array<{ renderConfig: MeasuredRenderConfig; frames: number[]; sourceIds: string[] }>;
160
+ sources: FrameRenderSource[]; frames: FrameSourceBinding[]; unknownFrames: number[]; missingFrames: number[];
161
+ knownFrameCount: number; unknownFrameCount: number; missingFrameCount: number;
162
+ /** A bounded read/metadata limit deferred attribution; omitted frames are not called missing or corrupt. */
163
+ limited?: true;
164
+ limitation?: 'status-budget' | 'frame-limit' | 'source-byte-limit' | 'journal-byte-limit' | 'journal-line-limit';
165
+ uncheckedFrameCount?: number;
166
+ }
167
+
168
+ export interface FrameSequenceOutcome {
169
+ /** Parsed but not yet validated. The Host checks protocol, completion and durable frame evidence. */
170
+ envelope: unknown; stdout: string; stderr: string; exitCode: number | null; signal: string | null;
171
+ durationMs: number; spawnFailure: unknown;
172
+ }
173
+ export interface EngineResolution { blenderEngine: string | null; requested: string | null; downgraded: boolean; warning: RuntimeWarning | null }
174
+ /** Required execution methods used by the current Host; see docs/public-api.md for lifecycle requirements. */
175
+ export interface BlenderRuntime {
176
+ getCapabilities(options?: RuntimeOptions & { refresh?: boolean }): Promise<BlenderCapabilities>;
177
+ invalidateCapabilities(): void;
178
+ resolveEngineKey(engineKey: EngineKey, options?: RuntimeOptions): Promise<EngineResolution>;
179
+ compileScene(request: CompileSceneRequest): Promise<RuntimeActionResult<CompileReport>>;
180
+ renderPreview(request: RenderPreviewRequest): Promise<RuntimeActionResult<PreviewReport>>;
181
+ renderViews(request: RenderViewsRequest): Promise<RenderViewsResult>;
182
+ startFrameSequence(request: FrameSequenceRequest): Promise<FrameSequenceRun>;
183
+ awaitFrameSequence(run: FrameSequenceRun): Promise<FrameSequenceOutcome>;
184
+ dispose(): void;
185
+ }
186
+ export interface BootstrapOptions extends RuntimeOptions {
187
+ session?: boolean; args?: string[]; cwd?: string; projectRoot?: string;
188
+ prepareDirectory?: (info: { directory: string; jobId: string }) => void | Promise<void>;
189
+ onWorkingDirectory?: (info: BootstrapWorkingDirectory) => void | Promise<void>;
190
+ }
191
+ export interface BootstrapRunOutcome {
192
+ envelope: BlenderBootstrapEnvelope; stdout: string; stderr: string; exitCode: number | null; durationMs: number;
193
+ /** Present for batch runs; it usually names an already removed directory. */
194
+ workingDirectory?: string | null;
195
+ }
196
+ export interface BlenderSession {
197
+ readonly pid: number | null; readonly workspace: string | null;
198
+ run(request: BlenderBootstrapRequest & { args?: string[] }, options?: RuntimeOptions): Promise<BlenderBootstrapEnvelope>;
199
+ close(): Promise<void>;
200
+ }
201
+ /** Optional local transport extensions. Remote runtimes need only implement BlenderRuntime. */
202
+ export interface LocalBlenderRuntime extends BlenderRuntime {
203
+ runBootstrap(request: BlenderBootstrapRequest, options?: BootstrapOptions): Promise<BootstrapRunOutcome>;
204
+ openSession(options?: RuntimeOptions & { cwd?: string; args?: string[] }): Promise<BlenderSession>;
205
+ attachSession(socketPath: string, options?: { timeoutMs?: number }): Promise<BlenderSession>;
206
+ closeSession(): Promise<void>;
207
+ resolveBlenderExecutable(options?: RuntimeOptions): Promise<{ requested: string; resolved: string | null; error: BlenderError | null; advice?: string }>;
208
+ }
@@ -18,6 +18,7 @@
18
18
 
19
19
  import { compileSchema, formatIssues } from './json-schema.js'
20
20
  import { collectionForKind, sceneSpecDigest, specHash } from './scene-spec.js'
21
+ import { referenceImageIssues } from './reference-images.js'
21
22
  import scenePatchSchema from './schemas/scene-patch.schema.json' with { type: 'json' }
22
23
 
23
24
  const validatePatchStructure = compileSchema(scenePatchSchema, { id: 'scene-patch.schema.json' })
@@ -53,6 +54,13 @@ export const SCENE_OPERATION_NAMES = Object.freeze([
53
54
  // accepted `material.texture.set` while this list (and therefore the manual and the tool's description) still
54
55
  // had 23 names, which `scene-patch.test.mjs`'s schema-vs-constant check is the only check able to see.
55
56
  'material.texture.set',
57
+ 'entity.generator.set',
58
+ 'entity.modifiers.set',
59
+ 'material.images.set',
60
+ 'entity.materialBindings.set',
61
+ 'material.tangent.set',
62
+ 'project.brief.set',
63
+ 'project.reviewSubject.set',
56
64
  ])
57
65
 
58
66
  /** The `id` grammar shared with SceneSpec. */const ID_PATTERN = /^[a-zA-Z][a-zA-Z0-9._-]*$/
@@ -105,6 +113,12 @@ export function validateScenePatch(patch) {
105
113
  const at = `operations[${position}]`
106
114
  const op = operation.op
107
115
 
116
+ if (op === 'project.brief.set') {
117
+ for (const issue of referenceImageIssues(operation.referenceImages)) {
118
+ errors.push({ code: 'PATCH_OPERATION_INVALID', path: `${at}.referenceImages${issue.path}`, message: issue.message })
119
+ }
120
+ }
121
+
108
122
  if (op === 'entity.transform.update') {
109
123
  const supplied = ['location', 'rotationEuler', 'scale'].filter(key => operation[key] !== undefined)
110
124
  if (supplied.length === 0) {
@@ -149,6 +163,11 @@ export function validateScenePatch(patch) {
149
163
  })
150
164
  }
151
165
  }
166
+ if (op === 'material.parameter.update' && ['anisotropic', 'anisotropicRotation'].includes(operation.parameter) &&
167
+ (!Number.isFinite(operation.value) || operation.value < 0 || operation.value > 1)) {
168
+ errors.push({ code: 'PATCH_OPERATION_INVALID', path: `${at}.value`,
169
+ message: `${operation.parameter} must be a finite scalar in [0, 1]` })
170
+ }
152
171
  for (const key of ['entityId', 'materialId', 'lightId', 'cameraId', 'trackId', 'shotId']) {
153
172
  const value = operation[key]
154
173
  if (typeof value === 'string' && !ID_PATTERN.test(value)) {
@@ -402,6 +421,51 @@ export function applyPatchToSpec(spec, patch) {
402
421
  break
403
422
  }
404
423
 
424
+ case 'entity.materialBindings.set': {
425
+ const at = indexOfId(next.entities, operation.entityId)
426
+ if (at < 0) fail('PATCH_TARGET_MISSING', `no entity "${operation.entityId}" exists in this scene`)
427
+ const entity = next.entities[at]
428
+ if (entity.type !== 'asset-instance') fail('PATCH_OPERATION_INVALID', `${op} requires an asset-instance entity`)
429
+ const selectors = new Set()
430
+ for (const binding of operation.materialBindings) {
431
+ if (indexOfId(next.materials, binding.materialId) < 0) fail('PATCH_REFERENCE_MISSING', `no material "${binding.materialId}" exists`)
432
+ const key = JSON.stringify([binding.partId, binding.slotIndex ?? null])
433
+ if (selectors.has(key)) fail('PATCH_OPERATION_INVALID', 'duplicate part and slot material binding')
434
+ selectors.add(key)
435
+ }
436
+ const changed = JSON.stringify(entity.materialBindings ?? []) !== JSON.stringify(operation.materialBindings)
437
+ if (changed) {
438
+ const updated = { ...entity, materialBindings: structuredClone(operation.materialBindings) }
439
+ if (!updated.materialBindings.length) delete updated.materialBindings
440
+ next.entities[at] = updated
441
+ }
442
+ applied.push({ op, target: entity.id, summary: `set material bindings on entity "${entity.id}"`,
443
+ changedPaths: changed ? [`entities.${entity.id}.materialBindings`] : [] })
444
+ break
445
+ }
446
+
447
+ case 'entity.generator.set':
448
+ case 'entity.modifiers.set': {
449
+ const at = indexOfId(next.entities, operation.entityId)
450
+ if (at < 0) fail('PATCH_TARGET_MISSING', `no entity "${operation.entityId}" exists in this scene`)
451
+ const entity = next.entities[at]
452
+ const field = op === 'entity.generator.set' ? 'generator' : 'modifiers'
453
+ if ((field === 'generator' && entity.type !== 'generator') || entity.type === 'empty') {
454
+ fail('PATCH_OPERATION_INVALID', `${op} cannot edit a ${entity.type} entity`)
455
+ }
456
+ const before = entity[field] ?? (field === 'modifiers' ? [] : undefined)
457
+ const value = structuredClone(operation[field])
458
+ const changed = JSON.stringify(before) !== JSON.stringify(value)
459
+ if (changed) {
460
+ const updated = { ...entity, [field]: value }
461
+ if (field === 'modifiers' && value.length === 0) delete updated.modifiers
462
+ next.entities[at] = updated
463
+ }
464
+ applied.push({ op, target: entity.id, summary: `set ${field} on entity "${entity.id}"`,
465
+ changedPaths: changed ? [`entities.${entity.id}.${field}`] : [] })
466
+ break
467
+ }
468
+
405
469
  case 'entity.add': {
406
470
  if (indexOfId(next.entities, operation.entity.id) >= 0) {
407
471
  fail('PATCH_TARGET_EXISTS', `entity "${operation.entity.id}" already exists; use entity.transform.update instead`)
@@ -428,6 +492,11 @@ export function applyPatchToSpec(spec, patch) {
428
492
  fail('PATCH_TARGET_MISSING', `no entity "${operation.entityId}" exists in this scene`)
429
493
  }
430
494
  const dependents = []
495
+ if (next.project.reviewSubjectId === operation.entityId) dependents.push('review subject')
496
+ if (next.entities.some(entity => (entity.modifiers ?? []).some(modifier =>
497
+ modifier.type === 'boolean' && modifier.targetEntityId === operation.entityId))) {
498
+ dependents.push('boolean operand')
499
+ }
431
500
  if (next.cameras.some(camera => camera.targetEntityId === operation.entityId)) {
432
501
  dependents.push('camera target')
433
502
  }
@@ -516,6 +585,34 @@ export function applyPatchToSpec(spec, patch) {
516
585
  break
517
586
  }
518
587
 
588
+ case 'material.tangent.set': {
589
+ const at = indexOfId(next.materials, operation.materialId)
590
+ if (at < 0) fail('PATCH_TARGET_MISSING', `no material "${operation.materialId}" exists in this scene`)
591
+ const material = { ...next.materials[at] }
592
+ if (operation.tangent === null) delete material.tangent
593
+ else material.tangent = structuredClone(operation.tangent)
594
+ next.materials = [...next.materials]
595
+ next.materials[at] = material
596
+ applied.push({ op, target: material.id, summary: `set anisotropic tangent on material "${material.id}"`,
597
+ changedPaths: [`materials.${material.id}.tangent`] })
598
+ break
599
+ }
600
+
601
+ case 'material.images.set': {
602
+ const at = indexOfId(next.materials, operation.materialId)
603
+ if (at < 0) fail('PATCH_TARGET_MISSING', `no material "${operation.materialId}" exists in this scene`)
604
+ const material = { ...next.materials[at] }
605
+ for (const binding of Object.values(operation.images ?? {})) {
606
+ if (indexOfId(next.assets, binding.assetId) < 0) fail('PATCH_REFERENCE_MISSING', `no image asset "${binding.assetId}" exists`)
607
+ }
608
+ if (operation.images === null) delete material.images
609
+ else material.images = structuredClone(operation.images)
610
+ next.materials[at] = material
611
+ applied.push({ op, target: material.id, summary: `set image maps on material "${material.id}"`,
612
+ changedPaths: [`materials.${material.id}.images`] })
613
+ break
614
+ }
615
+
519
616
  case 'material.texture.set': {
520
617
  const at = indexOfId(next.materials, operation.materialId)
521
618
  if (at < 0) fail('PATCH_TARGET_MISSING', `no material "${operation.materialId}" exists in this scene`)
@@ -758,6 +855,29 @@ export function applyPatchToSpec(spec, patch) {
758
855
  }
759
856
 
760
857
  // ---- project and profiles -------------------------------------------
858
+ case 'project.reviewSubject.set': {
859
+ const entity = next.entities.find(entry => entry.id === operation.entityId)
860
+ if (operation.entityId !== null && !entity) fail('PATCH_REFERENCE_MISSING', `review subject "${operation.entityId}" does not exist`)
861
+ if (entity?.type === 'empty') fail('PATCH_OPERATION_INVALID', 'The review subject must be a renderable entity, not an empty.')
862
+ const project = { ...next.project }
863
+ if (operation.entityId === null) delete project.reviewSubjectId
864
+ else project.reviewSubjectId = operation.entityId
865
+ next = { ...next, project }
866
+ applied.push({ op, target: project.id,
867
+ summary: operation.entityId === null ? 'restored automatic review subject selection' : `set review subject to "${operation.entityId}"`,
868
+ changedPaths: ['project.reviewSubjectId'] })
869
+ break
870
+ }
871
+ case 'project.brief.set': {
872
+ const issues = referenceImageIssues(operation.referenceImages, next.assets ?? [])
873
+ if (issues.length > 0) fail(issues[0].code === 'SCENE_REFERENCE_MISSING'
874
+ ? 'PATCH_REFERENCE_MISSING' : 'PATCH_OPERATION_INVALID', issues[0].message)
875
+ next = { ...next, project: { ...next.project, goal: operation.goal,
876
+ referenceImages: structuredClone(operation.referenceImages) } }
877
+ applied.push({ op, target: next.project.id, summary: 'replaced the authored goal and visual references',
878
+ changedPaths: ['project.goal', 'project.referenceImages'] })
879
+ break
880
+ }
761
881
  case 'project.frameRange.set': {
762
882
  if (operation.frameEnd <= operation.frameStart) {
763
883
  fail('PATCH_FRAME_RANGE_INVALID', `frameEnd (${operation.frameEnd}) must be greater than frameStart (${operation.frameStart})`)
@@ -817,6 +937,9 @@ export function applyPatchToSpec(spec, patch) {
817
937
  }
818
938
 
819
939
  case 'world.set': {
940
+ if (operation.world.environment && indexOfId(next.assets, operation.world.environment.assetId) < 0) {
941
+ fail('PATCH_REFERENCE_MISSING', `no environment asset "${operation.world.environment.assetId}" exists`)
942
+ }
820
943
  // The world REPLACES rather than merges, matching how a caller thinks about
821
944
  // "make the background black": a half-updated world is how the hidden grey
822
945
  // constant survived this long in the first place.
@@ -869,6 +992,12 @@ export function applyPatchToSpec(spec, patch) {
869
992
  }
870
993
 
871
994
  case 'asset.remove': {
995
+ if ((next.project.referenceImages ?? []).some(reference => reference.assetId === operation.assetId)) {
996
+ fail('PATCH_TARGET_IN_USE', `asset "${operation.assetId}" is still used by project.referenceImages`)
997
+ }
998
+ if (next.world?.environment?.assetId === operation.assetId) {
999
+ fail('PATCH_TARGET_IN_USE', `asset "${operation.assetId}" is still used by world.environment`)
1000
+ }
872
1001
  if (indexOfId(next.assets, operation.assetId) < 0) {
873
1002
  fail('PATCH_TARGET_MISSING', `no asset "${operation.assetId}" exists in this scene`)
874
1003
  }
@@ -876,6 +1005,9 @@ export function applyPatchToSpec(spec, patch) {
876
1005
  // instantiates this asset would be left pointing at nothing, and the compiler
877
1006
  // would then refuse a scene the patch claimed to have produced.
878
1007
  const dependents = next.entities.filter(entity => entity.assetId === operation.assetId)
1008
+ if ((next.materials ?? []).some(material => Object.values(material.images ?? {}).some(binding => binding.assetId === operation.assetId))) {
1009
+ fail('PATCH_TARGET_IN_USE', `asset "${operation.assetId}" is still referenced by material image maps`)
1010
+ }
879
1011
  if (dependents.length > 0) {
880
1012
  fail(
881
1013
  'PATCH_TARGET_IN_USE',
@@ -929,7 +1061,7 @@ export function buildOperationManifest({ operations, request, revision, notices
929
1061
  specHashBefore: revision.specHashBefore ?? null,
930
1062
  specHashAfter: revision.specHashAfter ?? null,
931
1063
  // Two different questions, two different answers. `sceneChanged` excludes
932
- // project.title/goal by design (see sceneSpecDigest); `specChanged` covers the
1064
+ // project metadata and brief by design (see sceneSpecDigest); `specChanged` covers the
933
1065
  // whole document, so the frame range counts.
934
1066
  sceneChanged: revision.digestBefore !== revision.digestAfter,
935
1067
  specChanged: (revision.specHashBefore ?? null) !== null