@deepblend/dsh-blender-host 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/delivery-manifest.js +177 -0
- package/lib/frame-ledger.js +189 -0
- package/lib/index.js +4778 -0
- package/lib/paths.js +314 -0
- package/lib/project-store.js +528 -0
- package/lib/render-job-store.js +290 -0
- package/lib/render-journal.js +236 -0
- package/lib/render-reconciler.js +340 -0
- package/lib/revision-transaction.js +1064 -0
- package/lib/video-encoder.js +345 -0
- package/package.json +34 -0
|
@@ -0,0 +1,1064 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Revision transaction — the atomic commit path (SPEC §13.2).
|
|
3
|
+
*
|
|
4
|
+
* ```
|
|
5
|
+
* validate the patch
|
|
6
|
+
* → resolve the idempotency key (a retry must not apply twice)
|
|
7
|
+
* → check baseRevision against current (a stale write must not overwrite)
|
|
8
|
+
* → apply operations to the stored SceneSpec in memory
|
|
9
|
+
* → JSON-Schema + semantic validation
|
|
10
|
+
* → compile in Blender into staging/
|
|
11
|
+
* → technical validation of the compiled scene
|
|
12
|
+
* → write the revision manifest
|
|
13
|
+
* → atomic rename staging → revisions/<id>
|
|
14
|
+
* → move the current pointer
|
|
15
|
+
* → record the idempotency outcome
|
|
16
|
+
* ```
|
|
17
|
+
*
|
|
18
|
+
* WHY NOTHING IS WRITTEN IN PLACE
|
|
19
|
+
* -------------------------------
|
|
20
|
+
* Every mutation happens either in memory or inside `<project>/staging/<rev>/`.
|
|
21
|
+
* The revision directory appears, fully formed, from a single `rename`. So the
|
|
22
|
+
* failure modes the SPEC cares about are structural rather than remembered:
|
|
23
|
+
*
|
|
24
|
+
* - "失败不污染当前 Revision" holds because the current revision's directory is
|
|
25
|
+
* never opened for writing at all — not because an error handler cleans up.
|
|
26
|
+
* - "不产生半提交 Revision" holds because a directory that has not been renamed
|
|
27
|
+
* is not a revision.
|
|
28
|
+
* - A crash leaves at most a staging directory, which `sweepStaging()` removes
|
|
29
|
+
* and which no reader ever consults.
|
|
30
|
+
*
|
|
31
|
+
* ORDERING: THE POINTER MOVES LAST
|
|
32
|
+
* --------------------------------
|
|
33
|
+
* `project.json` is updated only after the revision directory is published. The
|
|
34
|
+
* two writes are not one transaction, so one of them can be interrupted — and the
|
|
35
|
+
* order decides which interruption is survivable. Publishing then failing to move
|
|
36
|
+
* the pointer leaves an unreferenced but complete revision: visible, correct, and
|
|
37
|
+
* one `restoreRevision` away from usable. Moving the pointer first would leave a
|
|
38
|
+
* current revision that does not exist, which is unrecoverable.
|
|
39
|
+
*
|
|
40
|
+
* Owner: DeepBlend Studio — M1
|
|
41
|
+
* Plane: Host composition
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
import { existsSync, mkdirSync, readdirSync, renameSync, statSync } from 'node:fs'
|
|
45
|
+
import { join } from 'node:path'
|
|
46
|
+
|
|
47
|
+
import {
|
|
48
|
+
BlenderError,
|
|
49
|
+
BlenderErrorCode,
|
|
50
|
+
BlenderWarningCode,
|
|
51
|
+
PROJECT_RECORD_VERSION,
|
|
52
|
+
REVISION_MANIFEST_VERSION,
|
|
53
|
+
SCENE_PATCH_VERSION,
|
|
54
|
+
applyPatchToSpec,
|
|
55
|
+
buildOperationManifest,
|
|
56
|
+
compileSceneSpec,
|
|
57
|
+
sceneSpecCanonicalText,
|
|
58
|
+
sceneSpecDigest,
|
|
59
|
+
shortDigest,
|
|
60
|
+
specHash,
|
|
61
|
+
summarizeSceneSpec,
|
|
62
|
+
toCanonicalFailure,
|
|
63
|
+
validateScenePatch,
|
|
64
|
+
validateSceneSpec,
|
|
65
|
+
warning,
|
|
66
|
+
} from '@deepblend/dsh-blender-contracts'
|
|
67
|
+
import { GENESIS_REVISION, parseRevisionId } from './project-store.js'
|
|
68
|
+
import {
|
|
69
|
+
fileSha256,
|
|
70
|
+
fileSize,
|
|
71
|
+
isFile,
|
|
72
|
+
publishDirectory,
|
|
73
|
+
removeTree,
|
|
74
|
+
writeFileAtomic,
|
|
75
|
+
writeJsonAtomic,
|
|
76
|
+
} from './paths.js'
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Derive the idempotency key for a patch when the caller did not supply one.
|
|
80
|
+
*
|
|
81
|
+
* This is a deliberate departure from "require the caller to invent a key", and
|
|
82
|
+
* it is a safety improvement rather than a convenience. A model retrying a tool
|
|
83
|
+
* call it is unsure about is NORMAL behaviour, and a scheme that made identical
|
|
84
|
+
* retries create duplicate revisions would punish exactly the cautious caller.
|
|
85
|
+
*
|
|
86
|
+
* The derived key covers the whole logical intent: the project, the revision the
|
|
87
|
+
* patch was computed against, who asked, and every operation. So:
|
|
88
|
+
* - an identical retry produces the same key and returns the recorded outcome
|
|
89
|
+
* without re-applying anything (SPEC §20 M1 "同一幂等键不会重复提交");
|
|
90
|
+
* - a genuinely different patch produces a different key and applies normally,
|
|
91
|
+
* even against the same base revision;
|
|
92
|
+
* - the same operations against a LATER base revision also produce a different
|
|
93
|
+
* key, because they describe a different state and must not be conflated.
|
|
94
|
+
*
|
|
95
|
+
* A caller that wants to re-apply the same operations on purpose passes its own
|
|
96
|
+
* `idempotencyKey` — which is also the only way to express that intent.
|
|
97
|
+
*
|
|
98
|
+
* @param {object} patch
|
|
99
|
+
* @returns {string}
|
|
100
|
+
*/
|
|
101
|
+
export function deriveIdempotencyKey(patch) {
|
|
102
|
+
return `auto-${shortDigest({
|
|
103
|
+
projectId: patch.projectId,
|
|
104
|
+
baseRevision: patch.baseRevision,
|
|
105
|
+
actor: patch.actor ?? null,
|
|
106
|
+
stage: patch.stage ?? null,
|
|
107
|
+
operations: patch.operations,
|
|
108
|
+
})}`
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Resolve a caller-supplied or derived idempotency key.
|
|
113
|
+
* @param {object} patch
|
|
114
|
+
* @returns {{ key: string, derived: boolean }}
|
|
115
|
+
*/
|
|
116
|
+
export function resolveIdempotencyKey(patch) {
|
|
117
|
+
if (typeof patch.idempotencyKey === 'string' && patch.idempotencyKey.trim().length > 0) {
|
|
118
|
+
return { key: patch.idempotencyKey, derived: false }
|
|
119
|
+
}
|
|
120
|
+
return { key: deriveIdempotencyKey(patch), derived: true }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The revision transaction runner.
|
|
125
|
+
*
|
|
126
|
+
* One instance per `blenderStudio` service; it holds no state of its own beyond
|
|
127
|
+
* the stores and the runtime it was built with, so a second project cannot leak
|
|
128
|
+
* into the first.
|
|
129
|
+
*/
|
|
130
|
+
export class RevisionTransaction {
|
|
131
|
+
/**
|
|
132
|
+
* @param {object} options
|
|
133
|
+
* @param {import('./project-store.js').ProjectStore} options.store
|
|
134
|
+
* @param {object} options.runtime - the `blenderRuntime` service.
|
|
135
|
+
* @param {{ compileTimeoutMs?: number, projectsRoot: string }} options.config
|
|
136
|
+
*/
|
|
137
|
+
constructor({ store, runtime, config }) {
|
|
138
|
+
this.store = store
|
|
139
|
+
this.runtime = runtime
|
|
140
|
+
this.config = config
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** The store's own account of what is current; never cached here. */
|
|
144
|
+
currentRevision(projectId) {
|
|
145
|
+
return this.store.currentRevision(projectId)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Remove abandoned staging directories.
|
|
150
|
+
*
|
|
151
|
+
* Called before each transaction rather than on a timer: a staging directory
|
|
152
|
+
* can only be abandoned by a process that died, and the next transaction is
|
|
153
|
+
* exactly when we know we are alive and it is stale.
|
|
154
|
+
*
|
|
155
|
+
* @param {string} projectId
|
|
156
|
+
* @param {string} [keep] - a staging directory to preserve (this transaction's).
|
|
157
|
+
*/
|
|
158
|
+
sweepStaging(projectId, keep) {
|
|
159
|
+
const staging = join(this.store.projectDirectory(projectId), 'staging')
|
|
160
|
+
if (!existsSync(staging)) return
|
|
161
|
+
for (const entry of readdirSafe(staging)) {
|
|
162
|
+
if (entry === keep) continue
|
|
163
|
+
removeTree(join(staging, entry))
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Create a project by committing its first revision.
|
|
169
|
+
*
|
|
170
|
+
* `project_create` is not a separate kind of operation: a project without a
|
|
171
|
+
* revision would be a directory with no scene, and every read path would then
|
|
172
|
+
* need a special case for it. Committing r0001 immediately means the invariant
|
|
173
|
+
* "a project has a current revision" holds from the moment it is visible.
|
|
174
|
+
*
|
|
175
|
+
* @param {object} input
|
|
176
|
+
* @param {string} input.title
|
|
177
|
+
* @param {string} [input.goal]
|
|
178
|
+
* @param {object} [input.sceneSpec] - a full SceneSpec to seed the project with.
|
|
179
|
+
* @param {string} [input.projectId] - an explicit id; derived from the title otherwise.
|
|
180
|
+
* @param {boolean} [input.saveCheckpoint]
|
|
181
|
+
* @param {boolean} [input.renderPreview]
|
|
182
|
+
* @param {string} [input.jobId]
|
|
183
|
+
* @param {AbortSignal} [input.signal]
|
|
184
|
+
* @returns {Promise<{ projectId: string, record: object, revision: object, job: object, warnings: object[] }>}
|
|
185
|
+
*/
|
|
186
|
+
async createProject(input) {
|
|
187
|
+
const title = String(input.title ?? '').trim()
|
|
188
|
+
if (title.length === 0) {
|
|
189
|
+
throw new BlenderError(BlenderErrorCode.PROJECT_ID_INVALID, 'A project needs a non-empty title.')
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const projectId = input.projectId ?? this.store.allocateProjectId(title)
|
|
193
|
+
if (this.store.exists(projectId)) {
|
|
194
|
+
throw new BlenderError(
|
|
195
|
+
BlenderErrorCode.PROJECT_EXISTS,
|
|
196
|
+
`A project named "${projectId}" already exists.`,
|
|
197
|
+
{ detail: { projectId } },
|
|
198
|
+
)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const seeded = input.sceneSpec ?? defaultSceneSpec({ projectId, title, goal: input.goal })
|
|
202
|
+
const identified = {
|
|
203
|
+
...seeded,
|
|
204
|
+
project: { ...seeded.project, id: projectId, title, ...input.goal !== undefined ? { goal: input.goal } : {} },
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const validation = validateSceneSpec(identified)
|
|
208
|
+
if (!validation.ok) {
|
|
209
|
+
throw new BlenderError(
|
|
210
|
+
BlenderErrorCode.SCENE_SPEC_INVALID,
|
|
211
|
+
`The initial SceneSpec for "${projectId}" is not valid:\n${validation.summary}`,
|
|
212
|
+
{ detail: { errors: validation.errors, notices: validation.notices } },
|
|
213
|
+
)
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// RESOLVE BEFORE STORING. This is not cosmetic: the first revision is the
|
|
217
|
+
// spec every later revision is a delta from, so if it were stored unresolved
|
|
218
|
+
// while its digest was computed from the resolved form, every read-back would
|
|
219
|
+
// re-derive a DIFFERENT digest and the revision history would look corrupted.
|
|
220
|
+
// That was a real bug here — `revision r0001 still re-reads and re-compiles
|
|
221
|
+
// deterministically` in fixture.e2e.mjs is the guard that caught it.
|
|
222
|
+
//
|
|
223
|
+
// Resolving is idempotent (it only materialises documented defaults and aims
|
|
224
|
+
// targeted cameras), so a spec that already went through it is unaffected.
|
|
225
|
+
const compiled = compileSceneSpec(identified)
|
|
226
|
+
|
|
227
|
+
this.store.ensureRoot()
|
|
228
|
+
this.store.createSkeleton(projectId)
|
|
229
|
+
|
|
230
|
+
const now = new Date().toISOString()
|
|
231
|
+
this.store.writeRecord(projectId, {
|
|
232
|
+
schemaVersion: PROJECT_RECORD_VERSION,
|
|
233
|
+
projectId,
|
|
234
|
+
title,
|
|
235
|
+
goal: input.goal ?? null,
|
|
236
|
+
createdAt: now,
|
|
237
|
+
updatedAt: now,
|
|
238
|
+
currentRevision: GENESIS_REVISION,
|
|
239
|
+
revisionCount: 0,
|
|
240
|
+
runtimeProtocolVersion: 'deepblend.blender/v1',
|
|
241
|
+
projectsRoot: this.store.projectsRoot,
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
const outcome = await this.commit({
|
|
245
|
+
projectId,
|
|
246
|
+
baseRevision: GENESIS_REVISION,
|
|
247
|
+
operations: [],
|
|
248
|
+
spec: compiled.spec,
|
|
249
|
+
specHashBefore: null,
|
|
250
|
+
specHashAfter: specHash(compiled.spec),
|
|
251
|
+
kind: 'project_create',
|
|
252
|
+
summary: `Initialise project "${title}"`,
|
|
253
|
+
actor: input.actor ?? null,
|
|
254
|
+
stage: 'BRIEF',
|
|
255
|
+
note: input.goal ?? null,
|
|
256
|
+
notices: [...validation.notices, ...compiled.notices],
|
|
257
|
+
saveCheckpoint: input.saveCheckpoint !== false,
|
|
258
|
+
renderPreview: input.renderPreview === true,
|
|
259
|
+
jobId: input.jobId,
|
|
260
|
+
signal: input.signal,
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
return { projectId, ...outcome }
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Apply a ScenePatch as one atomic revision.
|
|
268
|
+
*
|
|
269
|
+
* @param {object} patch - a ScenePatch v1 document.
|
|
270
|
+
* @param {{ jobId?: string, signal?: AbortSignal }} [options]
|
|
271
|
+
* @returns {Promise<object>}
|
|
272
|
+
*/
|
|
273
|
+
async applyScenePatch(patch, options = {}) {
|
|
274
|
+
const structural = validateScenePatch(patch)
|
|
275
|
+
if (!structural.ok) {
|
|
276
|
+
throw new BlenderError(
|
|
277
|
+
BlenderErrorCode.SCENE_PATCH_INVALID,
|
|
278
|
+
`The ScenePatch is not well formed:\n${structural.summary}`,
|
|
279
|
+
{ detail: { errors: structural.errors } },
|
|
280
|
+
)
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const projectId = patch.projectId
|
|
284
|
+
const record = this.store.readRecord(projectId)
|
|
285
|
+
const { key, derived } = resolveIdempotencyKey(patch)
|
|
286
|
+
|
|
287
|
+
// ---------------------------------------------------------------------
|
|
288
|
+
// ORDER MATTERS HERE: idempotency is checked BEFORE the conflict check.
|
|
289
|
+
//
|
|
290
|
+
// A caller that retries a patch it is unsure about is behaving well, and a
|
|
291
|
+
// retry usually arrives AFTER the first attempt succeeded — which means the
|
|
292
|
+
// project has already moved past the patch's `baseRevision`. Checking
|
|
293
|
+
// conflict first would answer that retry with REVISION_CONFLICT, and the
|
|
294
|
+
// caller's only move would be to re-read and re-apply, producing the exact
|
|
295
|
+
// duplicate revision the idempotency key exists to prevent.
|
|
296
|
+
//
|
|
297
|
+
// Asking "have I already done exactly this?" first makes the retry return
|
|
298
|
+
// its own earlier outcome, which is the honest answer: the work is done, and
|
|
299
|
+
// here is what it produced.
|
|
300
|
+
// ---------------------------------------------------------------------
|
|
301
|
+
const recorded = this.store.readIdempotencyRecord(projectId, key)
|
|
302
|
+
if (recorded !== null) {
|
|
303
|
+
return {
|
|
304
|
+
...recorded.outcome,
|
|
305
|
+
idempotentReplay: true,
|
|
306
|
+
idempotencyKey: key,
|
|
307
|
+
idempotencyKeyDerived: derived,
|
|
308
|
+
warnings: [warning(
|
|
309
|
+
BlenderWarningCode.SCENE_COMPILER_DECISION,
|
|
310
|
+
`this exact patch was already applied as ${recorded.outcome.revision?.revision ?? recorded.outcome.revision} ` +
|
|
311
|
+
`at ${recorded.recordedAt}; the recorded outcome was returned and nothing was committed again`,
|
|
312
|
+
{ revision: recorded.outcome.revision?.revision ?? recorded.outcome.revision, recordedAt: recorded.recordedAt },
|
|
313
|
+
)],
|
|
314
|
+
replayOf: recorded.outcome.revision?.revision ?? recorded.outcome.revision,
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// ---- conflict: a stale view is refused, never merged -------------------
|
|
319
|
+
if (patch.baseRevision !== record.currentRevision) {
|
|
320
|
+
throw new BlenderError(
|
|
321
|
+
BlenderErrorCode.REVISION_CONFLICT,
|
|
322
|
+
`The patch was computed against ${patch.baseRevision} but "${projectId}" is at ${record.currentRevision}. ` +
|
|
323
|
+
'Read the scene again and re-apply against the current revision — this revision is refused rather than merged, ' +
|
|
324
|
+
'because merging silently would discard whatever changed in between.',
|
|
325
|
+
{
|
|
326
|
+
detail: {
|
|
327
|
+
projectId,
|
|
328
|
+
baseRevision: patch.baseRevision,
|
|
329
|
+
currentRevision: record.currentRevision,
|
|
330
|
+
hint: 'call blender_scene_get, then re-issue the patch with the returned revision as baseRevision',
|
|
331
|
+
},
|
|
332
|
+
},
|
|
333
|
+
)
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const baseSpec = this.store.readRevisionSpec(projectId, record.currentRevision)
|
|
337
|
+
const compiled = compileSceneSpec(baseSpec)
|
|
338
|
+
|
|
339
|
+
// ---- apply in memory ---------------------------------------------------
|
|
340
|
+
/** @type {object} */
|
|
341
|
+
let nextSpec
|
|
342
|
+
/** @type {object[]} */
|
|
343
|
+
let operationRecords
|
|
344
|
+
let digestBefore
|
|
345
|
+
let digestAfter
|
|
346
|
+
let specHashBefore
|
|
347
|
+
let specHashAfter
|
|
348
|
+
try {
|
|
349
|
+
const applied = applyPatchToSpec(compiled.spec, patch)
|
|
350
|
+
nextSpec = applied.spec
|
|
351
|
+
operationRecords = applied.operations
|
|
352
|
+
digestBefore = applied.digestBefore
|
|
353
|
+
digestAfter = applied.digestAfter
|
|
354
|
+
specHashBefore = applied.specHashBefore
|
|
355
|
+
specHashAfter = applied.specHashAfter
|
|
356
|
+
} catch (cause) {
|
|
357
|
+
const failure = toCanonicalFailure(cause, { isBlenderError: value => value instanceof BlenderError, fallbackCode: 'SCENE_PATCH_REJECTED' })
|
|
358
|
+
throw new BlenderError(
|
|
359
|
+
failure.code === 'SCENE_PATCH_REJECTED' ? BlenderErrorCode.SCENE_PATCH_REJECTED : failure.code,
|
|
360
|
+
`The patch was rejected and no revision was created: ${failure.message}`,
|
|
361
|
+
{ detail: { projectId, baseRevision: patch.baseRevision, path: failure.detail?.path ?? null } },
|
|
362
|
+
)
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// ---- a declared asset hash must be the file's hash ---------------------
|
|
366
|
+
//
|
|
367
|
+
// `asset.add` carries a `sha256` the patch APPLIER cannot check: it is a pure function over documents with no
|
|
368
|
+
// filesystem, which is the right shape for it. But the hash it stores is provenance a later reader trusts —
|
|
369
|
+
// the delivery manifest, a human auditing where the bytes came from — and `blender_asset_ingest` COMPUTES the
|
|
370
|
+
// hash of what it wrote, so the two copies can disagree with nothing comparing them. MEASURED before this:
|
|
371
|
+
// nothing did, on either side, and the Python never reads `assets[].sha256` at all.
|
|
372
|
+
//
|
|
373
|
+
// Checked here, in the transaction, because this is the layer that has both the document and the store. A
|
|
374
|
+
// path with no file is left alone: declaring an asset before its bytes exist is a different question, and the
|
|
375
|
+
// compiler refuses that one loudly.
|
|
376
|
+
for (const operation of patch.operations ?? []) {
|
|
377
|
+
if (operation?.op !== 'asset.add') continue
|
|
378
|
+
const declared = operation.asset?.sha256
|
|
379
|
+
const assetPath = operation.asset?.path
|
|
380
|
+
if (typeof declared !== 'string' || typeof assetPath !== 'string') continue
|
|
381
|
+
const absolute = join(this.store.projectDirectory(projectId), assetPath)
|
|
382
|
+
if (!existsSync(absolute)) continue
|
|
383
|
+
const actual = fileSha256(absolute)
|
|
384
|
+
if (actual !== declared) {
|
|
385
|
+
throw new BlenderError(
|
|
386
|
+
BlenderErrorCode.ASSET_HASH_MISMATCH,
|
|
387
|
+
`asset "${operation.asset.id}" declares sha256 ${declared}, but ${assetPath} is ${actual}. Nothing was ` +
|
|
388
|
+
'committed. The declaration is provenance a later reader trusts, so it has to be the file\u2019s own ' +
|
|
389
|
+
'hash — blender_asset_ingest reports the right one for what it wrote.',
|
|
390
|
+
{ detail: { projectId, assetId: operation.asset.id, path: assetPath, declared, actual } },
|
|
391
|
+
)
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// ---- resolve the RESULT before anything reads it -----------------------
|
|
396
|
+
//
|
|
397
|
+
// The base was compiled above, but the patch result was not — and every `*.add`
|
|
398
|
+
// operation inserts the caller's object verbatim. So a patch could store a
|
|
399
|
+
// document that no other part of the system is prepared to read:
|
|
400
|
+
//
|
|
401
|
+
// `camera.add` without `transform` → `summarizeSceneSpec` threw a TypeError on
|
|
402
|
+
// `camera.transform.location`, taking the whole commit response down with it.
|
|
403
|
+
// `entity.add` with a generator that omits its shape's size field — legal, both
|
|
404
|
+
// are optional — left `boundsOf` computing `undefined * n` = NaN, and a NaN
|
|
405
|
+
// reaches the model as `null` because that is what JSON does to it. The tool
|
|
406
|
+
// call SUCCEEDED and the harness rejected its own result as "not lossless
|
|
407
|
+
// JSON", which is precisely the symptom of a NaN.
|
|
408
|
+
// And the property M1 exists to guarantee — digest(stored) == digest(compile
|
|
409
|
+
// (stored)), so a revision can always be re-read and re-derived — was false
|
|
410
|
+
// for every revision with an added object.
|
|
411
|
+
//
|
|
412
|
+
// Compiling here is the same fix M1 applied to `createProject` (decision D19),
|
|
413
|
+
// for the same reason: whoever writes the document owns resolving it, because a
|
|
414
|
+
// reader that has to guess is a reader that will guess differently.
|
|
415
|
+
//
|
|
416
|
+
// Compilation materialises DOCUMENTED defaults only, and is idempotent, so a
|
|
417
|
+
// patch that adds nothing new is unaffected — the digest of an unrelated change
|
|
418
|
+
// does not move.
|
|
419
|
+
const resolved = compileSceneSpec(nextSpec)
|
|
420
|
+
nextSpec = resolved.spec
|
|
421
|
+
digestAfter = sceneSpecDigest(nextSpec)
|
|
422
|
+
specHashAfter = specHash(nextSpec)
|
|
423
|
+
|
|
424
|
+
const validation = validateSceneSpec(nextSpec)
|
|
425
|
+
if (!validation.ok) {
|
|
426
|
+
throw new BlenderError(
|
|
427
|
+
BlenderErrorCode.SCENE_SPEC_INVALID,
|
|
428
|
+
`Applying the patch would produce an invalid SceneSpec, so no revision was created:\n${validation.summary}`,
|
|
429
|
+
{ detail: { errors: validation.errors, notices: validation.notices } },
|
|
430
|
+
)
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const summary = patch.note ?? describeOperations(operationRecords)
|
|
434
|
+
const outcome = await this.commit({
|
|
435
|
+
projectId,
|
|
436
|
+
baseRevision: patch.baseRevision,
|
|
437
|
+
operations: patch.operations,
|
|
438
|
+
spec: nextSpec,
|
|
439
|
+
kind: 'scene_patch',
|
|
440
|
+
summary,
|
|
441
|
+
actor: patch.actor ?? null,
|
|
442
|
+
stage: patch.stage ?? null,
|
|
443
|
+
note: patch.note ?? null,
|
|
444
|
+
saveCheckpoint: patch.saveCheckpoint !== false,
|
|
445
|
+
renderPreview: patch.renderPreview === true,
|
|
446
|
+
digestBefore,
|
|
447
|
+
digestAfter,
|
|
448
|
+
operationRecords,
|
|
449
|
+
notices: [...validation.notices, ...compiled.notices, ...resolved.notices],
|
|
450
|
+
derivedIdempotencyKey: derived,
|
|
451
|
+
jobId: options.jobId,
|
|
452
|
+
signal: options.signal,
|
|
453
|
+
})
|
|
454
|
+
|
|
455
|
+
this.store.writeIdempotencyRecord(projectId, key, {
|
|
456
|
+
// The FULL outcome, not a pointer to it. A replay must be able to answer
|
|
457
|
+
// exactly as the original call did — same revision, same previews, same
|
|
458
|
+
// warnings — without re-deriving anything, because re-deriving is how a
|
|
459
|
+
// "replay" quietly becomes a second apply.
|
|
460
|
+
outcome: {
|
|
461
|
+
projectId,
|
|
462
|
+
revision: outcome.revision,
|
|
463
|
+
revisionNumber: outcome.revision.revisionNumber,
|
|
464
|
+
digest: outcome.revision.digest,
|
|
465
|
+
summary,
|
|
466
|
+
job: outcome.job,
|
|
467
|
+
warnings: outcome.warnings,
|
|
468
|
+
sceneSummary: outcome.sceneSummary,
|
|
469
|
+
},
|
|
470
|
+
kind: 'scene_patch',
|
|
471
|
+
})
|
|
472
|
+
|
|
473
|
+
return { ...outcome, idempotentReplay: false, idempotencyKey: key, idempotencyKeyDerived: derived }
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* The shared commit path used by both `createProject` and `applyScenePatch`.
|
|
478
|
+
*
|
|
479
|
+
* @param {object} plan
|
|
480
|
+
* @returns {Promise<{ revision: object, job: object, warnings: object[], sceneSummary: object }>}
|
|
481
|
+
*/
|
|
482
|
+
async commit(plan) {
|
|
483
|
+
const { projectId } = plan
|
|
484
|
+
const record = this.store.readRecord(projectId)
|
|
485
|
+
const revision = this.store.nextRevisionId(projectId)
|
|
486
|
+
const revisionNumber = /** @type {number} */ (parseRevisionId(revision))
|
|
487
|
+
|
|
488
|
+
this.sweepStaging(projectId)
|
|
489
|
+
const staging = join(this.store.projectDirectory(projectId), 'staging', revision)
|
|
490
|
+
removeTree(staging)
|
|
491
|
+
mkdirSync(staging, { recursive: true })
|
|
492
|
+
|
|
493
|
+
const jobId = plan.jobId ?? this.store.allocateJobId(projectId, plan.kind === 'project_create' ? 'compile_scene' : 'apply_scene_patch')
|
|
494
|
+
const startedAt = new Date().toISOString()
|
|
495
|
+
const startedMs = Date.now()
|
|
496
|
+
/** @type {object[]} */
|
|
497
|
+
const warnings = []
|
|
498
|
+
for (const notice of plan.notices ?? []) {
|
|
499
|
+
if (notice.severity === 'notice') {
|
|
500
|
+
warnings.push(warning(BlenderWarningCode.SCENE_COMPILER_DECISION, notice.message, { code: notice.code, path: notice.path }))
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
const writeJob = (fields) => {
|
|
505
|
+
const job = {
|
|
506
|
+
schemaVersion: 'deepblend.job/v1',
|
|
507
|
+
jobId,
|
|
508
|
+
projectId,
|
|
509
|
+
action: plan.kind === 'project_create' ? 'compile_scene' : 'apply_scene_patch',
|
|
510
|
+
revision: fields.revision ?? null,
|
|
511
|
+
status: fields.status,
|
|
512
|
+
errorCode: fields.errorCode ?? null,
|
|
513
|
+
message: fields.message ?? null,
|
|
514
|
+
startedAt,
|
|
515
|
+
finishedAt: fields.status === 'running' ? null : new Date().toISOString(),
|
|
516
|
+
durationMs: fields.status === 'running' ? null : Date.now() - startedMs,
|
|
517
|
+
idempotencyKey: plan.derivedIdempotencyKey === false ? (plan.explicitIdempotencyKey ?? null) : null,
|
|
518
|
+
baseRevision: plan.baseRevision,
|
|
519
|
+
artifacts: fields.artifacts ?? [],
|
|
520
|
+
warnings,
|
|
521
|
+
}
|
|
522
|
+
this.store.writeJob(projectId, job)
|
|
523
|
+
return job
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
writeJob({ status: 'running' })
|
|
527
|
+
if (!plan.saveCheckpoint && !plan.renderPreview) {
|
|
528
|
+
// Nothing needs Blender. Committing a spec-only revision is a legitimate
|
|
529
|
+
// and useful operation — a scene edit that has not been rendered yet — and
|
|
530
|
+
// spending a Blender launch on it would be pure cost.
|
|
531
|
+
warnings.push(warning(
|
|
532
|
+
BlenderWarningCode.SCENE_COMPILER_DECISION,
|
|
533
|
+
'no checkpoint and no preview were requested, so this revision stores the SceneSpec only; ' +
|
|
534
|
+
'there is no .blend to open until a later revision or an explicit compile',
|
|
535
|
+
))
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
let compileReport = null
|
|
539
|
+
let technical = null
|
|
540
|
+
/** @type {object[]} */
|
|
541
|
+
const artifacts = []
|
|
542
|
+
|
|
543
|
+
if (plan.saveCheckpoint || plan.renderPreview) {
|
|
544
|
+
const specPath = join(staging, 'scene-spec.json')
|
|
545
|
+
writeFileAtomic(specPath, sceneSpecCanonicalText(plan.spec))
|
|
546
|
+
const checkpointStaging = join(staging, 'scene.blend')
|
|
547
|
+
|
|
548
|
+
try {
|
|
549
|
+
const run = await this.runtime.compileScene({
|
|
550
|
+
sceneSpecPath: specPath,
|
|
551
|
+
profile: plan.spec.renderProfiles?.preview === undefined ? undefined : 'preview',
|
|
552
|
+
projectRoot: this.store.projectDirectory(projectId),
|
|
553
|
+
jobId,
|
|
554
|
+
signal: plan.signal,
|
|
555
|
+
onWorkingDirectory: info => {
|
|
556
|
+
const produced = join(info.directory, 'result.blend')
|
|
557
|
+
if (isFile(produced)) {
|
|
558
|
+
// Same filesystem (both are inside the project's staging tree), so
|
|
559
|
+
// this rename is atomic and cannot half-copy a large file.
|
|
560
|
+
renameSync(produced, checkpointStaging)
|
|
561
|
+
}
|
|
562
|
+
},
|
|
563
|
+
})
|
|
564
|
+
compileReport = run.report
|
|
565
|
+
technical = run.report?.validation ?? null
|
|
566
|
+
for (const entry of run.envelope.warnings ?? []) warnings.push(entry)
|
|
567
|
+
for (const entry of run.envelope.notices ?? []) {
|
|
568
|
+
warnings.push(warning(BlenderWarningCode.SCENE_COMPILER_DECISION, entry.message, { code: entry.code }))
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// SPEC §15.2 "Mesh 面数限制". The number is already here — the compile report carries
|
|
572
|
+
// it for the revision manifest — so this compares a measurement rather than estimating
|
|
573
|
+
// one. Thrown INSIDE the try on purpose: the existing catch removes the staging tree,
|
|
574
|
+
// writes a failed job and refuses the revision, which is exactly what a scene that is
|
|
575
|
+
// too heavy should produce. Nothing below this line runs, so no manifest is written and
|
|
576
|
+
// no checkpoint is published.
|
|
577
|
+
const polygons = compileReport?.sceneFingerprint?.totalPolygons
|
|
578
|
+
// A REPORT THAT DOES NOT CARRY THE COUNT IS REFUSED, not skipped. The guard below is the only thing
|
|
579
|
+
// standing between a five-times-too-heavy scene and a project that pays for it on every later compile,
|
|
580
|
+
// and `typeof polygons === 'number'` alone made a report with a RENAMED or missing field look like a
|
|
581
|
+
// scene that is fine — the check would pass by not running. The provider emits this field on every
|
|
582
|
+
// compile (`bootstrap.py`'s `scene_fingerprint`), so its absence means the two sides disagree about the
|
|
583
|
+
// protocol rather than that the scene is light. MEASURED: the field-name agreement between
|
|
584
|
+
// `scene_fingerprint`'s output and this reader was asserted nowhere before `render-job.test.mjs` gained
|
|
585
|
+
// the cross-language check; this is the other half — the guard fails loudly instead of silently.
|
|
586
|
+
if (compileReport !== null && compileReport !== undefined && typeof polygons !== 'number') {
|
|
587
|
+
throw new BlenderError(
|
|
588
|
+
BlenderErrorCode.PROTOCOL_VERSION_MISMATCH,
|
|
589
|
+
'the compile report carries no sceneFingerprint.totalPolygons, so this deployment cannot tell ' +
|
|
590
|
+
'whether the scene is within maxMeshPolygons and will not commit it. The report and this Host ' +
|
|
591
|
+
'disagree about the protocol — check that the provider package and the host package are the ' +
|
|
592
|
+
'same version.',
|
|
593
|
+
{ detail: { projectId, revision, fingerprint: compileReport.sceneFingerprint ?? null } },
|
|
594
|
+
)
|
|
595
|
+
}
|
|
596
|
+
if (typeof polygons === 'number' && polygons > this.config.maxMeshPolygons) {
|
|
597
|
+
throw new BlenderError(
|
|
598
|
+
BlenderErrorCode.SCENE_TOO_HEAVY,
|
|
599
|
+
`the compiled scene carries ${polygons.toLocaleString('en-US')} polygons, above the ` +
|
|
600
|
+
`${this.config.maxMeshPolygons.toLocaleString('en-US')} this deployment accepts ` +
|
|
601
|
+
'(deepblend.maxMeshPolygons, SPEC §15.2). Nothing was committed. Reduce the geometry — ' +
|
|
602
|
+
'a denser object is usually an imported asset, and blender_asset_ingest reports what it brought in.',
|
|
603
|
+
{ detail: { polygons, maxMeshPolygons: this.config.maxMeshPolygons, projectId, revision } },
|
|
604
|
+
)
|
|
605
|
+
}
|
|
606
|
+
} catch (cause) {
|
|
607
|
+
const failure = toCanonicalFailure(cause, {
|
|
608
|
+
isBlenderError: value => value instanceof BlenderError,
|
|
609
|
+
fallbackCode: 'BLENDER_SCRIPT_ERROR',
|
|
610
|
+
})
|
|
611
|
+
removeTree(staging)
|
|
612
|
+
const job = writeJob({
|
|
613
|
+
status: 'failed',
|
|
614
|
+
revision: null,
|
|
615
|
+
errorCode: failure.code,
|
|
616
|
+
message: failure.message,
|
|
617
|
+
})
|
|
618
|
+
this.recordFailedAttempt(projectId, { revision, baseRevision: plan.baseRevision, job, failure, plan })
|
|
619
|
+
|
|
620
|
+
// A FIRST COMPILE THAT FAILS LEAVES NO PROJECT BEHIND. `createProject` writes the
|
|
621
|
+
// skeleton and the record before it compiles, so a failure — a timeout, a refused
|
|
622
|
+
// import, a scene over `maxMeshPolygons` — used to leave a project with
|
|
623
|
+
// `currentRevision: r0000, revisionCount: 0` on disk. MEASURED: reading it back threw
|
|
624
|
+
// `REVISION_ID_INVALID` (not a "no revisions yet" answer), and retrying the same id
|
|
625
|
+
// threw `PROJECT_EXISTS`, so the id was burned and the project list showed a project
|
|
626
|
+
// that errors when opened. Both halves are worse than the failure itself, and both
|
|
627
|
+
// contradict the rule the rest of this file keeps: a refusal costs a message, not your
|
|
628
|
+
// work.
|
|
629
|
+
//
|
|
630
|
+
// The guard is `revisionCount === 0` rather than "this was a project_create", because
|
|
631
|
+
// that is the property that matters: once a revision exists the project is real and
|
|
632
|
+
// must survive a failed LATER compile exactly as it does today. What is not kept is the
|
|
633
|
+
// failed job record — it lives inside the project directory, and a job record for a
|
|
634
|
+
// project that does not exist is not something anyone can read.
|
|
635
|
+
if (plan.kind === 'project_create') this._discardEmptyProject(projectId)
|
|
636
|
+
|
|
637
|
+
throw new BlenderError(
|
|
638
|
+
failure.code === 'BLENDER_SCRIPT_ERROR' ? BlenderErrorCode.SCRIPT_ERROR : failure.code,
|
|
639
|
+
`Compiling revision ${revision} failed, so the project is unchanged (current revision ${record.currentRevision}): ${failure.message}`,
|
|
640
|
+
{ detail: { projectId, revision, jobId: job.jobId, path: failure.detail?.path ?? null, artifacts: job.artifacts } },
|
|
641
|
+
)
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
if (isFile(checkpointStaging)) {
|
|
645
|
+
artifacts.push({
|
|
646
|
+
kind: 'blend',
|
|
647
|
+
path: `revisions/${revision}/scene.blend`,
|
|
648
|
+
bytes: statSync(checkpointStaging).size,
|
|
649
|
+
sha256: fileSha256(checkpointStaging),
|
|
650
|
+
mime: 'application/x-blender',
|
|
651
|
+
})
|
|
652
|
+
}
|
|
653
|
+
} else {
|
|
654
|
+
writeFileAtomic(join(staging, 'scene-spec.json'), sceneSpecCanonicalText(plan.spec))
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
// ---- optional preview, rendered from the checkpoint just produced -------
|
|
658
|
+
/** @type {object[]} */
|
|
659
|
+
const previews = []
|
|
660
|
+
if (plan.renderPreview) {
|
|
661
|
+
const checkpointStaging = join(staging, 'scene.blend')
|
|
662
|
+
if (!isFile(checkpointStaging)) {
|
|
663
|
+
throw new BlenderError(
|
|
664
|
+
BlenderErrorCode.REVISION_CHECKPOINT_MISSING,
|
|
665
|
+
`A preview was requested for ${revision}, but the compile produced no checkpoint to render from.`,
|
|
666
|
+
{ detail: { projectId, revision, jobId } },
|
|
667
|
+
)
|
|
668
|
+
}
|
|
669
|
+
try {
|
|
670
|
+
previews.push(await this.renderPreviewInto({
|
|
671
|
+
projectId, revision, jobId, staging,
|
|
672
|
+
spec: plan.spec,
|
|
673
|
+
checkpointPath: checkpointStaging,
|
|
674
|
+
cameraId: plan.cameraId,
|
|
675
|
+
signal: plan.signal,
|
|
676
|
+
warnings,
|
|
677
|
+
}))
|
|
678
|
+
} catch (cause) {
|
|
679
|
+
const failure = toCanonicalFailure(cause, {
|
|
680
|
+
isBlenderError: value => value instanceof BlenderError,
|
|
681
|
+
fallbackCode: 'BLENDER_SCRIPT_ERROR',
|
|
682
|
+
})
|
|
683
|
+
removeTree(staging)
|
|
684
|
+
const job = writeJob({ status: 'failed', revision: null, errorCode: failure.code, message: failure.message })
|
|
685
|
+
this.recordFailedAttempt(projectId, { revision, baseRevision: plan.baseRevision, job, failure, plan })
|
|
686
|
+
if (plan.kind === 'project_create') this._discardEmptyProject(projectId)
|
|
687
|
+
throw new BlenderError(
|
|
688
|
+
failure.code === 'BLENDER_SCRIPT_ERROR' ? BlenderErrorCode.SCRIPT_ERROR : failure.code,
|
|
689
|
+
`Rendering the preview for revision ${revision} failed, so the project is unchanged ` +
|
|
690
|
+
`(current revision ${record.currentRevision}): ${failure.message}`,
|
|
691
|
+
{ detail: { projectId, revision, jobId: job.jobId } },
|
|
692
|
+
)
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
for (const preview of previews) artifacts.push(preview.artifact)
|
|
697
|
+
|
|
698
|
+
// ---- write the revision's own documents --------------------------------
|
|
699
|
+
const digest = digestAfter(plan, warnings)
|
|
700
|
+
const sceneSummary = summarizeSceneSpec(plan.spec, { revision, revisionNumber, digest })
|
|
701
|
+
const manifest = {
|
|
702
|
+
schemaVersion: REVISION_MANIFEST_VERSION,
|
|
703
|
+
revision,
|
|
704
|
+
revisionNumber,
|
|
705
|
+
projectId,
|
|
706
|
+
title: record.title,
|
|
707
|
+
baseRevision: plan.baseRevision,
|
|
708
|
+
kind: plan.kind,
|
|
709
|
+
createdAt: new Date().toISOString(),
|
|
710
|
+
digest,
|
|
711
|
+
digestBefore: plan.digestBefore ?? digest,
|
|
712
|
+
digestAfter: digest,
|
|
713
|
+
specHashBefore: plan.specHashBefore ?? null,
|
|
714
|
+
specHashAfter: plan.specHashAfter ?? specHash(plan.spec),
|
|
715
|
+
// `sceneChanged` answers "did the geometry, lighting and animation change?"
|
|
716
|
+
// — the question a render cache must ask. `specChanged` answers "did the
|
|
717
|
+
// stored document change at all?", which is also true for a frame-range or
|
|
718
|
+
// render-profile edit. Both are in the manifest because they are different
|
|
719
|
+
// questions and a caller may need either.
|
|
720
|
+
sceneChanged: (plan.digestBefore ?? digest) !== digest,
|
|
721
|
+
specChanged: plan.specHashBefore === null || plan.specHashBefore === undefined
|
|
722
|
+
? true
|
|
723
|
+
: plan.specHashBefore !== (plan.specHashAfter ?? specHash(plan.spec)),
|
|
724
|
+
summary: plan.summary,
|
|
725
|
+
actor: plan.actor ?? null,
|
|
726
|
+
stage: plan.stage ?? null,
|
|
727
|
+
note: plan.note ?? null,
|
|
728
|
+
sceneSpecVersion: plan.spec.schemaVersion,
|
|
729
|
+
counts: sceneSummary.counts,
|
|
730
|
+
subjectBounds: sceneSummary.subjectBounds,
|
|
731
|
+
project: sceneSummary.project,
|
|
732
|
+
validation: technical === null ? null : {
|
|
733
|
+
ok: technical.ok !== false,
|
|
734
|
+
errorCount: (technical.errors ?? []).length,
|
|
735
|
+
counts: technical.counts ?? null,
|
|
736
|
+
geometry: technical.geometry ?? null,
|
|
737
|
+
frameRange: technical.frameRange ?? null,
|
|
738
|
+
fps: technical.fps ?? null,
|
|
739
|
+
engine: technical.engine ?? null,
|
|
740
|
+
activeCamera: technical.activeCamera ?? null,
|
|
741
|
+
animatedObjects: technical.animatedObjects ?? null,
|
|
742
|
+
cameraParameters: technical.cameraParameters ?? null,
|
|
743
|
+
},
|
|
744
|
+
renderConfig: compileReport?.renderConfig ?? null,
|
|
745
|
+
sceneFingerprint: compileReport?.sceneFingerprint ?? null,
|
|
746
|
+
checkpoint: isFile(join(staging, 'scene.blend')) ? `revisions/${revision}/scene.blend` : null,
|
|
747
|
+
previews: previews.map(entry => entry.artifact),
|
|
748
|
+
jobId,
|
|
749
|
+
runtime: {
|
|
750
|
+
blenderVersion: null,
|
|
751
|
+
engine: compileReport?.engine ?? null,
|
|
752
|
+
requestedEngine: compileReport?.requestedEngine ?? null,
|
|
753
|
+
protocolVersion: 'deepblend.blender/v1',
|
|
754
|
+
},
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
if (plan.kind === 'scene_patch') {
|
|
758
|
+
writeJsonAtomic(join(staging, 'operation-manifest.json'), buildOperationManifest({
|
|
759
|
+
operations: plan.operationRecords ?? [],
|
|
760
|
+
request: plan.operations === undefined ? {} : {
|
|
761
|
+
idempotencyKey: plan.derivedIdempotencyKey === false ? '<caller-supplied>' : '<derived>',
|
|
762
|
+
actor: plan.actor,
|
|
763
|
+
stage: plan.stage,
|
|
764
|
+
note: plan.note,
|
|
765
|
+
saveCheckpoint: plan.saveCheckpoint,
|
|
766
|
+
renderPreview: plan.renderPreview,
|
|
767
|
+
},
|
|
768
|
+
revision: {
|
|
769
|
+
revision,
|
|
770
|
+
baseRevision: plan.baseRevision,
|
|
771
|
+
digestBefore: manifest.digestBefore,
|
|
772
|
+
digestAfter: manifest.digestAfter,
|
|
773
|
+
specHashBefore: manifest.specHashBefore,
|
|
774
|
+
specHashAfter: manifest.specHashAfter,
|
|
775
|
+
},
|
|
776
|
+
notices: plan.notices ?? [],
|
|
777
|
+
}))
|
|
778
|
+
writeJsonAtomic(join(staging, 'request.json'), {
|
|
779
|
+
schemaVersion: SCENE_PATCH_VERSION,
|
|
780
|
+
...plan.operations === undefined ? {} : { operations: plan.operations },
|
|
781
|
+
baseRevision: plan.baseRevision,
|
|
782
|
+
saveCheckpoint: plan.saveCheckpoint,
|
|
783
|
+
renderPreview: plan.renderPreview,
|
|
784
|
+
actor: plan.actor ?? null,
|
|
785
|
+
stage: plan.stage ?? null,
|
|
786
|
+
note: plan.note ?? null,
|
|
787
|
+
})
|
|
788
|
+
} else {
|
|
789
|
+
writeJsonAtomic(join(staging, 'request.json'), {
|
|
790
|
+
kind: 'project_create',
|
|
791
|
+
title: record.title,
|
|
792
|
+
goal: record.goal,
|
|
793
|
+
baseRevision: GENESIS_REVISION,
|
|
794
|
+
})
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
writeJsonAtomic(join(staging, 'validation.json'), {
|
|
798
|
+
schemaVersion: 'deepblend.validation/v1',
|
|
799
|
+
revision,
|
|
800
|
+
semantic: { ok: true, notices: plan.notices ?? [] },
|
|
801
|
+
technical,
|
|
802
|
+
})
|
|
803
|
+
|
|
804
|
+
// The manifest is written LAST inside staging: while it is absent, the
|
|
805
|
+
// directory is definitionally incomplete, which is the marker a future
|
|
806
|
+
// recovery sweep would use to identify an abandoned staging directory.
|
|
807
|
+
writeJsonAtomic(join(staging, 'revision-manifest.json'), manifest)
|
|
808
|
+
|
|
809
|
+
// ---- publish -----------------------------------------------------------
|
|
810
|
+
const finalPath = this.store.revisionDirectory(projectId, revision)
|
|
811
|
+
publishDirectory(staging, finalPath)
|
|
812
|
+
|
|
813
|
+
const nextRecord = {
|
|
814
|
+
...record,
|
|
815
|
+
currentRevision: revision,
|
|
816
|
+
revisionCount: (record.revisionCount ?? 0) + 1,
|
|
817
|
+
updatedAt: new Date().toISOString(),
|
|
818
|
+
specHash: manifest.specHashAfter,
|
|
819
|
+
}
|
|
820
|
+
this.store.writeRecord(projectId, nextRecord)
|
|
821
|
+
|
|
822
|
+
const job = writeJob({
|
|
823
|
+
status: 'succeeded',
|
|
824
|
+
revision,
|
|
825
|
+
artifacts: artifacts.map(entry => ({ ...entry, path: entry.path })),
|
|
826
|
+
})
|
|
827
|
+
|
|
828
|
+
return {
|
|
829
|
+
revision: { ...manifest, isCurrent: true },
|
|
830
|
+
record: nextRecord,
|
|
831
|
+
job,
|
|
832
|
+
warnings,
|
|
833
|
+
sceneSummary,
|
|
834
|
+
technical,
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
/**
|
|
839
|
+
* Render one preview into a revision's staging `previews/` directory.
|
|
840
|
+
* @param {object} input
|
|
841
|
+
* @returns {Promise<{ artifact: object, report: object }>}
|
|
842
|
+
*/
|
|
843
|
+
async renderPreviewInto(input) {
|
|
844
|
+
const { projectId, revision, staging, spec } = input
|
|
845
|
+
const profile = spec.renderProfiles?.preview
|
|
846
|
+
if (profile === undefined) {
|
|
847
|
+
throw new BlenderError(
|
|
848
|
+
BlenderErrorCode.RENDER_PROFILE_MISSING,
|
|
849
|
+
'The SceneSpec defines no preview render profile, so no preview can be rendered.',
|
|
850
|
+
)
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
const cameraId = input.cameraId ?? spec.cameras?.[0]?.id
|
|
854
|
+
if (cameraId === undefined) {
|
|
855
|
+
throw new BlenderError(BlenderErrorCode.SCENE_CAMERA_MISSING, 'The SceneSpec declares no camera to render from.')
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
const engineInfo = await this.runtime.resolveEngineKey(profile.engine, { signal: input.signal })
|
|
859
|
+
if (engineInfo.warning !== null) input.warnings?.push(engineInfo.warning)
|
|
860
|
+
|
|
861
|
+
const previewsDir = join(staging, 'previews')
|
|
862
|
+
mkdirSync(previewsDir, { recursive: true })
|
|
863
|
+
const filename = `${cameraId}.png`
|
|
864
|
+
const outputPath = join(previewsDir, filename)
|
|
865
|
+
|
|
866
|
+
const run = await this.runtime.renderPreview({
|
|
867
|
+
checkpointPath: input.checkpointPath,
|
|
868
|
+
outputPath,
|
|
869
|
+
cameraId,
|
|
870
|
+
engine: engineInfo.blenderEngine === null ? undefined : profile.engine,
|
|
871
|
+
width: profile.resolution?.[0],
|
|
872
|
+
height: profile.resolution?.[1],
|
|
873
|
+
samples: profile.samples,
|
|
874
|
+
jobId: input.jobId,
|
|
875
|
+
signal: input.signal,
|
|
876
|
+
})
|
|
877
|
+
|
|
878
|
+
for (const entry of run.envelope.warnings ?? []) input.warnings?.push(entry)
|
|
879
|
+
for (const entry of run.envelope.notices ?? []) {
|
|
880
|
+
input.warnings?.push(warning(BlenderWarningCode.SCENE_COMPILER_DECISION, entry.message, { code: entry.code }))
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
const report = run.report ?? {}
|
|
884
|
+
const bytes = fileSize(outputPath)
|
|
885
|
+
if (bytes === null || bytes === 0) {
|
|
886
|
+
throw new BlenderError(
|
|
887
|
+
BlenderErrorCode.RENDER_NO_OUTPUT,
|
|
888
|
+
`The renderer reported success but produced no image at ${filename}.`,
|
|
889
|
+
)
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
return {
|
|
893
|
+
report,
|
|
894
|
+
artifact: {
|
|
895
|
+
kind: 'preview',
|
|
896
|
+
path: `revisions/${revision}/previews/${filename}`,
|
|
897
|
+
cameraId,
|
|
898
|
+
frame: report.frame ?? null,
|
|
899
|
+
width: report.width ?? null,
|
|
900
|
+
height: report.height ?? null,
|
|
901
|
+
engine: report.engine ?? profile.engine,
|
|
902
|
+
samples: report.renderConfig?.samples ?? profile.samples ?? null,
|
|
903
|
+
bytes,
|
|
904
|
+
sha256: fileSha256(outputPath),
|
|
905
|
+
mime: 'image/png',
|
|
906
|
+
},
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
/**
|
|
911
|
+
* Record a failed attempt so a failure is auditable without being a revision.
|
|
912
|
+
*
|
|
913
|
+
* Written to `jobs/`, NOT to `revisions/`: a failure must be visible and must
|
|
914
|
+
* never look like a version of the project.
|
|
915
|
+
*/
|
|
916
|
+
/**
|
|
917
|
+
* Discard a project whose FIRST commit failed, so the id is not burned.
|
|
918
|
+
*
|
|
919
|
+
* WHY THIS IS A METHOD AND NOT TWO COPIES: it was written for the COMPILE failure path, with a
|
|
920
|
+
* measured reason — `createProject` writes the skeleton and the record before it compiles, so a
|
|
921
|
+
* failure used to leave `currentRevision: r0000, revisionCount: 0` on disk; reading it back threw
|
|
922
|
+
* `REVISION_ID_INVALID` (not a "no revisions yet" answer), retrying the same id threw
|
|
923
|
+
* `PROJECT_EXISTS`, and the project list showed a project that errors when opened.
|
|
924
|
+
*
|
|
925
|
+
* The PREVIEW failure path — a render that dies after a successful compile — kept the half-project,
|
|
926
|
+
* because the guard only existed in the other catch. MEASURED by asserting the promise ("a refused
|
|
927
|
+
* first commit costs a message, not your work") instead of the code's shape: `store.exists()` was
|
|
928
|
+
* still true and retrying the id failed. The rule is one fact, so it lives here and both paths call
|
|
929
|
+
* it.
|
|
930
|
+
*
|
|
931
|
+
* `revisionCount === 0` is the guard rather than "this was a project_create": once a revision
|
|
932
|
+
* exists the project is real and must survive a failed later commit.
|
|
933
|
+
*
|
|
934
|
+
* @param {string} projectId
|
|
935
|
+
*/
|
|
936
|
+
_discardEmptyProject(projectId) {
|
|
937
|
+
if (this.store.readRecord(projectId)?.revisionCount === 0) {
|
|
938
|
+
removeTree(this.store.projectDirectory(projectId))
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
recordFailedAttempt(projectId, input) {
|
|
943
|
+
try {
|
|
944
|
+
writeJsonAtomic(
|
|
945
|
+
join(this.store.projectDirectory(projectId), 'jobs', `${input.job.jobId}.attempt.json`),
|
|
946
|
+
{
|
|
947
|
+
schemaVersion: 'deepblend.failed-attempt/v1',
|
|
948
|
+
projectId,
|
|
949
|
+
attemptedRevision: input.revision,
|
|
950
|
+
baseRevision: input.baseRevision,
|
|
951
|
+
jobId: input.job.jobId,
|
|
952
|
+
errorCode: input.failure.code,
|
|
953
|
+
message: input.failure.message,
|
|
954
|
+
at: new Date().toISOString(),
|
|
955
|
+
operations: input.plan.operations ?? null,
|
|
956
|
+
summary: input.plan.summary ?? null,
|
|
957
|
+
},
|
|
958
|
+
)
|
|
959
|
+
} catch {
|
|
960
|
+
// Losing the audit record must not replace the real error the caller sees.
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
/** readdir that tolerates absence. */
|
|
966
|
+
function readdirSafe(directory) {
|
|
967
|
+
try {
|
|
968
|
+
return readdirSync(directory)
|
|
969
|
+
} catch {
|
|
970
|
+
return []
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
/** A readable one-line description of what a patch did. */
|
|
975
|
+
function describeOperations(records) {
|
|
976
|
+
if (records.length === 0) return 'No operations.'
|
|
977
|
+
if (records.length === 1) return records[0].summary
|
|
978
|
+
return `${records.length} operations: ${records.map(record => record.summary).join('; ')}`
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
/** Digest helper kept separate so the manifest reads as data, not as computation. */
|
|
982
|
+
function digestAfter(plan, _warnings) {
|
|
983
|
+
return sceneSpecDigest(plan.spec)
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
/**
|
|
987
|
+
* The SceneSpec a `project_create` starts from when the caller supplied none.
|
|
988
|
+
*
|
|
989
|
+
* Deliberately minimal and deliberately VALID: one cube, one camera, one light,
|
|
990
|
+
* both render profiles. A brand-new project must be renderable immediately, or
|
|
991
|
+
* the first thing every caller does is repair the scaffold — and a scaffold that
|
|
992
|
+
* needs repairing is a worse starting point than no scaffold at all.
|
|
993
|
+
*
|
|
994
|
+
* @param {{ projectId: string, title: string, goal?: string }} input
|
|
995
|
+
* @returns {object}
|
|
996
|
+
*/
|
|
997
|
+
export function defaultSceneSpec({ projectId, title, goal }) {
|
|
998
|
+
return {
|
|
999
|
+
schemaVersion: 'deepblend.scene/v1',
|
|
1000
|
+
project: {
|
|
1001
|
+
id: projectId,
|
|
1002
|
+
title,
|
|
1003
|
+
...goal !== undefined ? { goal } : {},
|
|
1004
|
+
units: 'metric',
|
|
1005
|
+
fps: 24,
|
|
1006
|
+
frameStart: 1,
|
|
1007
|
+
frameEnd: 48,
|
|
1008
|
+
aspectRatio: '16:9',
|
|
1009
|
+
},
|
|
1010
|
+
materials: [
|
|
1011
|
+
{ id: 'default-surface', shader: 'principled', parameters: { baseColor: [0.62, 0.62, 0.64, 1], metallic: 0.0, roughness: 0.5 } },
|
|
1012
|
+
],
|
|
1013
|
+
entities: [
|
|
1014
|
+
{
|
|
1015
|
+
id: 'subject',
|
|
1016
|
+
type: 'generator',
|
|
1017
|
+
generator: { shape: 'cube', size: 2 },
|
|
1018
|
+
materialId: 'default-surface',
|
|
1019
|
+
transform: { location: [0, 0, 1], rotationEuler: [0, 0, 0], scale: [1, 1, 1] },
|
|
1020
|
+
tags: ['hero-product'],
|
|
1021
|
+
},
|
|
1022
|
+
],
|
|
1023
|
+
lights: [
|
|
1024
|
+
{
|
|
1025
|
+
id: 'key-light',
|
|
1026
|
+
type: 'area',
|
|
1027
|
+
transform: { location: [2.5, -3, 4], rotationEuler: [0, 0, 0], scale: [1, 1, 1] },
|
|
1028
|
+
energy: 400,
|
|
1029
|
+
color: [1, 1, 1],
|
|
1030
|
+
size: 2,
|
|
1031
|
+
},
|
|
1032
|
+
],
|
|
1033
|
+
cameras: [
|
|
1034
|
+
{
|
|
1035
|
+
id: 'camera-main',
|
|
1036
|
+
lens: 50,
|
|
1037
|
+
sensorWidth: 36,
|
|
1038
|
+
clipping: [0.1, 100],
|
|
1039
|
+
transform: { location: [4.2, -4.2, 3.4], rotationEuler: [0, 0, 0], scale: [1, 1, 1] },
|
|
1040
|
+
targetEntityId: 'subject',
|
|
1041
|
+
},
|
|
1042
|
+
],
|
|
1043
|
+
shots: [{ id: 'shot-01', cameraId: 'camera-main', frameRange: [1, 48], description: 'Default framing of the subject.' }],
|
|
1044
|
+
animationTracks: [],
|
|
1045
|
+
renderProfiles: {
|
|
1046
|
+
preview: {
|
|
1047
|
+
engine: 'cycles',
|
|
1048
|
+
resolution: [640, 360],
|
|
1049
|
+
samples: 48,
|
|
1050
|
+
filmTransparent: false,
|
|
1051
|
+
colorManagement: { viewTransform: 'Standard' },
|
|
1052
|
+
maxSamplesBudget: 256,
|
|
1053
|
+
},
|
|
1054
|
+
final: {
|
|
1055
|
+
engine: 'cycles',
|
|
1056
|
+
resolution: [1920, 1080],
|
|
1057
|
+
samples: 256,
|
|
1058
|
+
filmTransparent: false,
|
|
1059
|
+
colorManagement: { viewTransform: 'AgX' },
|
|
1060
|
+
maxSamplesBudget: 1024,
|
|
1061
|
+
},
|
|
1062
|
+
},
|
|
1063
|
+
}
|
|
1064
|
+
}
|