@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/index.js ADDED
@@ -0,0 +1,4778 @@
1
+ /**
2
+ * @deepblend/dsh-blender-host
3
+ *
4
+ * The DeepBlend **Host service** (SPEC §5.1, §7.2).
5
+ *
6
+ * Owns the `blenderStudio` business facade that both the model-facing tool
7
+ * package and the Web UI talk to. It is the only place in DeepBlend that knows
8
+ * the ORDER of a revision commit; everything below it (`blenderRuntime`, the
9
+ * stores) is a mechanism, and everything above it (the preset tools, the UI) is
10
+ * a surface.
11
+ *
12
+ * WHY THE FACADE EXISTS — the SPEC's plane rule. `blender_scene_patch` must be a
13
+ * *preset* row (it decides what the model sees), while the revision transaction,
14
+ * the project store and the idempotency ledger belong to the *host* (they are
15
+ * process-level and cross-session). One facade means the tool and the browser can
16
+ * never disagree about what "current revision" means (SPEC §14.3).
17
+ *
18
+ * M1 implements the batch SceneSpec loop: create, read, patch, validate, preview,
19
+ * restore. `startFinalRender` and `exportProject` still throw a stable
20
+ * `UNSUPPORTED_ACTION` — a method that silently returned undefined would be a
21
+ * capability the model could believe in and the runtime could not keep
22
+ * (SPEC §11.1).
23
+ *
24
+ * Owner: DeepBlend Studio — M1
25
+ * Plane: Host composition
26
+ */
27
+
28
+ import { Service } from '@deepseek-ai/cordis'
29
+ import z from '@deepseek-ai/schemastery'
30
+
31
+ import {
32
+ BLENDER_SETTINGS_NAMESPACE,
33
+ JOB_RECORD_VERSION,
34
+ LOG_SCOPE,
35
+ BlenderError,
36
+ BlenderErrorCode,
37
+ BlenderWarningCode,
38
+ SCENE_PATCH_VERSION,
39
+ buildViewPlan,
40
+ buildVisualReview,
41
+ compileSceneSpec,
42
+ resolveSubject,
43
+ resolveSubjectId,
44
+ sceneSpecDigest,
45
+ scoreReview,
46
+ summarizeSceneSpec,
47
+ toCanonicalJobRecord,
48
+ toCanonicalProjectSummary,
49
+ toCanonicalCapabilities,
50
+ toCanonicalQAReport,
51
+ toCanonicalRevisionSummary,
52
+ runVisualLoop,
53
+ subjectParts,
54
+ trackedObjects,
55
+ validateFindings,
56
+ validateSceneSpec,
57
+ warning,
58
+ // M4 — the preview pair the workbench compares
59
+ PREVIEW_SHEET_SLOTS,
60
+ composeContactSheet,
61
+ // M3 — the persistent render job
62
+ HOST_API_VERSION,
63
+ RENDER_JOB_VERSION,
64
+ describeRenderJob,
65
+ estimateRemaining,
66
+ frameFileName,
67
+ frameNumbers,
68
+ renderProgressPercent,
69
+ verifyVideoProperties,
70
+ // M5 — the two roots the host and the provider must agree on
71
+ resolveProjectsRoot,
72
+ resolveWorkspaceRoot,
73
+ IMPORT_OPERATOR_BY_ASSET_TYPE,
74
+ ASSET_HEAD_BYTES,
75
+ assetContentVerdict,
76
+ describeAssetContent,
77
+ redactUrl,
78
+ assertKnownConfigKeys,
79
+ } from '@deepblend/dsh-blender-contracts'
80
+
81
+ import { ProjectStore, GENESIS_REVISION, parseRevisionId } from './project-store.js'
82
+ import { RenderJobStore, UNFINISHED_STATUSES } from './render-job-store.js'
83
+ import { RevisionTransaction } from './revision-transaction.js'
84
+ import { inspectFrameSample, readFrameLedger, sampleFrame } from './frame-ledger.js'
85
+ import { JournalTail, incompleteJournalWarning, isFrameClaim } from './render-journal.js'
86
+ import { ORPHAN_GRACE_MS, checkProcessAlive, reconcileRenderJob, stopProcessGroup } from './render-reconciler.js'
87
+ import { encodeFrameSequence, encodedPath, probeVideo } from './video-encoder.js'
88
+ import { buildDeliveryManifest } from './delivery-manifest.js'
89
+ import { randomUUID } from 'node:crypto'
90
+ import { closeSync, copyFileSync, existsSync, mkdirSync, openSync, readFileSync, readSync, renameSync, rmdirSync, statSync, writeFileSync } from 'node:fs'
91
+ import { basename, dirname, extname, isAbsolute, join, resolve } from 'node:path'
92
+
93
+ import {
94
+ fileSha256,
95
+ fileSize,
96
+ isFile,
97
+ readJson,
98
+ readJsonSafe,
99
+ removeTree,
100
+ requireSafeSegment,
101
+ resolveInside,
102
+ writeJsonAtomic,
103
+ } from './paths.js'
104
+
105
+ /** Service key registered into the Cordis context. */
106
+ /**
107
+ * How many redirects a remote asset URL may take before this refuses to follow it.
108
+ *
109
+ * Bounded because the chain is followed one hop at a time so that every hop can be checked (see
110
+ * `_fetchFollowingRedirects`); a server that redirects forever must cost a refusal, not an endless loop.
111
+ */
112
+ const MAX_ASSET_REDIRECTS = 5
113
+
114
+ export const BLENDER_STUDIO_SERVICE = 'blenderStudio'
115
+
116
+ /**
117
+ * Content type of a project artifact, from its extension.
118
+ *
119
+ * A closed map rather than a library: the browser displays previews and reads
120
+ * manifests, and anything else is served as bytes so a mistake cannot become a
121
+ * script the UI is tricked into running.
122
+ *
123
+ * @param {string} path
124
+ * @returns {string}
125
+ */
126
+ export function contentTypeForArtifact(path) {
127
+ switch (extname(path).toLowerCase()) {
128
+ case '.png': return 'image/png'
129
+ case '.jpg':
130
+ case '.jpeg': return 'image/jpeg'
131
+ case '.webp': return 'image/webp'
132
+ case '.json': return 'application/json; charset=utf-8'
133
+ case '.txt':
134
+ case '.log': return 'text/plain; charset=utf-8'
135
+ case '.mp4': return 'video/mp4'
136
+ default: return 'application/octet-stream'
137
+ }
138
+ }
139
+
140
+ /** The runtime service this facade consumes. */
141
+ /** The runtime service this host resolves. Exported so `contract/export-usage.test.mjs` can tie it
142
+ * to the provider's own constant: two literals for one service name is how a rename becomes a
143
+ * "the host bundle is missing" error at runtime. */
144
+ export const RUNTIME_SERVICE = 'blenderRuntime'
145
+
146
+ /**
147
+ * Host-level configuration. Mirrors SPEC §17.
148
+ *
149
+ * Named `StudioConfig`, NOT `Config`. The class below declares `static Config`,
150
+ * and a module-scope `Config` would be shadowed by that class field inside the
151
+ * class body — so `static Config = Config` would read the field already being
152
+ * initialized and throw a temporal-dead-zone ReferenceError during module
153
+ * evaluation, taking the whole package down on import. That is exactly the bug
154
+ * this naming avoids; it failed only at bundle-load time, never in the unit
155
+ * tests, which is why the runtime install check caught it.
156
+ */
157
+ export const StudioConfig = z.object({
158
+ /**
159
+ * Directory holding DeepBlend projects. Unset, it is `<workspaceRoot>/projects`
160
+ * (SPEC §13's `workspace/projects/…`), so overriding one root moves the other
161
+ * with it instead of leaving the two disagreeing.
162
+ */
163
+ projectsRoot: z.string().default(''),
164
+ /**
165
+ * Workspace root that staging and provider scratch directories must stay
166
+ * inside (SPEC §15.2). Must be the SAME root the provider is configured with,
167
+ * or a staging path the host hands out would be one the provider refuses.
168
+ *
169
+ * Unset, it defaults to `<DSH_HOME>/deepblend` (SPEC §17). Both rows resolve it
170
+ * through the same `resolveWorkspaceRoot`, which is what makes them agree.
171
+ */
172
+ workspaceRoot: z.string().default(''),
173
+ // `serveCachedCapabilities` USED TO BE DECLARED HERE AND NOTHING READ IT. The caching it described is
174
+ // the provider's (`capabilitiesCacheMs`, plus `getCapabilities({refresh: true})`), so a second flag in
175
+ // the host would have been a second copy of one decision — and while it existed it was a knob that
176
+ // LIED: setting it changed nothing and said nothing. Found by
177
+ // `contract/config-surface.test.mjs`, which holds each schema against the code that reads it.
178
+ /** Refuse a preview whose sample count exceeds this, however it was asked for. */
179
+ maxPreviewSamples: z.number().default(512),
180
+ /**
181
+ * Ceiling on one ingested asset, in bytes. SPEC §15's example is 1 GiB.
182
+ *
183
+ * Checked against the source BEFORE it is copied, and again while a remote one
184
+ * streams, because a limit applied after the transfer is a description of what
185
+ * already happened rather than a control on it.
186
+ */
187
+ assetMaxBytes: z.number().default(1_073_741_824),
188
+ /**
189
+ * Ceiling on the polygons one compiled scene may carry (SPEC §15.2 "Mesh 面数限制").
190
+ *
191
+ * THE COUNT IS MEASURED, NOT GUESSED AT: the provider's compile report already carries
192
+ * `sceneFingerprint.totalPolygons` for the revision manifest, so this compares a number
193
+ * that exists rather than estimating one from the SceneSpec. A scene over the ceiling is
194
+ * refused with `SCENE_TOO_HEAVY`, before the revision is published.
195
+ *
196
+ * WHY IT IS NOT REDUNDANT WITH THE TIMEOUT. `timeoutMs` bounds ONE Blender invocation; a
197
+ * scene five times heavier than intended usually finishes each invocation inside it and
198
+ * then costs that much on every compile, every preview and every delivery render for the
199
+ * rest of the project's life — including a three-hour final render nobody wants any more.
200
+ * The two failures also read differently: a timeout says "try again or raise the
201
+ * deadline", and this says "this asset brought 8,412,004 polygons".
202
+ *
203
+ * The default is generous on purpose. A product turntable is a few hundred polygons and
204
+ * the golden fixture is 243; two million is the point at which a scene stops being a
205
+ * product shot and becomes a scan, which is a decision an operator should make by raising
206
+ * this number rather than by discovering it as a timeout.
207
+ */
208
+ maxMeshPolygons: z.number().default(2_000_000),
209
+
210
+ // ---- M2: the visual loop (SPEC §12.3, §17 `agent`) ------------------------
211
+ //
212
+ // The three knobs SPEC §17 groups under `agent` live here because they bound a
213
+ // HOST-owned loop: the iteration cap, the confidence floor below which a model
214
+ // finding is not auto-applied, and how many consecutive rounds a repeated finding
215
+ // is tolerated before the loop stops.
216
+
217
+ /** Hard cap on visual fix rounds per run. SPEC §17 default is 5. */
218
+ maxVisualIterations: z.number().default(5),
219
+ /** Findings below this confidence are reported but never auto-applied. */
220
+ minVisualConfidenceForAutoFix: z.number().default(0.8),
221
+ /** Consecutive rounds an unchanged finding may recur before stopping. */
222
+ stopOnRepeatedIssueCount: z.number().default(2),
223
+ /** Provider route used by the built-in vision reviewer. */
224
+ visualReviewProvider: z.string().default('deepseek-official'),
225
+ /** Model used by the built-in vision reviewer. */
226
+ visualReviewModel: z.string().default('deepseek-flash'),
227
+ /**
228
+ * Token budget for one reviewer call.
229
+ *
230
+ * MEASURED, and the reason this is a config field rather than a constant: with the
231
+ * full reviewer prompt and a 2x2 sheet, `deepseek-flash` spent ~11 000 REASONING
232
+ * tokens before emitting any text. At 2048 — a budget that looks generous for a
233
+ * 600-character JSON answer — the stream ended with `max-tokens` and NO text, which
234
+ * downstream is indistinguishable from "the model reviewed the sheet and found
235
+ * nothing wrong". That silent approval is the failure mode this number exists to
236
+ * prevent, and the size of it is a property of the model, not of this code.
237
+ */
238
+ visualReviewMaxTokens: z.number().default(24_000),
239
+ /** Views in the standard plan, in reading order (SPEC §12.3). */
240
+ visualReviewViews: z.array(z.string()).default(['active-camera', 'three-quarter', 'top', 'detail']),
241
+
242
+ // ---- M3: the persistent render job (SPEC §10, §17) ----------------------
243
+
244
+ /**
245
+ * The render profile a delivery uses.
246
+ *
247
+ * A NAME into the SceneSpec's own `renderProfiles`, not a copy of its settings:
248
+ * the spec is the source of truth for how a project renders (SPEC §8.1), and a
249
+ * second copy of "1920x1080 / 256 samples / AgX" in the Host composition is the
250
+ * shape of defect this repository has already paid for four times (D38, D43).
251
+ */
252
+ finalRenderProfile: z.string().default('final'),
253
+ /**
254
+ * Ceiling on the samples a DELIVERY render may use.
255
+ *
256
+ * Separate from `maxPreviewSamples` on purpose. That one exists to stop the
257
+ * model spending money on previews (SPEC §16.1/§16.2); applying it to a delivery
258
+ * would silently rewrite `watch-commercial`'s 256 samples down to 512-capped —
259
+ * except the reverse: it would cap a 256-spp delivery at 512 and change nothing,
260
+ * while capping a legitimate 1024-spp delivery at 512 and changing everything.
261
+ * The profile's own `maxSamplesBudget` is the intended ceiling (SPEC §17).
262
+ */
263
+ maxFinalSamples: z.number().default(4096),
264
+ /** ffmpeg executable: an absolute path, or a bare PATH name. */
265
+ ffmpegPath: z.string().default('ffmpeg'),
266
+ /** ffprobe executable: an absolute path, or a bare PATH name. */
267
+ ffprobePath: z.string().default('ffprobe'),
268
+ /** x264 quality knob for the delivery encode. Lower is better and larger. */
269
+ encodeCrf: z.number().default(18),
270
+ /** x264 speed/size preset for the delivery encode. */
271
+ encodePreset: z.string().default('medium'),
272
+ /**
273
+ * Delivery renders above this many frames require an approval the caller must
274
+ * have obtained (SPEC §15.1 "高成本最终渲染达阈值审批", §17
275
+ * `requireApprovalAboveFrames`). The Host records the requirement; the approval
276
+ * itself belongs to the harness' approval plane (M5 wires the prompt).
277
+ */
278
+ requireApprovalAboveFrames: z.number().default(900),
279
+ /**
280
+ * How often a running render folds its journal into the recorded progress.
281
+ *
282
+ * One second against frames that take 19.6-41.4 s each: frequent enough that a
283
+ * reader never sees a stale count, rare enough that the polling is invisible
284
+ * beside the render it is describing.
285
+ */
286
+ progressPollMs: z.number().default(1000),
287
+ /**
288
+ * Run the restart reconciler when this Host is constructed (SPEC §10.3).
289
+ *
290
+ * On by default. Off only for a test that needs a clean slate, because a
291
+ * reconciler that has to be remembered is a reconciler that will be forgotten —
292
+ * and the one time it matters is the one time nobody remembers.
293
+ */
294
+ reconcileOnStart: z.boolean().default(true),
295
+ /**
296
+ * How long a cancellation waits for an orphaned renderer to die before escalating to SIGKILL, and
297
+ * how long it then waits again before REPORTING it as not gone.
298
+ *
299
+ * The default is the reconciler's own grace (`ORPHAN_GRACE_MS`), and it is configurable for the
300
+ * reason every other bound here is: "the process did not die" has a cost, and how long a Host is
301
+ * willing to spend on that question is an operator's decision, not a constant's. Passing it through
302
+ * also makes the escalation ladder REACHABLE — with the default ten seconds per rung, the only way
303
+ * to stand on the escalation is to wait twenty of them, which is why that branch had never run.
304
+ */
305
+ orphanGraceMs: z.number().default(ORPHAN_GRACE_MS),
306
+ })
307
+
308
+ export default class BlenderStudio extends Service {
309
+ // A hard dependency: without a BlenderRuntime the facade has nothing to run,
310
+ // so Cordis keeps this row `waiting` instead of failing at first call.
311
+ static inject = [RUNTIME_SERVICE]
312
+
313
+ static Config = StudioConfig
314
+
315
+ /**
316
+ * @param {import('@deepseek-ai/cordis').Context} ctx
317
+ * @param {import('z').infer<typeof StudioConfig>} config
318
+ */
319
+ constructor(ctx, config) {
320
+ super(ctx, BLENDER_STUDIO_SERVICE)
321
+ // A key this row does not read is a startup error, not a silent no-op: SPEC §17 illustrates the
322
+ // configuration as nested groups (`finalRender: {requireApprovalAboveFrames: 900}`) while the
323
+ // implementation reads flat keys, and Schemastery accepts the nested document without a word.
324
+ assertKnownConfigKeys(StudioConfig, config, 'deepblend-blender-host')
325
+ this.config = config
326
+ /** In-flight probe, so N concurrent callers share one Blender launch. */
327
+ this._inFlight = null
328
+ /** Jobs being encoded right now, so two encoders never write one file. Keyed `projectId/jobId`. */
329
+ this._deliveriesInFlight = new Map()
330
+
331
+ // Resolved ONCE, here, and every consumer below uses these two values rather
332
+ // than `config.*`. An unresolved `projectsRoot` must follow the resolved
333
+ // `workspaceRoot`, or overriding one would leave the other pointing at a
334
+ // different deployment's directory — and the provider, which resolves its own
335
+ // copy through the same helper, would then refuse the host's staging paths.
336
+ const workspaceRoot = resolveWorkspaceRoot(config.workspaceRoot)
337
+ const projectsRoot = resolveProjectsRoot(config.projectsRoot, workspaceRoot)
338
+ this.workspaceRoot = workspaceRoot
339
+ this.projectsRoot = projectsRoot
340
+
341
+ this.store = new ProjectStore({ projectsRoot, workspaceRoot })
342
+ this.transactions = new RevisionTransaction({ store: this.store, runtime: this.runtime, config })
343
+
344
+ // ---- M3: the persistent render job (SPEC §10) --------------------------
345
+ this.renderJobs = new RenderJobStore({
346
+ projectDirectory: projectId => this.store.projectDirectory(projectId),
347
+ workspaceRoot,
348
+ })
349
+ /** Live renders, keyed by render job id, so cancel can reach the handle. */
350
+ this._liveRenders = new Map()
351
+ /** The reconciliation pass, so a caller (or a test) can await the answer. */
352
+ this._reconciliation = null
353
+ /** Findings from the most recent pass, kept for the job surface. */
354
+ this._recoveryFindings = []
355
+ /**
356
+ * Why the job controller could not be attached, when it could not be.
357
+ *
358
+ * Kept because the alternative is a message that names the WRONG REASON: a `jobs` service
359
+ * whose `attachController` threw is not the same fact as a composition without a `jobs`
360
+ * service, and the model reads this warning to decide whether a missing background job is
361
+ * its own mistake, a deployment's, or nobody's.
362
+ */
363
+ this._jobControllerError = null
364
+
365
+ if (config.reconcileOnStart === true) this._kickReconciliation()
366
+ }
367
+
368
+ /** @type {Promise<import('@deepblend/dsh-blender-contracts').BlenderCapabilities>|null} */
369
+ _inFlight
370
+
371
+ /** @type {Map<string, object>} */
372
+ _liveRenders
373
+
374
+ /** @type {Promise<object[]>|null} */
375
+ _reconciliation
376
+
377
+ /** @type {object[]} */
378
+ _recoveryFindings
379
+
380
+ /** @type {string|null} */
381
+ _jobControllerError
382
+
383
+ /** @returns {import('@deepblend/dsh-blender-provider-local').default} */
384
+ get runtime() {
385
+ return this.ctx.blenderRuntime
386
+ }
387
+
388
+ /**
389
+ * The host API this service implements.
390
+ *
391
+ * A NUMBER, and the reason is a measurement: with M3's tools loaded against a
392
+ * pre-M3 host, method probing detected `resumeRenderJob`/`listJobs` as missing but
393
+ * saw `startFinalRender`/`exportProject` as present — because M1 implemented them
394
+ * as stubs that throw. So `typeof` answered "fine" for the two entry points that
395
+ * would have failed worst, and the guard would have covered four of six.
396
+ *
397
+ * @returns {number}
398
+ */
399
+ hostApiVersion() {
400
+ return HOST_API_VERSION
401
+ }
402
+
403
+ // ---------------------------------------------------------------------------
404
+ // Capabilities (M0)
405
+ // ---------------------------------------------------------------------------
406
+
407
+ /**
408
+ * Report Blender capabilities.
409
+ *
410
+ * Concurrent callers are coalesced onto one probe: a Blender launch costs
411
+ * ~1 s, and a page load plus a tool call arriving together must not launch
412
+ * two processes (SPEC §16.4 concurrency discipline).
413
+ *
414
+ * @param {{ refresh?: boolean, signal?: AbortSignal }} [request]
415
+ * @returns {Promise<import('@deepblend/dsh-blender-contracts').BlenderCapabilities>}
416
+ */
417
+ async getCapabilities(request = {}) {
418
+ if (request.refresh === true) this.runtime.invalidateCapabilities()
419
+ if (this._inFlight !== null) return this._inFlight
420
+
421
+ const probe = (async () => {
422
+ try {
423
+ return await this.runtime.getCapabilities({
424
+ refresh: request.refresh === true,
425
+ ...request.signal !== undefined ? { signal: request.signal } : {},
426
+ })
427
+ } finally {
428
+ this._inFlight = null
429
+ }
430
+ })()
431
+ this._inFlight = probe
432
+ return probe
433
+ }
434
+
435
+ /**
436
+ * The canonical, stable-order view consumed by the model tool and the UI.
437
+ *
438
+ * One projection serves both surfaces so the browser can never disagree with
439
+ * the tool about what Blender can do (SPEC §14.3 "Host 保存权威项目状态").
440
+ *
441
+ * @param {{ refresh?: boolean, signal?: AbortSignal }} [request]
442
+ * @returns {Promise<Record<string, unknown>>}
443
+ */
444
+ async describeCapabilities(request = {}) {
445
+ const capabilities = await this.getCapabilities(request)
446
+ return toCanonicalCapabilities(capabilities)
447
+ }
448
+
449
+ // ---------------------------------------------------------------------------
450
+ // Projects
451
+ // ---------------------------------------------------------------------------
452
+
453
+ /**
454
+ * Create a project and commit its first revision.
455
+ *
456
+ * @param {object} request
457
+ * @param {string} request.title
458
+ * @param {string} [request.goal] - the natural-language brief, retained verbatim.
459
+ * @param {object} [request.sceneSpec] - a full SceneSpec to seed the project with.
460
+ * @param {string} [request.projectId]
461
+ * @param {boolean} [request.saveCheckpoint]
462
+ * @param {boolean} [request.renderPreview]
463
+ * @param {AbortSignal} [request.signal]
464
+ * @returns {Promise<Record<string, unknown>>}
465
+ */
466
+ async createProject(request) {
467
+ if (typeof request?.title !== 'string' || request.title.trim().length === 0) {
468
+ throw new BlenderError(
469
+ BlenderErrorCode.PROJECT_ID_INVALID,
470
+ 'createProject needs a title; it is both the human name and the source of the project id.',
471
+ )
472
+ }
473
+ const outcome = await this.transactions.createProject(request)
474
+ return {
475
+ ...toCanonicalProjectSummary({
476
+ record: outcome.record,
477
+ revisionCount: outcome.record.revisionCount,
478
+ sceneSummary: outcome.sceneSummary,
479
+ }),
480
+ revision: toCanonicalRevisionSummary({
481
+ manifest: outcome.revision,
482
+ checkpointPath: outcome.revision.checkpoint,
483
+ }),
484
+ job: toCanonicalJobRecord(outcome.job),
485
+ warnings: outcome.warnings,
486
+ }
487
+ }
488
+
489
+ /**
490
+ * Read a project's summary and current revision digest.
491
+ *
492
+ * @param {string} projectId
493
+ * @param {{ revision?: string }} [options]
494
+ * @returns {Promise<Record<string, unknown>>}
495
+ */
496
+ async getProject(projectId, options = {}) {
497
+ const record = this.store.readRecord(projectId)
498
+ const revision = options.revision ?? record.currentRevision
499
+ const spec = this.store.readRevisionSpec(projectId, revision)
500
+ const digest = sceneSpecDigest(spec)
501
+ return {
502
+ ...toCanonicalProjectSummary({
503
+ record,
504
+ revisionCount: record.revisionCount ?? this.store.listRevisions(projectId).length,
505
+ sceneSummary: summarizeSceneSpec(spec, {
506
+ revision,
507
+ revisionNumber: parseRevisionId(revision),
508
+ digest,
509
+ }),
510
+ }),
511
+ revisions: this.store.listRevisions(projectId).map(id => {
512
+ const manifest = this.store.readRevisionManifest(projectId, id)
513
+ return {
514
+ revision: id,
515
+ revisionNumber: parseRevisionId(id),
516
+ createdAt: manifest?.createdAt ?? null,
517
+ kind: manifest?.kind ?? null,
518
+ summary: manifest?.summary ?? null,
519
+ digest: manifest?.digest ?? null,
520
+ checkpoint: this.store.checkpointPath(projectId, id) === null
521
+ ? null
522
+ : `revisions/${id}/scene.blend`,
523
+ previews: (manifest?.previews ?? []).map(entry => entry.path),
524
+ isCurrent: id === record.currentRevision,
525
+ }
526
+ }),
527
+ }
528
+ }
529
+
530
+ // ---------------------------------------------------------------------------
531
+ // Scene
532
+ // ---------------------------------------------------------------------------
533
+
534
+ /**
535
+ * The compact `SceneDigest` of a revision (SPEC §7.2 `getScene`).
536
+ *
537
+ * Returns a DIGEST, not the document, by default. The SPEC's tool rule is
538
+ * explicit: "FullSceneSpec 明确需要时才读取" (SPEC §18) — a model that reads the
539
+ * whole spec on every turn spends its context on fields it is not changing.
540
+ *
541
+ * @param {string} projectId
542
+ * @param {{ revision?: string, full?: boolean }} [options]
543
+ * @returns {Promise<Record<string, unknown>>}
544
+ */
545
+ async getScene(projectId, options = {}) {
546
+ const record = this.store.readRecord(projectId)
547
+ const revision = options.revision ?? record.currentRevision
548
+ const spec = this.store.readRevisionSpec(projectId, revision)
549
+ const digest = sceneSpecDigest(spec)
550
+ const summary = summarizeSceneSpec(spec, {
551
+ revision,
552
+ revisionNumber: parseRevisionId(revision),
553
+ digest,
554
+ })
555
+ const manifest = this.store.readRevisionManifest(projectId, revision)
556
+ return {
557
+ ...summary,
558
+ projectId,
559
+ isCurrent: revision === record.currentRevision,
560
+ checkpoint: this.store.checkpointPath(projectId, revision) === null ? null : `revisions/${revision}/scene.blend`,
561
+ previews: (manifest?.previews ?? []).map(entry => ({
562
+ cameraId: entry.cameraId,
563
+ path: entry.path,
564
+ frame: entry.frame,
565
+ width: entry.width,
566
+ height: entry.height,
567
+ })),
568
+ // Returned only on explicit request. The caller asked for the document;
569
+ // withholding it would just force a second call.
570
+ ...options.full === true ? { spec, compiledSpec: compileSceneSpec(spec).spec } : {},
571
+ }
572
+ }
573
+
574
+ /**
575
+ * Apply a ScenePatch as one atomic revision (SPEC §8.3, §13.2).
576
+ *
577
+ * @param {object} request - a ScenePatch v1 document, plus `signal`.
578
+ * @returns {Promise<Record<string, unknown>>}
579
+ */
580
+ async applyScenePatch(request) {
581
+ const { signal, ...patch } = request ?? {}
582
+ // NOTHING is added to `patch`. The ScenePatch schema is `additionalProperties:
583
+ // false`, so annotating the document with a host-side flag rejected the patch as
584
+ // malformed — and the rejection only appeared on the path where a caller supplied
585
+ // its own idempotency key, which is precisely the path the visual loop uses. The
586
+ // transaction derives "was the key explicit?" from the document itself
587
+ // (`resolveIdempotencyKey`), so the flag was always redundant as well as harmful.
588
+ const outcome = await this.transactions.applyScenePatch(
589
+ { ...patch, projectId: patch.projectId },
590
+ { signal },
591
+ )
592
+ return {
593
+ ...toCanonicalRevisionSummary({
594
+ manifest: outcome.revision,
595
+ checkpointPath: outcome.revision.checkpoint,
596
+ }),
597
+ idempotentReplay: outcome.idempotentReplay === true,
598
+ replayOf: outcome.replayOf ?? null,
599
+ idempotencyKey: outcome.idempotencyKey,
600
+ idempotencyKeyDerived: outcome.idempotencyKeyDerived === true,
601
+ job: toCanonicalJobRecord(outcome.job),
602
+ warnings: outcome.warnings,
603
+ scene: outcome.sceneSummary,
604
+ }
605
+ }
606
+
607
+ /**
608
+ * Validate a revision (SPEC §7.2 `validateScene`).
609
+ *
610
+ * @param {object} request
611
+ * @param {string} request.projectId
612
+ * @param {string} [request.revision]
613
+ * @param {object} [request.patch] - additionally check whether this patch applies.
614
+ * @param {AbortSignal} [request.signal]
615
+ * @returns {Promise<Record<string, unknown>>}
616
+ */
617
+ async validateScene(request) {
618
+ const projectId = request?.projectId
619
+ const record = this.store.readRecord(projectId)
620
+ const revision = request.revision ?? record.currentRevision
621
+ const spec = this.store.readRevisionSpec(projectId, revision)
622
+ const digest = sceneSpecDigest(spec)
623
+
624
+ const semantic = validateSceneSpec(spec)
625
+ const compiled = compileSceneSpec(spec)
626
+
627
+ /** @type {object[]} */
628
+ const notices = [...semantic.notices, ...compiled.notices]
629
+ /** @type {object[]} */
630
+ const errors = [...semantic.errors]
631
+
632
+ if (request.patch !== undefined) {
633
+ // "Will this patch apply?" is the question worth answering BEFORE a caller
634
+ // spends a revision on it, so it is answered here using the same code path
635
+ // the commit uses. A dry run that took a different route would be worthless.
636
+ //
637
+ // `projectId` is merged in from the request rather than demanded twice: the
638
+ // caller already named the project, and requiring it inside the patch as
639
+ // well is ceremony that only ever yields a confusing "projectId is missing"
640
+ // on an otherwise correct patch.
641
+ const dryRun = { projectId, ...request.patch }
642
+ try {
643
+ const { validateScenePatch, applyPatchToSpec } = await import('@deepblend/dsh-blender-contracts')
644
+ const structural = validateScenePatch(dryRun)
645
+ if (!structural.ok) {
646
+ errors.push(...structural.errors.map(issue => ({
647
+ severity: 'error', code: issue.code, path: issue.path,
648
+ message: `the patch is not well formed — ${issue.message}`,
649
+ })))
650
+ } else if (dryRun.baseRevision !== revision) {
651
+ errors.push({
652
+ severity: 'error', code: BlenderErrorCode.REVISION_CONFLICT, path: 'baseRevision',
653
+ message: `the patch targets ${dryRun.baseRevision} but revision ${revision} is being validated`,
654
+ })
655
+ } else {
656
+ const applied = applyPatchToSpec(compiled.spec, dryRun)
657
+ const after = validateSceneSpec(applied.spec)
658
+ errors.push(...after.errors.map(issue => ({
659
+ severity: 'error', code: issue.code, path: issue.path,
660
+ message: `applying the patch — ${issue.message}`,
661
+ })))
662
+ notices.push(...applied.operations.map(record => ({
663
+ severity: 'notice', code: 'PATCH_OPERATION_WOULD_APPLY', path: null, message: record.summary,
664
+ })))
665
+ if (after.ok) {
666
+ notices.push({
667
+ severity: 'notice', code: 'PATCH_WOULD_SUCCEED', path: null,
668
+ message:
669
+ `all ${applied.operations.length} operation(s) would apply cleanly against ${revision}; ` +
670
+ `the scene digest would change from ${applied.digestBefore.slice(0, 16)} to ${applied.digestAfter.slice(0, 16)}`,
671
+ })
672
+ }
673
+ }
674
+ } catch (cause) {
675
+ const issue = /** @type {any} */ (cause).patchIssue
676
+ errors.push({
677
+ severity: 'error',
678
+ code: issue?.code ?? BlenderErrorCode.SCENE_PATCH_REJECTED,
679
+ path: issue?.path ?? null,
680
+ message: issue?.message ?? (cause instanceof Error ? cause.message : String(cause)),
681
+ })
682
+ }
683
+ }
684
+
685
+ const manifest = this.store.readRevisionManifest(projectId, revision)
686
+ return toCanonicalQAReport({
687
+ projectId,
688
+ revision,
689
+ digest,
690
+ errors,
691
+ notices,
692
+ technical: manifest?.validation ?? null,
693
+ })
694
+ }
695
+
696
+ // ---------------------------------------------------------------------------
697
+ // Rendering
698
+ // ---------------------------------------------------------------------------
699
+
700
+ /**
701
+ * Render a preview of one revision and record it against that revision.
702
+ *
703
+ * Rendering does NOT create a revision: a preview is an observation of a
704
+ * scene, not a change to it. It is written into the revision's own `previews/`
705
+ * directory, which is why a revision's previews are immutable alongside it.
706
+ *
707
+ * @param {object} request
708
+ * @param {string} request.projectId
709
+ * @param {string} [request.revision]
710
+ * @param {string} [request.cameraId]
711
+ * @param {number} [request.samples]
712
+ * @param {number} [request.width]
713
+ * @param {number} [request.height]
714
+ * @param {number} [request.frame]
715
+ * @param {AbortSignal} [request.signal]
716
+ * @returns {Promise<Record<string, unknown>>}
717
+ */
718
+ async renderPreview(request) {
719
+ const projectId = request?.projectId
720
+ const record = this.store.readRecord(projectId)
721
+ const revision = request.revision ?? record.currentRevision
722
+ const spec = this.store.readRevisionSpec(projectId, revision)
723
+ const digest = sceneSpecDigest(spec)
724
+
725
+ const profile = spec.renderProfiles?.preview
726
+ if (profile === undefined) {
727
+ throw new BlenderError(
728
+ BlenderErrorCode.RENDER_PROFILE_MISSING,
729
+ // The refusal names the FIELD and where it goes: a caller that has to guess which key is missing
730
+ // reads a message that says what is wrong but not what to do, and this one is reached by the most
731
+ // ordinary mistake there is (a spec written without a preview profile).
732
+ `Revision ${revision} defines no preview render profile, so there is nothing to render with. Add ` +
733
+ 'renderProfiles.preview to the SceneSpec (width, height, samples, engine) with ' +
734
+ 'blender_scene_patch {op: "render.profile.set"}, then render again — the profile is a property of the ' +
735
+ 'revision, not of this call.',
736
+ { detail: { projectId, revision } },
737
+ )
738
+ }
739
+
740
+ const requestedSamples = request.samples ?? profile.samples
741
+ const sampleCeiling = Math.min(
742
+ profile.maxSamplesBudget ?? this.config.maxPreviewSamples,
743
+ this.config.maxPreviewSamples,
744
+ )
745
+ const effectiveSamples = requestedSamples === undefined
746
+ ? undefined
747
+ : Math.min(requestedSamples, sampleCeiling)
748
+ /** @type {object[]} */
749
+ const warnings = []
750
+ if (effectiveSamples !== undefined && requestedSamples !== undefined && effectiveSamples < requestedSamples) {
751
+ warnings.push(warning(
752
+ BlenderWarningCode.RENDER_SAMPLES_REDUCED,
753
+ `preview samples reduced from ${requestedSamples} to ${effectiveSamples} by the configured budget ` +
754
+ `(profile budget ${profile.maxSamplesBudget ?? this.config.maxPreviewSamples}, host ceiling ${this.config.maxPreviewSamples})`,
755
+ { requested: requestedSamples, used: effectiveSamples },
756
+ ))
757
+ }
758
+
759
+ // A preview must render the scene the caller asked for. A checkpoint is
760
+ // preferred when the revision has one; otherwise the revision is compiled
761
+ // from its spec into a scratch directory first, because the spec — not a
762
+ // .blend — is the source of truth (SPEC §8.1).
763
+ const checkpoint = this.store.findCheckpointAtOrBefore(projectId, revision)
764
+ let resolvedCheckpoint = checkpoint
765
+ let compiledForThisRender = null
766
+
767
+ const jobId = this.store.allocateJobId(projectId, 'render_preview')
768
+ const startedAt = new Date().toISOString()
769
+ const startedMs = Date.now()
770
+ this.store.writeJob(projectId, {
771
+ schemaVersion: JOB_RECORD_VERSION,
772
+ jobId,
773
+ projectId,
774
+ action: 'render_preview',
775
+ revision,
776
+ status: 'running',
777
+ errorCode: null,
778
+ message: null,
779
+ startedAt,
780
+ finishedAt: null,
781
+ durationMs: null,
782
+ idempotencyKey: null,
783
+ baseRevision: revision,
784
+ artifacts: [],
785
+ warnings,
786
+ })
787
+
788
+ try {
789
+ if (resolvedCheckpoint === null || resolvedCheckpoint.revision !== revision) {
790
+ compiledForThisRender = await this.compileRevisionForRender({
791
+ projectId, revision, spec, jobId, signal: request.signal,
792
+ })
793
+ resolvedCheckpoint = compiledForThisRender
794
+ if (checkpoint !== null && checkpoint.revision !== revision) {
795
+ warnings.push(warning(
796
+ BlenderWarningCode.SCENE_COMPILER_DECISION,
797
+ `revision ${revision} has no checkpoint of its own; it was compiled from its SceneSpec for this render ` +
798
+ `(the nearest earlier checkpoint is ${checkpoint.revision})`,
799
+ { revision, nearestCheckpoint: checkpoint.revision },
800
+ ))
801
+ }
802
+ }
803
+
804
+ // PER RENDER, NOT PER REVISION. This was `<revision>/.render-staging`, shared by every preview of that
805
+ // revision — and the cleanup removes the whole directory, so two previews of ONE revision at once made
806
+ // whichever finished first delete the other's image, and the other reported `RENDER_NO_OUTPUT` for a
807
+ // render that had worked. MEASURED by the concurrency case in `host-render-orchestration.test.mjs`
808
+ // (two cameras, one revision, `Promise.all`): one of the two failed with "the renderer reported success
809
+ // but wrote no image", and the artifact index lost it. The job id is minted and written in one
810
+ // synchronous block above, so it is unique per call and can name the staging directory.
811
+ const stagingParent = join(this.store.revisionDirectory(projectId, revision), '.render-staging')
812
+ const staging = join(stagingParent, jobId)
813
+ const engineInfo = await this.runtime.resolveEngineKey(profile.engine, { signal: request.signal })
814
+ if (engineInfo.warning !== null) warnings.push(engineInfo.warning)
815
+
816
+ const outputPath = join(staging, `${request.cameraId ?? spec.cameras?.[0]?.id ?? 'camera'}.png`)
817
+ const run = await this.runtime.renderPreview({
818
+ checkpointPath: resolvedCheckpoint.path,
819
+ outputPath,
820
+ cameraId: request.cameraId,
821
+ engine: engineInfo.blenderEngine === null ? undefined : profile.engine,
822
+ width: request.width ?? profile.resolution?.[0],
823
+ height: request.height ?? profile.resolution?.[1],
824
+ samples: effectiveSamples,
825
+ frame: request.frame,
826
+ jobId,
827
+ signal: request.signal,
828
+ })
829
+
830
+ for (const entry of run.envelope.warnings ?? []) warnings.push(entry)
831
+ for (const entry of run.envelope.notices ?? []) {
832
+ warnings.push(warning(BlenderWarningCode.SCENE_COMPILER_DECISION, entry.message, { code: entry.code }))
833
+ }
834
+
835
+ const report = run.report ?? {}
836
+ if (!isFile(outputPath)) {
837
+ throw new BlenderError(
838
+ BlenderErrorCode.RENDER_NO_OUTPUT,
839
+ 'The Blender renderer reported success but wrote no image.',
840
+ { detail: { outputPath, jobId } },
841
+ )
842
+ }
843
+
844
+ // The image lives in the revision's previews/ from now on: a preview is an
845
+ // artifact OF a revision, so a later revision cannot overwrite it.
846
+ const revisionDirectory = this.store.revisionDirectory(projectId, revision)
847
+ const previewsDir = join(revisionDirectory, 'previews')
848
+ mkdirSync(previewsDir, { recursive: true })
849
+ const filename = `frame${report.frame ?? 0}-${request.cameraId ?? spec.cameras?.[0]?.id ?? 'camera'}.png`
850
+ const finalPath = resolveInside(
851
+ this.store.projectDirectory(projectId),
852
+ join(previewsDir, filename),
853
+ 'preview artifact',
854
+ )
855
+ renameSync(outputPath, finalPath)
856
+ removeTree(staging)
857
+ // The parent is shared, so it goes only when this render was the last one using it: `rmdirSync` refuses a
858
+ // non-empty directory, which is exactly the check wanted — no listing, no race, and a failure here means
859
+ // another preview is still working.
860
+ try {
861
+ rmdirSync(stagingParent)
862
+ } catch {
863
+ /* another preview is still staging into it */
864
+ }
865
+
866
+ const artifact = {
867
+ kind: 'preview',
868
+ path: `revisions/${revision}/previews/${filename}`,
869
+ cameraId: report.cameraId ?? request.cameraId ?? null,
870
+ frame: report.frame ?? null,
871
+ width: report.width ?? null,
872
+ height: report.height ?? null,
873
+ engine: report.engine ?? null,
874
+ samples: report.renderConfig?.samples ?? null,
875
+ bytes: fileSize(finalPath),
876
+ sha256: fileSha256(finalPath),
877
+ mime: 'image/png',
878
+ }
879
+
880
+ // Record the new artifact in the revision's own manifest.
881
+ //
882
+ // Without this the manifest under-reports its own revision: it is written
883
+ // once at commit time, so every preview rendered LATER left the
884
+ // `previews` array listing only the ones that existed then. That made the
885
+ // audit record disagree with the directory it describes — a caller asking
886
+ // "what previews does r0002 have?" got one answer from the manifest and
887
+ // three from the filesystem, and the manifest is the one that gets
888
+ // persisted, copied into a delivery bundle, and read by the model.
889
+ //
890
+ // This is the one WRITE that may touch a published revision, and it is
891
+ // deliberately an append-only amendment of the artifact index rather than a
892
+ // change to the revision's content: the SceneSpec, the checkpoint, the
893
+ // validation report and the manifest's identity/digest fields are all left
894
+ // exactly as committed. A preview is produced BY a revision and is not part
895
+ // of what that revision decided, which is why rendering does not create a
896
+ // revision and why this amendment is not one either.
897
+ const previews = this.store.recordRevisionPreview(projectId, revision, artifact)
898
+ // What the caller is actually looking at. A preview is the one result whose
899
+ // value depends on facts the numbers do not carry — which checkpoint it came
900
+ // from, which frame, which engine — so all three are stated rather than
901
+ // implied.
902
+ //
903
+ // The previous spelling of this line had a real defect, caught only by
904
+ // rendering through the restarted live process: the provenance was built by
905
+ // splicing a word into the middle of a sentence, so it produced
906
+ // "rendered from the revisioncheckpoint" in every ORDINARY case (the
907
+ // checkpoint is the requested revision) and read correctly only in the
908
+ // rarer inherited-checkpoint case. It is now two whole sentences rather
909
+ // than one assembled one.
910
+ // ONE SENTENCE, NOT TWO, and the second one is gone because it became unreachable: the ternary here
911
+ // used to distinguish "the checkpoint IS this revision" from "this revision inherited an earlier
912
+ // checkpoint", and the compile path above makes the second case impossible — when the nearest checkpoint
913
+ // is not this revision's, the revision is COMPILED and `resolvedCheckpoint` is the compiled result, whose
914
+ // revision is the requested one. MEASURED before deleting it: every preview through this line reports
915
+ // `resolvedCheckpoint.revision === revision`. (The sentence is kept whole rather than assembled — the
916
+ // defect this comment block records was a spliced word, "rendered from the revisioncheckpoint".)
917
+ const checkpointSource = `revision ${revision}`
918
+ // The renderer's report is not guaranteed to carry the size or the engine — the provider sets
919
+ // both, but a report that omits them used to print "nullxnull, engine null" into a warning a
920
+ // person reads. Saying that the renderer did not report them is the honest version, and it
921
+ // never invents a measurement: the numbers here are the ones the report carried, not the ones
922
+ // the profile asked for.
923
+ const measuredSize = artifact.width === null || artifact.height === null
924
+ ? 'size not reported'
925
+ : `${artifact.width}x${artifact.height}`
926
+ warnings.push(warning(
927
+ BlenderWarningCode.SCENE_COMPILER_DECISION,
928
+ `preview of ${checkpointSource}: frame ${artifact.frame}, ` +
929
+ `${measuredSize}, engine ${artifact.engine ?? 'not reported'}`,
930
+ ))
931
+
932
+ const job = this.store.writeJob(projectId, {
933
+ schemaVersion: JOB_RECORD_VERSION,
934
+ jobId,
935
+ projectId,
936
+ action: 'render_preview',
937
+ revision,
938
+ status: 'succeeded',
939
+ errorCode: null,
940
+ message: null,
941
+ startedAt,
942
+ finishedAt: new Date().toISOString(),
943
+ durationMs: Date.now() - startedMs,
944
+ idempotencyKey: null,
945
+ baseRevision: revision,
946
+ artifacts: [artifact],
947
+ warnings,
948
+ })
949
+
950
+ return {
951
+ projectId,
952
+ revision,
953
+ digest,
954
+ profile: {
955
+ engine: profile.engine,
956
+ blenderEngine: engineInfo.blenderEngine,
957
+ resolution: [artifact.width ?? profile.resolution?.[0], artifact.height ?? profile.resolution?.[1]],
958
+ samples: artifact.samples,
959
+ filmTransparent: profile.filmTransparent === true,
960
+ },
961
+ // The artifact just rendered, and — separately — every preview this
962
+ // revision now has. Both are reported because a caller asking "what did I
963
+ // just make?" and a caller asking "what does this revision have?" are
964
+ // different questions, and conflating them is how the manifest drifted in
965
+ // the first place.
966
+ artifacts: [artifact],
967
+ revisionPreviews: previews,
968
+ warnings,
969
+ job: toCanonicalJobRecord(job),
970
+ }
971
+ } catch (cause) {
972
+ removeTree(join(this.store.revisionDirectory(projectId, revision), '.render-staging', jobId))
973
+ try {
974
+ rmdirSync(join(this.store.revisionDirectory(projectId, revision), '.render-staging'))
975
+ } catch {
976
+ /* another preview is still staging into it */
977
+ }
978
+ const failure = this._failedRenderError(cause, jobId)
979
+ this.store.writeJob(projectId, {
980
+ schemaVersion: JOB_RECORD_VERSION,
981
+ jobId,
982
+ projectId,
983
+ action: 'render_preview',
984
+ revision,
985
+ status: 'failed',
986
+ errorCode: failure.code,
987
+ message: failure.message,
988
+ startedAt,
989
+ finishedAt: new Date().toISOString(),
990
+ durationMs: Date.now() - startedMs,
991
+ idempotencyKey: null,
992
+ baseRevision: revision,
993
+ artifacts: [],
994
+ warnings,
995
+ })
996
+ throw failure
997
+ } finally {
998
+ if (compiledForThisRender !== null) removeTree(compiledForThisRender.directory)
999
+ }
1000
+ }
1001
+
1002
+ /**
1003
+ * Compile a revision from its SceneSpec into a scratch checkpoint.
1004
+ * @param {object} input
1005
+ * @returns {Promise<{ revision: string, path: string, directory: string }>}
1006
+ */
1007
+ async compileRevisionForRender(input) {
1008
+ const { projectId, revision, spec } = input
1009
+ const scratch = resolveInside(
1010
+ this.store.workspaceRoot,
1011
+ join(this.store.workspaceRoot, 'tmp', `render-${revision}-${Date.now().toString(36)}`),
1012
+ 'render scratch directory',
1013
+ )
1014
+ const { writeFileSync } = await import('node:fs')
1015
+ mkdirSync(scratch, { recursive: true })
1016
+ const specPath = join(scratch, 'scene-spec.json')
1017
+ writeFileSync(specPath, `${JSON.stringify(spec, null, 2)}\n`, 'utf8')
1018
+
1019
+ let produced = null
1020
+ const compiledPath = join(scratch, 'scene.blend')
1021
+ await this.runtime.compileScene({
1022
+ sceneSpecPath: specPath,
1023
+ projectRoot: this.store.projectDirectory(projectId),
1024
+ jobId: input.jobId,
1025
+ signal: input.signal,
1026
+ // THIS CALLBACK IS A WINDOW, NOT A NOTIFICATION. The provider removes the
1027
+ // per-invocation directory as soon as it returns, so an artifact that matters
1028
+ // has to MOVE here — which is what the provider's own contract says
1029
+ // ("the caller must be able to move those bytes somewhere durable BEFORE the
1030
+ // directory is removed").
1031
+ //
1032
+ // This callback used to only CHECK for `result.blend` and record a destination
1033
+ // path, without ever writing it. `produced` then named a file nothing had
1034
+ // created, the `isFile(produced)` check below failed, and the whole
1035
+ // compile-for-render path threw `REVISION_CHECKPOINT_MISSING` — always, for
1036
+ // every revision. MEASURED by rendering a revision committed with
1037
+ // `saveCheckpoint:false` on a project whose previous revision HAD a checkpoint:
1038
+ // "Revision r0002 was compiled for rendering but produced no checkpoint."
1039
+ //
1040
+ // The visible consequence was that `saveCheckpoint:false` was a trap rather
1041
+ // than a fast path: the commit skipped the compile, and every later preview of
1042
+ // that revision was impossible — even though SceneSpec is the source of truth
1043
+ // (SPEC §8.1) and a `.blend` is by design a rebuildable artifact.
1044
+ onWorkingDirectory: info => {
1045
+ const candidate = join(info.directory, 'result.blend')
1046
+ if (!isFile(candidate)) return
1047
+ copyFileSync(candidate, compiledPath)
1048
+ produced = compiledPath
1049
+ },
1050
+ })
1051
+
1052
+ if (produced === null || !isFile(produced)) {
1053
+ throw new BlenderError(
1054
+ BlenderErrorCode.REVISION_CHECKPOINT_MISSING,
1055
+ `Revision ${revision} was compiled for rendering but produced no checkpoint.`,
1056
+ )
1057
+ }
1058
+ return { revision, path: produced, directory: scratch }
1059
+ }
1060
+
1061
+ // ---------------------------------------------------------------------------
1062
+ // M2: multi-view preview and the visual review loop
1063
+ // ---------------------------------------------------------------------------
1064
+
1065
+ /**
1066
+ * Render a plan of views for one revision and record the images against it.
1067
+ *
1068
+ * Rendering observes a scene, so — exactly like `renderPreview` — this creates no
1069
+ * revision. The images land in the revision's own `previews/views/` directory, and
1070
+ * the manifest's preview index is amended to list them (decision D28).
1071
+ *
1072
+ * @param {object} request
1073
+ * @param {string} request.projectId
1074
+ * @param {string} [request.revision]
1075
+ * @param {object[]} [request.views] - explicit views; default is the standard plan.
1076
+ * @param {string[]} [request.track] - objects to measure by isolation.
1077
+ * @param {number} [request.frame]
1078
+ * @param {string} [request.engine]
1079
+ * @param {number} [request.width]
1080
+ * @param {number} [request.height]
1081
+ * @param {number} [request.samples]
1082
+ * @param {AbortSignal} [request.signal]
1083
+ * @returns {Promise<Record<string, unknown>>}
1084
+ */
1085
+ async renderViews(request) {
1086
+ const projectId = request?.projectId
1087
+ const record = this.store.readRecord(projectId)
1088
+ const revision = request.revision ?? record.currentRevision
1089
+ const spec = this.store.readRevisionSpec(projectId, revision)
1090
+ const digest = sceneSpecDigest(spec)
1091
+ const profile = spec.renderProfiles?.preview
1092
+ if (profile === undefined) {
1093
+ throw new BlenderError(
1094
+ BlenderErrorCode.RENDER_PROFILE_MISSING,
1095
+ // The refusal names the FIELD and where it goes: a caller that has to guess which key is missing
1096
+ // reads a message that says what is wrong but not what to do, and this one is reached by the most
1097
+ // ordinary mistake there is (a spec written without a preview profile).
1098
+ `Revision ${revision} defines no preview render profile, so there is nothing to render with. Add ` +
1099
+ 'renderProfiles.preview to the SceneSpec (width, height, samples, engine) with ' +
1100
+ 'blender_scene_patch {op: "render.profile.set"}, then render again — the profile is a property of the ' +
1101
+ 'revision, not of this call.',
1102
+ { detail: { projectId, revision } },
1103
+ )
1104
+ }
1105
+
1106
+ const subject = resolveSubject(spec)
1107
+ const planned = Array.isArray(request.views) && request.views.length > 0
1108
+ ? { views: request.views, notices: [] }
1109
+ : buildViewPlan({
1110
+ spec,
1111
+ subjectId: subject.id,
1112
+ frame: request.frame,
1113
+ // The caller's roles are strict; the configured set is only a preference.
1114
+ roles: request.roles,
1115
+ preferredRoles: this.config.visualReviewViews,
1116
+ maxViews: request.maxViews,
1117
+ })
1118
+
1119
+ // Why the subject is what it is, and what the plan could not cover, are both
1120
+ // warnings rather than silence: a review that quietly scored a 2.5 mm marker, or
1121
+ // that covered two views of four, reads downstream as a clean review of the shot.
1122
+ const warnings = []
1123
+ if (subject.candidates.length > 1) {
1124
+ warnings.push(warning(
1125
+ BlenderWarningCode.SCENE_COMPILER_DECISION,
1126
+ `the subject was resolved to "${subject.id}" because ${subject.source}`,
1127
+ { subjectId: subject.id, candidates: subject.candidates.slice(0, 8), source: subject.source },
1128
+ ))
1129
+ }
1130
+ for (const notice of planned.notices) {
1131
+ warnings.push(warning(BlenderWarningCode.SCENE_COMPILER_DECISION, notice, { kind: 'view-plan' }))
1132
+ }
1133
+
1134
+ return this._renderViewPlan({
1135
+ projectId, revision, spec, digest, profile, plan: planned.views, subjectId: subject.id,
1136
+ initialWarnings: warnings,
1137
+ track: request.track,
1138
+ engine: request.engine,
1139
+ width: request.width,
1140
+ height: request.height,
1141
+ samples: request.samples,
1142
+ signal: request.signal,
1143
+ })
1144
+ }
1145
+
1146
+ /**
1147
+ * The mechanism behind {@link renderViews}: resolve a checkpoint, render the plan
1148
+ * in one Blender process, publish the PNGs into the revision, and return both the
1149
+ * artifacts and the per-view measurements that came out of the same process.
1150
+ *
1151
+ * Split from the public method because the visual loop renders the same plan for a
1152
+ * revision it has just committed, and the two must produce BYTE-IDENTICAL
1153
+ * artifact records — a review that named a different path than `renderViews` would
1154
+ * make the sheet and its images disagree about which file they describe.
1155
+ *
1156
+ * @param {object} input
1157
+ * @returns {Promise<{ views: object[], artifacts: object[], pngs: Record<string, Buffer>, warnings: object[], job: object, subjectId: string|null }>}
1158
+ */
1159
+ async _renderViewPlan(input) {
1160
+ const { projectId, revision, spec, digest, profile, plan, subjectId } = input
1161
+ /** @type {object[]} */
1162
+ // Warnings raised by the CALLER's own decisions (which subject, which views) come
1163
+ // first, so a reader sees "this review covers two views of four" before it sees
1164
+ // "samples were reduced".
1165
+ const warnings = [...(input.initialWarnings ?? [])]
1166
+
1167
+ const requestedSamples = input.samples ?? profile.samples
1168
+ const ceiling = Math.min(profile.maxSamplesBudget ?? this.config.maxPreviewSamples, this.config.maxPreviewSamples)
1169
+ const effectiveSamples = requestedSamples === undefined ? undefined : Math.min(requestedSamples, ceiling)
1170
+ if (effectiveSamples !== undefined && requestedSamples !== undefined && effectiveSamples < requestedSamples) {
1171
+ warnings.push(warning(
1172
+ BlenderWarningCode.RENDER_SAMPLES_REDUCED,
1173
+ `view samples reduced from ${requestedSamples} to ${effectiveSamples} by the configured budget`,
1174
+ { requested: requestedSamples, used: effectiveSamples },
1175
+ ))
1176
+ }
1177
+
1178
+ const engineInfo = await this.runtime.resolveEngineKey(input.engine ?? profile.engine, { signal: input.signal })
1179
+ if (engineInfo.warning !== null) warnings.push(engineInfo.warning)
1180
+
1181
+ const track = Array.isArray(input.track) && input.track.length > 0
1182
+ ? input.track
1183
+ : trackedObjects(spec, subjectId)
1184
+ // Entities the scene declared part of the subject's own body. Sent to the renderer
1185
+ // because only a ray cast can tell what is in front of the subject, and only the
1186
+ // scene can say whether that thing IS the subject (see `_visibility`).
1187
+ const parts = subjectParts(spec)
1188
+
1189
+ const jobId = this.store.allocateJobId(projectId, 'render_views')
1190
+ const startedAt = new Date().toISOString()
1191
+ const startedMs = Date.now()
1192
+ this.store.writeJob(projectId, {
1193
+ schemaVersion: JOB_RECORD_VERSION,
1194
+ jobId,
1195
+ projectId,
1196
+ action: 'render_views',
1197
+ revision,
1198
+ status: 'running',
1199
+ errorCode: null,
1200
+ message: null,
1201
+ startedAt,
1202
+ finishedAt: null,
1203
+ durationMs: null,
1204
+ idempotencyKey: null,
1205
+ baseRevision: revision,
1206
+ artifacts: [],
1207
+ warnings,
1208
+ })
1209
+
1210
+ const checkpoint = await this._resolveCheckpointForRender({ projectId, revision, spec, jobId, warnings, signal: input.signal })
1211
+
1212
+ try {
1213
+ const run = await this.runtime.renderViews({
1214
+ checkpointPath: checkpoint.path,
1215
+ views: plan.map(view => ({
1216
+ id: view.id,
1217
+ role: view.role,
1218
+ cameraId: view.cameraId,
1219
+ frame: view.frame,
1220
+ })),
1221
+ track,
1222
+ parts,
1223
+ engine: engineInfo.blenderEngine === null ? undefined : (input.engine ?? profile.engine),
1224
+ width: input.width ?? profile.resolution?.[0],
1225
+ height: input.height ?? profile.resolution?.[1],
1226
+ samples: effectiveSamples,
1227
+ jobId,
1228
+ signal: input.signal,
1229
+ })
1230
+
1231
+ for (const entry of run.envelope.warnings ?? []) warnings.push(entry)
1232
+ for (const entry of run.envelope.notices ?? []) {
1233
+ warnings.push(warning(BlenderWarningCode.SCENE_COMPILER_DECISION, entry.message, { code: entry.code }))
1234
+ }
1235
+
1236
+ // Published in VIEW ORDER, and the measurement records keep that order too:
1237
+ // the contact sheet's left-to-right reading order is derived from it, and a
1238
+ // sheet whose labels disagree with the measurements is worse than a sheet with
1239
+ // no labels at all.
1240
+ const revisionDirectory = this.store.revisionDirectory(projectId, revision)
1241
+ const viewsDirectory = join(revisionDirectory, 'previews', 'views')
1242
+ mkdirSync(viewsDirectory, { recursive: true })
1243
+
1244
+ const artifacts = []
1245
+ const measurements = []
1246
+ for (const entry of run.report.views ?? []) {
1247
+ if (typeof entry.outputPath !== 'string') continue
1248
+ const finalPath = resolveInside(
1249
+ this.store.projectDirectory(projectId),
1250
+ join(viewsDirectory, `${safeFileName(entry.viewId)}.png`),
1251
+ 'view preview artifact',
1252
+ )
1253
+ const png = run.pngs[entry.viewId]
1254
+ if (!Buffer.isBuffer(png)) {
1255
+ throw new BlenderError(
1256
+ BlenderErrorCode.RENDER_NO_OUTPUT,
1257
+ `The Blender renderer reported success for view "${entry.viewId}" but its bytes could not be read.`,
1258
+ { detail: { viewId: entry.viewId, outputPath: entry.outputPath, jobId } },
1259
+ )
1260
+ }
1261
+ // The bytes are written by the HOST, not by Blender, so the file that gets
1262
+ // hashed is the file that gets published — Blender wrote into a scratch
1263
+ // directory that no longer exists by the time anyone reads this record.
1264
+ writeFileSync(finalPath, png)
1265
+ const relative = `revisions/${revision}/previews/views/${safeFileName(entry.viewId)}.png`
1266
+ const artifact = {
1267
+ kind: 'view',
1268
+ viewId: entry.viewId,
1269
+ role: entry.role ?? null,
1270
+ path: relative,
1271
+ cameraId: entry.cameraId ?? null,
1272
+ frame: entry.frame ?? null,
1273
+ width: entry.width ?? null,
1274
+ height: entry.height ?? null,
1275
+ engine: entry.engine ?? null,
1276
+ samples: effectiveSamples ?? null,
1277
+ bytes: png.length,
1278
+ sha256: fileSha256(finalPath),
1279
+ mime: 'image/png',
1280
+ // WHEN this was produced. A preview is an EMITTED artifact: rendering one
1281
+ // replaces the files at the same paths (D28), so without a timestamp the
1282
+ // only trace of "I just rendered this" is the sha changing — which a
1283
+ // human cannot see, and which a UI that keys its <img> on the path alone
1284
+ // does not even re-fetch. Recorded here rather than inferred by a reader.
1285
+ at: new Date().toISOString(),
1286
+ }
1287
+ artifacts.push(artifact)
1288
+ measurements.push({
1289
+ viewId: entry.viewId,
1290
+ role: entry.role ?? null,
1291
+ cameraId: entry.cameraId ?? null,
1292
+ frame: entry.frame ?? null,
1293
+ width: entry.width ?? null,
1294
+ height: entry.height ?? null,
1295
+ engine: entry.engine ?? null,
1296
+ path: relative,
1297
+ caption: plan.find(view => view.id === entry.viewId)?.label ?? entry.viewId,
1298
+ purpose: plan.find(view => view.id === entry.viewId)?.purpose ?? null,
1299
+ metrics: entry.metrics ?? null,
1300
+ })
1301
+ this.store.recordRevisionPreview(projectId, revision, artifact)
1302
+ }
1303
+
1304
+ // ── one preview render = one sheet, and one generation kept back ──────
1305
+ //
1306
+ // Why the render composes a sheet at all: the individual views change on disk
1307
+ // but nothing in the panel displayed them, so a person who clicked render saw
1308
+ // "已渲染 7 个视角" and a screen identical to the one before (measured; §13.5B).
1309
+ // Why it keeps the PREVIOUS one: a preview replaces its own image, so without
1310
+ // a kept generation the panel can only ever show the present — and the
1311
+ // question a person has after a render is "what changed?".
1312
+ //
1313
+ // The sheet the REVIEW path writes (`contact-sheets/round-N.png`, the image the
1314
+ // model was shown) is deliberately untouched: it is evidence for a review, and
1315
+ // overwriting it would rewrite what a reviewer looked at.
1316
+ let previewSheets = null
1317
+ if (run.pngs !== undefined && run.pngs !== null && Object.keys(run.pngs).length > 0) {
1318
+ const directory = join(this.store.revisionDirectory(projectId, revision), 'contact-sheets')
1319
+ mkdirSync(directory, { recursive: true })
1320
+ const built = composeContactSheet({
1321
+ views: measurements
1322
+ .filter(view => Buffer.isBuffer(run.pngs[view.viewId]))
1323
+ .map(view => ({ viewId: view.viewId, label: view.caption ?? view.viewId, png: run.pngs[view.viewId] })),
1324
+ title: `${projectId} ${revision} preview ${new Date().toISOString()}`,
1325
+ })
1326
+ const currentFile = resolveInside(this.store.projectDirectory(projectId), join(directory, 'preview-current.png'), 'preview sheet')
1327
+ const previousFile = resolveInside(this.store.projectDirectory(projectId), join(directory, 'preview-previous.png'), 'previous preview sheet')
1328
+
1329
+ // Rotate FIRST, then write the new one: the file that was current becomes the
1330
+ // comparison, and its digest is recomputed from the bytes that are now there
1331
+ // rather than carried over from the manifest.
1332
+ let previousArtifact = null
1333
+ if (isFile(currentFile)) {
1334
+ // WHEN the rotated sheet was produced is the previous render's time, not
1335
+ // this one's. Stamping `now` here made the pane claim a sheet rendered
1336
+ // minutes earlier was "渲染于 <this render's clock>" — measured in the real
1337
+ // GUI, where both panes read 07:11:58 while only one of them was rendered
1338
+ // then. The time comes from the artifact that is being rotated; a store
1339
+ // written before artifacts carried `at` falls back to now, which is wrong
1340
+ // by at most one render.
1341
+ const priorCurrent = (this.store.readRevisionManifest(projectId, revision)?.contactSheets ?? [])
1342
+ .find(entry => entry.slot === PREVIEW_SHEET_SLOTS.current)
1343
+ copyFileSync(currentFile, previousFile)
1344
+ previousArtifact = {
1345
+ kind: 'contact-sheet',
1346
+ slot: PREVIEW_SHEET_SLOTS.previous,
1347
+ path: `revisions/${revision}/contact-sheets/preview-previous.png`,
1348
+ iteration: null,
1349
+ width: built.width,
1350
+ height: built.height,
1351
+ columns: built.columns,
1352
+ rows: built.rows,
1353
+ bytes: fileSize(previousFile),
1354
+ sha256: fileSha256(previousFile),
1355
+ mime: 'image/png',
1356
+ views: built.placements.map(placement => placement.viewId),
1357
+ at: priorCurrent?.at ?? new Date().toISOString(),
1358
+ }
1359
+ }
1360
+ writeFileSync(currentFile, built.png)
1361
+ const currentArtifact = {
1362
+ kind: 'contact-sheet',
1363
+ slot: PREVIEW_SHEET_SLOTS.current,
1364
+ path: `revisions/${revision}/contact-sheets/preview-current.png`,
1365
+ iteration: null,
1366
+ width: built.width,
1367
+ height: built.height,
1368
+ columns: built.columns,
1369
+ rows: built.rows,
1370
+ bytes: built.png.length,
1371
+ sha256: fileSha256(currentFile),
1372
+ mime: 'image/png',
1373
+ views: built.placements.map(placement => placement.viewId),
1374
+ at: new Date().toISOString(),
1375
+ }
1376
+ if (previousArtifact !== null) this.store.recordRevisionArtifact(projectId, revision, 'contactSheets', previousArtifact)
1377
+ this.store.recordRevisionArtifact(projectId, revision, 'contactSheets', currentArtifact)
1378
+ previewSheets = { current: currentArtifact, previous: previousArtifact }
1379
+ artifacts.push(currentArtifact)
1380
+ }
1381
+
1382
+ const job = this.store.writeJob(projectId, {
1383
+ schemaVersion: JOB_RECORD_VERSION,
1384
+ jobId,
1385
+ projectId,
1386
+ action: 'render_views',
1387
+ revision,
1388
+ status: 'succeeded',
1389
+ errorCode: null,
1390
+ message: null,
1391
+ startedAt,
1392
+ finishedAt: new Date().toISOString(),
1393
+ durationMs: Date.now() - startedMs,
1394
+ idempotencyKey: null,
1395
+ baseRevision: revision,
1396
+ artifacts,
1397
+ warnings,
1398
+ })
1399
+
1400
+ return {
1401
+ // Named explicitly: the route that renders a preview reports which revision it
1402
+ // wrote into, and without this field it reported `null` — a message that read
1403
+ // "written into null's previews" while the panel's own label said r0029.
1404
+ revision,
1405
+ views: measurements,
1406
+ artifacts,
1407
+ previewSheets,
1408
+ pngs: run.pngs,
1409
+ warnings,
1410
+ job: toCanonicalJobRecord(job),
1411
+ subjectId,
1412
+ parts,
1413
+ digest,
1414
+ profile: {
1415
+ engine: profile.engine,
1416
+ blenderEngine: engineInfo.blenderEngine,
1417
+ resolution: [artifacts[0]?.width ?? profile.resolution?.[0], artifacts[0]?.height ?? profile.resolution?.[1]],
1418
+ samples: effectiveSamples,
1419
+ },
1420
+ track,
1421
+ durationMs: run.durationMs,
1422
+ checkpointRevision: checkpoint.revision,
1423
+ }
1424
+ } catch (cause) {
1425
+ const failure = this._failedRenderError(cause, jobId)
1426
+ this.store.writeJob(projectId, {
1427
+ schemaVersion: JOB_RECORD_VERSION,
1428
+ jobId,
1429
+ projectId,
1430
+ action: 'render_views',
1431
+ revision,
1432
+ status: 'failed',
1433
+ errorCode: failure.code,
1434
+ message: failure.message,
1435
+ startedAt,
1436
+ finishedAt: new Date().toISOString(),
1437
+ durationMs: Date.now() - startedMs,
1438
+ idempotencyKey: null,
1439
+ baseRevision: revision,
1440
+ artifacts: [],
1441
+ warnings,
1442
+ })
1443
+ throw failure
1444
+ } finally {
1445
+ if (checkpoint.compiled !== null) removeTree(checkpoint.compiled)
1446
+ }
1447
+ }
1448
+
1449
+ /**
1450
+ * The error a failed render throws, after its job record exists.
1451
+ *
1452
+ * WHY THE JOB ID IS ATTACHED HERE. Both render entry points write a failed job record before they
1453
+ * throw, and that record is the only trace of an attempt whose process is gone. But `listJobs`
1454
+ * returns RENDER jobs (`renders/`), not these attempt logs (`jobs/`), so the id is the caller's only
1455
+ * handle on it — and the caller never had one: the coded error named the failure and said nothing
1456
+ * about the record that describes it. MEASURED by writing this round's contract test: a failed
1457
+ * preview left a perfectly good record that no assertion could find through the public surface.
1458
+ *
1459
+ * The cause's own detail is MERGED rather than replaced: a coded failure often carries the fields
1460
+ * that explain it, and the job id is one more field, not a substitute.
1461
+ *
1462
+ * @param {unknown} cause
1463
+ * @param {string} jobId
1464
+ * @returns {BlenderError}
1465
+ */
1466
+ _failedRenderError(cause, jobId) {
1467
+ const failure = cause instanceof BlenderError
1468
+ ? cause
1469
+ : new BlenderError(
1470
+ BlenderErrorCode.SCRIPT_ERROR,
1471
+ cause instanceof Error ? cause.message : String(cause),
1472
+ { cause },
1473
+ )
1474
+ failure.detail = { ...(failure.detail ?? {}), jobId }
1475
+ return failure
1476
+ }
1477
+
1478
+ /**
1479
+ * Resolve the `.blend` a render should open for one revision.
1480
+ *
1481
+ * A checkpoint is preferred when the revision has one; otherwise the revision is
1482
+ * compiled from its SceneSpec into a scratch directory, because the spec — not a
1483
+ * `.blend` — is the source of truth (SPEC §8.1). Compiling per render is slower,
1484
+ * so it is always REPORTED: a caller looking at an image must be able to tell
1485
+ * which scene state it came from.
1486
+ *
1487
+ * @param {object} input
1488
+ * @returns {Promise<{ revision: string, path: string, compiled: string|null }>}
1489
+ */
1490
+ async _resolveCheckpointForRender(input) {
1491
+ const { projectId, revision, spec, jobId, warnings } = input
1492
+ const checkpoint = this.store.findCheckpointAtOrBefore(projectId, revision)
1493
+ if (checkpoint !== null && checkpoint.revision === revision) {
1494
+ return { revision, path: checkpoint.path, compiled: null }
1495
+ }
1496
+ const compiled = await this.compileRevisionForRender({ projectId, revision, spec, jobId, signal: input.signal })
1497
+ if (checkpoint !== null && checkpoint.revision !== revision) {
1498
+ warnings.push(warning(
1499
+ BlenderWarningCode.SCENE_COMPILER_DECISION,
1500
+ `revision ${revision} has no checkpoint of its own; it was compiled from its SceneSpec for this render ` +
1501
+ `(the nearest earlier checkpoint is ${checkpoint.revision})`,
1502
+ { revision, nearestCheckpoint: checkpoint.revision },
1503
+ ))
1504
+ }
1505
+ return { revision, path: compiled.path, compiled: compiled.directory }
1506
+ }
1507
+
1508
+ /**
1509
+ * Score a revision's views without rendering anything new.
1510
+ *
1511
+ * The scorer is pure, so this is the cheap half of a review: a caller that has
1512
+ * measurements — from an earlier `renderViews` call, or from a test fixture — can
1513
+ * find out what the product considers wrong without spending a render.
1514
+ *
1515
+ * @param {object} request
1516
+ * @param {object[]} request.views - measurement entries as `renderViews` returns.
1517
+ * @param {string|null} [request.subjectId]
1518
+ * @returns {{ score: number, issues: object[], perView: object[] }}
1519
+ */
1520
+ scoreVisualViews(request) {
1521
+ const views = Array.isArray(request?.views) ? request.views : []
1522
+ return scoreReview(views, { subjectId: request?.subjectId ?? null })
1523
+ }
1524
+
1525
+ /**
1526
+ * One complete visual review of a revision: render, measure, compose a contact
1527
+ * sheet, score, and (unless disabled) ask the vision reviewer what it sees.
1528
+ *
1529
+ * @param {object} request
1530
+ * @param {string} request.projectId
1531
+ * @param {string} [request.revision]
1532
+ * @param {string[]} [request.roles]
1533
+ * @param {boolean} [request.includeSheet] - compose and persist the sheet. Default true.
1534
+ * @param {boolean} [request.consultReviewer] - ask the vision reviewer. Default false:
1535
+ * a review that only needs the measured score must not cost a model call.
1536
+ * @param {number} [request.iteration]
1537
+ * @param {object} [request.reviewer] - override the port (tests inject a stub).
1538
+ * @param {AbortSignal} [request.signal]
1539
+ * @returns {Promise<Record<string, unknown>>}
1540
+ */
1541
+ async visualReview(request) {
1542
+ const projectId = request?.projectId
1543
+ const record = this.store.readRecord(projectId)
1544
+ const revision = request.revision ?? record.currentRevision
1545
+ const iteration = Number.isInteger(request.iteration) ? request.iteration : 0
1546
+
1547
+ const rendered = await this.renderViews({
1548
+ projectId,
1549
+ revision,
1550
+ roles: request.roles,
1551
+ frame: request.frame,
1552
+ width: request.width,
1553
+ height: request.height,
1554
+ samples: request.samples,
1555
+ track: request.track,
1556
+ engine: request.engine,
1557
+ signal: request.signal,
1558
+ })
1559
+
1560
+ const sheetPath = `revisions/${revision}/contact-sheets/round-${iteration}.png`
1561
+ const built = buildVisualReview({
1562
+ projectId,
1563
+ revision,
1564
+ digest: rendered.digest,
1565
+ views: rendered.views,
1566
+ pngs: rendered.pngs,
1567
+ subjectId: rendered.subjectId,
1568
+ iteration,
1569
+ sheetPath,
1570
+ })
1571
+
1572
+ // Persist the sheet into the revision and index it. The sheet is an artifact OF
1573
+ // this revision — it is what this exact scene state looked like — so a later
1574
+ // revision must not overwrite it, and the index has to say it exists.
1575
+ const revisionDirectory = this.store.revisionDirectory(projectId, revision)
1576
+ const sheetDirectory = join(revisionDirectory, 'contact-sheets')
1577
+ mkdirSync(sheetDirectory, { recursive: true })
1578
+ const sheetFile = resolveInside(
1579
+ this.store.projectDirectory(projectId),
1580
+ join(sheetDirectory, `round-${iteration}.png`),
1581
+ 'contact sheet artifact',
1582
+ )
1583
+ writeFileSync(sheetFile, built.sheet.png)
1584
+ const sheetArtifact = {
1585
+ kind: 'contact-sheet',
1586
+ path: sheetPath,
1587
+ iteration,
1588
+ width: built.sheet.width,
1589
+ height: built.sheet.height,
1590
+ columns: built.sheet.columns,
1591
+ rows: built.sheet.rows,
1592
+ bytes: built.sheet.png.length,
1593
+ sha256: fileSha256(sheetFile),
1594
+ mime: 'image/png',
1595
+ views: built.sheet.placements.map(placement => placement.viewId),
1596
+ at: new Date().toISOString(),
1597
+ }
1598
+
1599
+ const review = { ...built.review, warnings: rendered.warnings }
1600
+
1601
+ if (request.consultReviewer === true) {
1602
+ const reviewer = typeof request.reviewer === 'function'
1603
+ ? request.reviewer
1604
+ : this.createVisualReviewer()
1605
+ // A failed reviewer does NOT fail the review. The render, the measurements, the
1606
+ // contact sheet and the score are all still there and all still true; the only
1607
+ // thing missing is a second opinion. Losing the sheet because a model call failed
1608
+ // would make the review strictly worse than useless — the caller would have paid
1609
+ // for four renders and received an error.
1610
+ try {
1611
+ const answer = await reviewer({
1612
+ review,
1613
+ sheetPng: built.sheet.png,
1614
+ views: rendered.views,
1615
+ iteration,
1616
+ signal: request.signal,
1617
+ })
1618
+ const context = {
1619
+ viewIds: new Set(review.perView.map(entry => entry.viewId)),
1620
+ objectIds: new Set(review.issues.map(issue => issue.objectId).filter(Boolean)),
1621
+ }
1622
+ const verified = validateFindings(answer?.findings, context)
1623
+ review.reported = verified.accepted
1624
+ review.rejected = verified.rejected
1625
+ review.reviewer = {
1626
+ model: answer?.model ?? null,
1627
+ provider: answer?.provider ?? null,
1628
+ note: typeof answer?.note === 'string' ? answer.note : null,
1629
+ proposedOperations: Array.isArray(answer?.operations) ? answer.operations.length : 0,
1630
+ raw: typeof answer?.raw === 'string' ? answer.raw : null,
1631
+ error: null,
1632
+ }
1633
+ review.suggestedOperations = Array.isArray(answer?.operations) ? answer.operations : []
1634
+ } catch (cause) {
1635
+ review.reported = []
1636
+ review.rejected = []
1637
+ review.suggestedOperations = []
1638
+ review.reviewer = {
1639
+ model: null,
1640
+ provider: null,
1641
+ note: null,
1642
+ proposedOperations: 0,
1643
+ raw: null,
1644
+ error: {
1645
+ code: cause instanceof BlenderError ? cause.code : BlenderErrorCode.RUNTIME_UNAVAILABLE,
1646
+ message: cause instanceof Error ? cause.message : String(cause),
1647
+ },
1648
+ }
1649
+ review.warnings = [
1650
+ ...(review.warnings ?? []),
1651
+ warning(
1652
+ BlenderWarningCode.PROBE_WARNING,
1653
+ 'the vision reviewer could not be consulted, so this review carries measurements and a sheet but no ' +
1654
+ `second opinion: ${review.reviewer.error.message}`,
1655
+ review.reviewer.error,
1656
+ ),
1657
+ ]
1658
+ }
1659
+ }
1660
+
1661
+ const previews = this.store.recordRevisionArtifact(projectId, revision, 'contactSheets', sheetArtifact)
1662
+ const reviewArtifact = {
1663
+ kind: 'visual-review',
1664
+ path: `revisions/${revision}/visual-reviews/round-${iteration}.json`,
1665
+ iteration,
1666
+ score: review.score,
1667
+ pass: review.pass,
1668
+ issueCount: review.issues.length,
1669
+ at: new Date().toISOString(),
1670
+ }
1671
+ const reviewDirectory = join(revisionDirectory, 'visual-reviews')
1672
+ mkdirSync(reviewDirectory, { recursive: true })
1673
+ writeJsonAtomic(
1674
+ resolveInside(this.store.projectDirectory(projectId), join(reviewDirectory, `round-${iteration}.json`), 'visual review record'),
1675
+ { review, views: rendered.views },
1676
+ )
1677
+ const reviews = this.store.recordRevisionArtifact(projectId, revision, 'reviews', reviewArtifact)
1678
+
1679
+ return {
1680
+ ...review,
1681
+ subjectId: rendered.subjectId,
1682
+ track: rendered.track,
1683
+ // Which tracked entities the SCENE declared part of the subject's own body.
1684
+ // Surfaced because it changes how an occlusion finding should be read: a part in
1685
+ // front of the subject is the product, and a part invisible in every view is a
1686
+ // missing component. A reader that cannot see the declaration cannot tell those
1687
+ // apart from the measurement alone.
1688
+ parts: rendered.parts ?? [],
1689
+ profile: rendered.profile,
1690
+ checkpointRevision: rendered.checkpointRevision,
1691
+ sheetArtifact,
1692
+ contactSheets: previews,
1693
+ reviews,
1694
+ views: rendered.views.map(view => ({
1695
+ viewId: view.viewId,
1696
+ role: view.role,
1697
+ cameraId: view.cameraId,
1698
+ frame: view.frame,
1699
+ width: view.width,
1700
+ height: view.height,
1701
+ path: view.path,
1702
+ purpose: view.purpose,
1703
+ caption: view.caption,
1704
+ objects: view.metrics?.objects ?? [],
1705
+ luminance: view.metrics?.luminance ?? null,
1706
+ })),
1707
+ job: rendered.job,
1708
+ }
1709
+ }
1710
+
1711
+ /**
1712
+ * The built-in vision reviewer: one multimodal model call, parsed into findings
1713
+ * and proposed ScenePatch operations.
1714
+ *
1715
+ * Deliberately narrow. It builds a prompt from the review's own measurements plus
1716
+ * one image, streams one answer, and validates whatever comes back. Everything
1717
+ * expensive or stateful — the loop, the cap, the adoption decision — lives in
1718
+ * `visual-loop.js`, and everything about the model route lives here, so a
1719
+ * deployment that wants a different vision model changes one config value.
1720
+ *
1721
+ * @returns {(request: object) => Promise<{ findings: object[], operations: object[], note: string|null, model: string|null, provider: string|null, raw: string|null }>}
1722
+ */
1723
+ createVisualReviewer() {
1724
+ const provider = this.config.visualReviewProvider
1725
+ const model = this.config.visualReviewModel
1726
+ return async request => {
1727
+ const llm = this.ctx.get('llm')
1728
+ const attachments = this.ctx.get('attachments')
1729
+ if (llm === undefined || attachments === undefined) {
1730
+ throw new BlenderError(
1731
+ BlenderErrorCode.RUNTIME_UNAVAILABLE,
1732
+ 'The vision reviewer needs the `llm` and `attachments` services, and at least one is not composed.',
1733
+ { detail: { llm: llm !== undefined, attachments: attachments !== undefined } },
1734
+ )
1735
+ }
1736
+
1737
+ const ref = await attachments.saveImage({
1738
+ data: request.sheetPng,
1739
+ mediaType: 'image/png',
1740
+ name: `contact-sheet-${request.review.revision}-r${request.iteration}.png`,
1741
+ })
1742
+
1743
+ // The message is built here rather than through `@deepseek-ai/dsh-llm`'s
1744
+ // `createMessage`. That helper adds nothing this call needs — a message IS
1745
+ // `{id, role, content, source}` — and importing it would make the host package
1746
+ // depend on the LLM package's internal layout, which is exactly what the
1747
+ // separate `llm` service seam exists to avoid. What the host depends on is the
1748
+ // SERVICE (`ctx.get('llm').stream`), and that dependency is one string.
1749
+ const message = {
1750
+ id: randomUUID(),
1751
+ role: 'user',
1752
+ content: [
1753
+ { type: 'text', text: buildReviewerPrompt(request.review, request.views) },
1754
+ { type: 'image', attachment: ref },
1755
+ ],
1756
+ source: { kind: 'plugin', plugin: 'deepblend.visual-reviewer', form: 'notice', summary: 'contact sheet review' },
1757
+ }
1758
+
1759
+ /** @type {object[]} */
1760
+ const chunks = []
1761
+ for await (const chunk of llm.stream({
1762
+ provider,
1763
+ model,
1764
+ messages: [message],
1765
+ // The budget must cover REASONING as well as the answer (see
1766
+ // `visualReviewMaxTokens`). The empty-answer guard below turns an exhausted
1767
+ // budget into a reported failure rather than a silent approval; this is what
1768
+ // stops it happening in the first place.
1769
+ maxTokens: this.config.visualReviewMaxTokens,
1770
+ signal: request.signal,
1771
+ })) {
1772
+ chunks.push(chunk)
1773
+ }
1774
+ const finish = chunks.find(chunk => chunk.type === 'finish')
1775
+ const usage = chunks.find(chunk => chunk.type === 'usage')
1776
+ const raw = chunks
1777
+ .filter(chunk => chunk.type === 'text-delta')
1778
+ .map(chunk => chunk.text)
1779
+ .join('')
1780
+
1781
+ if (finish !== undefined && finish.reason?.kind === 'error') {
1782
+ throw new BlenderError(
1783
+ BlenderErrorCode.RUNTIME_UNAVAILABLE,
1784
+ `The vision reviewer's model call failed: ${finish.reason.failure?.message ?? 'unknown failure'}`,
1785
+ { detail: { code: finish.reason.failure?.code ?? null } },
1786
+ )
1787
+ }
1788
+ // An empty answer is a FAILURE, not a review with nothing to say. The two are
1789
+ // indistinguishable downstream — both yield zero findings — and the difference
1790
+ // matters: "the model looked and saw nothing wrong" is a result, while "the model
1791
+ // said nothing" means no review happened at all. Reporting the second as the first
1792
+ // would let a broken reviewer silently approve every scene it was shown.
1793
+ if (raw.trim().length === 0) {
1794
+ throw new BlenderError(
1795
+ BlenderErrorCode.RUNTIME_UNAVAILABLE,
1796
+ 'The vision reviewer returned an empty answer, so no review took place. ' +
1797
+ `finish=${finish?.reason?.kind ?? 'none'}, chunks=${chunks.length}, ` +
1798
+ `types=${[...new Set(chunks.map(chunk => chunk.type))].join('/')}`,
1799
+ {
1800
+ detail: {
1801
+ finish: finish?.reason?.kind ?? null,
1802
+ failure: finish?.reason?.failure ?? null,
1803
+ chunkCount: chunks.length,
1804
+ usage: usage?.usage ?? null,
1805
+ },
1806
+ },
1807
+ )
1808
+ }
1809
+
1810
+ const parsed = parseReviewerAnswer(raw)
1811
+ return { ...parsed, model, provider, raw }
1812
+ }
1813
+ }
1814
+
1815
+ /**
1816
+ * Run the whole visual loop on a revision (SPEC §12.1).
1817
+ *
1818
+ * @param {object} request
1819
+ * @param {string} request.projectId
1820
+ * @param {string} [request.revision]
1821
+ * @param {number} [request.maxIterations]
1822
+ * @param {number} [request.stopOnRepeatedIssueCount]
1823
+ * @param {number} [request.minConfidenceForAutoFix]
1824
+ * @param {boolean} [request.autoFix] - when false the loop measures and scores once
1825
+ * and hands over immediately. That is the honest form of "review only": nothing
1826
+ * has been fixed, so a reviewer call would only produce a proposal no one asked
1827
+ * for. Default true.
1828
+ * @param {object} [request.reviewer] - override the vision port (tests inject a stub).
1829
+ * @param {AbortSignal} [request.signal]
1830
+ * @returns {Promise<Record<string, unknown>>}
1831
+ */
1832
+ async visualLoop(request) {
1833
+ const projectId = request?.projectId
1834
+ const record = this.store.readRecord(projectId)
1835
+ const revision = request.revision ?? record.currentRevision
1836
+ const reviewer = typeof request.reviewer === 'function' ? request.reviewer : this.createVisualReviewer()
1837
+ const autoFix = request.autoFix !== false
1838
+
1839
+ const reviewPort = async ({ revision: target, iteration }) => this.visualReview({
1840
+ projectId,
1841
+ revision: target,
1842
+ iteration,
1843
+ consultReviewer: false,
1844
+ roles: request.roles,
1845
+ frame: request.frame,
1846
+ width: request.width,
1847
+ height: request.height,
1848
+ samples: request.samples,
1849
+ signal: request.signal,
1850
+ })
1851
+
1852
+ const visionPort = async ({ review, round, previousRounds }) => {
1853
+ const answer = await reviewer({
1854
+ review,
1855
+ sheetPng: await this.readSheetPng(projectId, review),
1856
+ views: review.views ?? [],
1857
+ iteration: round,
1858
+ previousRounds,
1859
+ signal: request.signal,
1860
+ })
1861
+ return {
1862
+ findings: answer?.findings,
1863
+ operations: Array.isArray(answer?.operations) ? answer.operations : [],
1864
+ note: typeof answer?.note === 'string' ? answer.note : null,
1865
+ detail: { model: answer?.model ?? null, provider: answer?.provider ?? null, raw: answer?.raw ?? null },
1866
+ }
1867
+ }
1868
+
1869
+ return runVisualLoop({
1870
+ projectId,
1871
+ revision,
1872
+ review: reviewPort,
1873
+ patch: patchRequest => this.applyScenePatch(patchRequest),
1874
+ restore: restoreRequest => this.restoreRevision({
1875
+ projectId: restoreRequest.projectId,
1876
+ revision: restoreRequest.revision,
1877
+ }),
1878
+ reviewer: visionPort,
1879
+ log: line => this.ctx.logger?.info?.(`[deepblend] ${line}`),
1880
+ maxIterations: autoFix ? (request.maxIterations ?? this.config.maxVisualIterations) : 0,
1881
+ stopOnRepeatedIssueCount: request.stopOnRepeatedIssueCount ?? this.config.stopOnRepeatedIssueCount,
1882
+ minConfidenceForAutoFix: request.minConfidenceForAutoFix ?? this.visualConfidence(),
1883
+ signal: request.signal,
1884
+ })
1885
+ }
1886
+
1887
+ /**
1888
+ * Read a review's contact sheet back as bytes, so the reviewer port can be handed
1889
+ * an image rather than a path.
1890
+ *
1891
+ * The sheet is written to disk first because it is a real artifact OF the revision
1892
+ * (a human reviewing the run needs the same image the model saw), and reading it
1893
+ * back keeps one representation of it rather than two.
1894
+ *
1895
+ * @param {string} projectId
1896
+ * @param {object} review
1897
+ * @returns {Promise<Buffer>}
1898
+ */
1899
+ async readSheetPng(projectId, review) {
1900
+ const path = review?.sheet?.path
1901
+ if (typeof path !== 'string' || path.length === 0) {
1902
+ throw new BlenderError(
1903
+ BlenderErrorCode.RENDER_NO_OUTPUT,
1904
+ 'The visual review has no contact sheet recorded, so there is nothing to show the reviewer. Run ' +
1905
+ 'blender_visual_review for this revision first — it writes the sheet and records it against the ' +
1906
+ 'revision, and the reviewer is handed those bytes rather than a path.',
1907
+ )
1908
+ }
1909
+ const absolute = resolveInside(this.store.projectDirectory(projectId), join(this.store.projectDirectory(projectId), path), 'contact sheet')
1910
+ const { readFile } = await import('node:fs/promises')
1911
+ return readFile(absolute)
1912
+ }
1913
+
1914
+ /** The confidence floor, clamped into [0, 1] so a bad config cannot disable review. */
1915
+ visualConfidence() {
1916
+ const value = this.config.minVisualConfidenceForAutoFix
1917
+ return typeof value === 'number' && value >= 0 && value <= 1 ? value : 0.8
1918
+ }
1919
+
1920
+ // ---------------------------------------------------------------------------
1921
+ // Revisions and jobs
1922
+ // ---------------------------------------------------------------------------
1923
+
1924
+ // ---------------------------------------------------------------------------
1925
+ // Assets (SPEC §11 "导入用户资产": local automatic, network requires approval)
1926
+ // ---------------------------------------------------------------------------
1927
+
1928
+ /**
1929
+ * Bring one file into a project's `assets/raw/` and describe it.
1930
+ *
1931
+ * WHY THE HOST OWNS THIS AND NOT THE TOOL
1932
+ * ---------------------------------------
1933
+ * Three of the four things that can go wrong here are policy, and policy belongs
1934
+ * where every caller passes:
1935
+ *
1936
+ * - the DESTINATION is a path inside the project, so it goes through the same
1937
+ * `resolveInside` guard as every other write (SPEC §15.2);
1938
+ * - the SIZE is capped by `assetMaxBytes` (SPEC §15);
1939
+ * - a REMOTE source needs a grant, and the host refuses one that arrives without
1940
+ * it — the tool plane is simply the only place that can ask.
1941
+ *
1942
+ * The fourth, the format, is checked here for a fast, precise refusal and checked
1943
+ * again behaviourally by the compiler (D10): `hasattr` cannot tell whether an
1944
+ * importer works in this build, so the definitive answer is the one that comes from
1945
+ * trying it.
1946
+ *
1947
+ * This does NOT commit a revision. It puts bytes on disk and returns the descriptor
1948
+ * a `scene-patch` `asset.add` operation then declares — so the scene still changes
1949
+ * through exactly one path, and an ingest that is never declared leaves a file
1950
+ * nobody references rather than a scene nobody checked.
1951
+ *
1952
+ * @param {object} request
1953
+ * @param {string} request.projectId
1954
+ * @param {string} [request.sourcePath] - absolute path to a local file
1955
+ * @param {string} [request.sourceUrl] - http(s) URL; requires `approved: true`
1956
+ * @param {string} [request.assetId] - defaults to a slug of the file name
1957
+ * @param {string} [request.type] - defaults to the extension
1958
+ * @param {boolean} [request.approved] - the caller's assertion that a person agreed
1959
+ * @param {AbortSignal} [request.signal]
1960
+ * @returns {Promise<Record<string, unknown>>}
1961
+ */
1962
+ async ingestAsset(request) {
1963
+ const projectId = requireSafeSegment(request?.projectId, 'project id')
1964
+ const record = this.store.readRecord(projectId)
1965
+
1966
+ /** The scratch directory this call fetched into, or null when the source was a local file. */
1967
+ let fetchedScratch = null
1968
+ // Where the redirects went, filled by `_fetchAssetToScratch` (empty for a local source).
1969
+ const fetchedChain = []
1970
+ const sourcePath = typeof request?.sourcePath === 'string' && request.sourcePath.length > 0
1971
+ ? request.sourcePath
1972
+ : null
1973
+ const sourceUrl = typeof request?.sourceUrl === 'string' && request.sourceUrl.length > 0
1974
+ ? request.sourceUrl
1975
+ : null
1976
+ if (sourcePath === null && sourceUrl === null) {
1977
+ throw new BlenderError(
1978
+ BlenderErrorCode.ASSET_SOURCE_NOT_FOUND,
1979
+ 'ingestAsset needs either sourcePath (a local file) or sourceUrl (a remote one).',
1980
+ )
1981
+ }
1982
+ if (sourcePath !== null && sourceUrl !== null) {
1983
+ throw new BlenderError(
1984
+ BlenderErrorCode.ASSET_REQUEST_INVALID,
1985
+ 'ingestAsset takes a local source or a remote one, not both.',
1986
+ )
1987
+ }
1988
+ // The licence is CARRIED, not interpreted: this product does not decide what a licence means, it makes
1989
+ // sure the string the caller supplied survives into the two places a later reader looks — the asset
1990
+ // manifest beside the bytes, and the `asset.add` declaration the caller is told to write. It used to be
1991
+ // a declared parameter of `blender_asset_ingest` that nothing passed and nothing recorded, which is a
1992
+ // promise to the model that no reader could keep.
1993
+ const license = typeof request?.license === 'string' && request.license.trim().length > 0
1994
+ ? request.license.trim().slice(0, 512)
1995
+ : null
1996
+
1997
+ // A remote source is the one that reaches off this machine, so it is the one that
1998
+ // needs a person. Refused BEFORE anything is fetched or written.
1999
+ if (sourceUrl !== null && request?.approved !== true) {
2000
+ throw new BlenderError(
2001
+ BlenderErrorCode.ASSET_APPROVAL_REQUIRED,
2002
+ `Importing ${redactUrl(sourceUrl)} fetches bytes from the network, which needs approval (SPEC §11 ` +
2003
+ '"本地自动,网络需审批"). Nothing has been downloaded. Ask the operator, then re-issue with ' +
2004
+ 'approved:true — or point at a local file with sourcePath, which needs no approval.',
2005
+ { detail: { projectId, sourceUrl, maxBytes: this.config.assetMaxBytes } },
2006
+ )
2007
+ }
2008
+
2009
+ // ---- where it comes from ------------------------------------------------
2010
+ let staged = null
2011
+ let name = null
2012
+ if (sourcePath !== null) {
2013
+ const resolvedSource = resolve(sourcePath)
2014
+ let stats
2015
+ try {
2016
+ stats = statSync(resolvedSource)
2017
+ } catch {
2018
+ throw new BlenderError(
2019
+ BlenderErrorCode.ASSET_SOURCE_NOT_FOUND,
2020
+ `no file at ${resolvedSource}.`,
2021
+ { detail: { sourcePath: resolvedSource } },
2022
+ )
2023
+ }
2024
+ if (!stats.isFile()) {
2025
+ throw new BlenderError(
2026
+ BlenderErrorCode.ASSET_SOURCE_NOT_FOUND,
2027
+ `${resolvedSource} is not a regular file.`,
2028
+ { detail: { sourcePath: resolvedSource } },
2029
+ )
2030
+ }
2031
+ if (stats.size > this.config.assetMaxBytes) {
2032
+ throw new BlenderError(
2033
+ BlenderErrorCode.ASSET_TOO_LARGE,
2034
+ `${resolvedSource} is ${stats.size} bytes, above the configured assetMaxBytes of ` +
2035
+ `${this.config.assetMaxBytes} (SPEC §15).`,
2036
+ { detail: { sourcePath: resolvedSource, bytes: stats.size, maxBytes: this.config.assetMaxBytes } },
2037
+ )
2038
+ }
2039
+ staged = resolvedSource
2040
+ name = basename(resolvedSource)
2041
+ } else {
2042
+ staged = await this._fetchAssetToScratch(sourceUrl, request?.signal, fetchedChain)
2043
+ // (the redirect chain is reported in the RESULT below — this path has no warnings array, and the approval's
2044
+ // subject is worth stating where the caller reads the outcome rather than inventing a second channel)
2045
+ // Remembered so the `finally` below can remove it, and left null for a local source: the caller's own
2046
+ // file is not ours to delete.
2047
+ fetchedScratch = dirname(staged)
2048
+ name = decodeURIComponent(new URL(sourceUrl).pathname.split('/').filter(Boolean).pop() ?? 'asset')
2049
+ }
2050
+
2051
+ try {
2052
+ // ---- what it is ---------------------------------------------------------
2053
+ const extension = name.includes('.') ? name.slice(name.lastIndexOf('.') + 1).toLowerCase() : ''
2054
+ const type = typeof request?.type === 'string' && request.type.length > 0 ? request.type : extension
2055
+ if (!(type in IMPORT_OPERATOR_BY_ASSET_TYPE)) {
2056
+ throw new BlenderError(
2057
+ BlenderErrorCode.ASSET_FORMAT_UNAVAILABLE,
2058
+ `"${name}" is a ${type === '' ? 'file with no extension' : `.${type} file`}; this project can carry ` +
2059
+ `${Object.keys(IMPORT_OPERATOR_BY_ASSET_TYPE).join(', ')}.`,
2060
+ { detail: { name, type, supported: Object.keys(IMPORT_OPERATOR_BY_ASSET_TYPE) } },
2061
+ )
2062
+ }
2063
+
2064
+ // ---- and what its BYTES are ---------------------------------------------
2065
+ //
2066
+ // SPEC §15.2 "MIME 与扩展名双重校验": the extension picked the import operator, and this
2067
+ // is the second half — 512 bytes read off the STAGED file, before anything is copied into
2068
+ // the project, so a refusal costs nothing and leaves nothing. It reads the head with
2069
+ // `readSync` rather than `readFileSync` because a legitimate asset can be a gigabyte.
2070
+ //
2071
+ // Only a POSITIVE contradiction is refused (see `contracts/lib/asset-content.js`): a
2072
+ // known signature for another format, a NUL in a format that must be text, or an empty
2073
+ // file. The alternative — a matcher sure enough to accept as well as refuse — is also a
2074
+ // matcher sure enough to reject somebody's legitimate model, and deciding what a file
2075
+ // really is stays Blender's job (D10).
2076
+ const head = readFileHead(staged, ASSET_HEAD_BYTES)
2077
+ if (assetContentVerdict(head, type) === 'contradicts') {
2078
+ const described = describeAssetContent(head)
2079
+ const looksLike = described.empty
2080
+ ? 'an empty file'
2081
+ : (described.signature?.label ?? 'binary content, not text')
2082
+ throw new BlenderError(
2083
+ BlenderErrorCode.ASSET_CONTENT_MISMATCH,
2084
+ `"${name}" is named as a .${type} file, but its first bytes are ${looksLike}. ` +
2085
+ 'This is checked before the bytes are copied anywhere, so nothing was written. ' +
2086
+ 'Rename the file if the extension is wrong, or pass sourcePath for the file that really holds the model.',
2087
+ { detail: { name, type, headBytes: head.length, signature: described.signature?.format ?? null, empty: described.empty } },
2088
+ )
2089
+ }
2090
+
2091
+ const assetId = typeof request?.assetId === 'string' && request.assetId.length > 0
2092
+ ? request.assetId
2093
+ : (() => {
2094
+ // The file name without its extension, reduced to the id grammar. A name that
2095
+ // cannot become an id is not guessed at: the caller is told to pass one,
2096
+ // because a silently different id is how a scene ends up declaring an asset
2097
+ // nobody can find.
2098
+ const stem = name.includes('.') ? name.slice(0, name.lastIndexOf('.')) : name
2099
+ return stem
2100
+ .replace(/[^a-zA-Z0-9._-]+/g, '-')
2101
+ .replace(/-+/g, '-')
2102
+ .replace(/^[^a-zA-Z]+/, '')
2103
+ .replace(/[-._]+$/, '')
2104
+ })()
2105
+ if (!/^[a-zA-Z][a-zA-Z0-9._-]*$/.test(assetId)) {
2106
+ throw new BlenderError(
2107
+ BlenderErrorCode.PATH_SEGMENT_INVALID,
2108
+ `"${assetId}" cannot be an asset id: ids start with a letter and use letters, digits, ".", "_" and "-". ` +
2109
+ 'Pass assetId explicitly.',
2110
+ { detail: { assetId, name } },
2111
+ )
2112
+ }
2113
+
2114
+ // ---- where it goes ------------------------------------------------------
2115
+ //
2116
+ // `assets/raw/` keeps the ingested bytes distinguishable from anything a later
2117
+ // step derives from them (SPEC §13's tree has `raw/`, `normalized/` and
2118
+ // `textures/`), and it is the directory the SceneSpec's asset paths are written
2119
+ // against.
2120
+ const relativePath = `assets/raw/${requireSafeSegment(name, 'asset file name')}`
2121
+ const destination = resolveInside(
2122
+ this.store.projectDirectory(projectId),
2123
+ relativePath,
2124
+ 'asset destination',
2125
+ )
2126
+ mkdirSync(dirname(destination), { recursive: true })
2127
+ // NO IN-FLIGHT GUARD HERE, AND THAT IS A MEASUREMENT RATHER THAN AN OMISSION: everything from the copy to
2128
+ // the manifest write below is SYNCHRONOUS (`copyFileSync`, `renameSync`, `writeJsonAtomic`), so two ingests
2129
+ // of one file name cannot interleave in that window at all — a guard written for it never fires. What they
2130
+ // DO leave behind is two manifest entries for one path, which is handled at the entry below.
2131
+ //
2132
+ // COPIED TO A TEMPORARY NAME AND RENAMED, because the destination is shared by FILE NAME: two ingests of a
2133
+ // file called `model.glb` — even with different asset ids — write the same path, and `copyFileSync` writes
2134
+ // in place. `renameSync` is atomic on POSIX, the same discipline `writeJsonAtomic` applies to every JSON
2135
+ // document this store writes.
2136
+ //
2137
+ // THIS IS DEFENSIVE RATHER THAN OBSERVED, and saying so is the point: MEASURED, the mutation that puts
2138
+ // `copyFileSync` back in place survives every test, because this whole stretch is synchronous and two calls
2139
+ // cannot interleave in it. The rename costs nothing and makes the file atomic for any FUTURE reader — a
2140
+ // panel, a second process, a tool that opens the asset while an ingest runs — where the synchronous
2141
+ // argument no longer applies.
2142
+ const staging = `${destination}.incoming-${randomUUID()}`
2143
+ try {
2144
+ copyFileSync(staged, staging)
2145
+ renameSync(staging, destination)
2146
+ } catch (cause) {
2147
+ // DARK, AND NAMED: copying a file this process just wrote into a directory it just created fails only
2148
+ // when the volume is full or read-only — which is the disk-full probe's territory, not a branch a test
2149
+ // can arrange here. The cleanup is what matters: a failed ingest must not leave a `.incoming-…` file
2150
+ // behind, which is the same promise the scratch removal above makes.
2151
+ removeTree(staging)
2152
+ throw cause
2153
+ }
2154
+
2155
+ const bytes = statSync(destination).size
2156
+ // DARK, AND NAMED: the LOCAL source path checks this before copying, and the remote path caps the STREAM,
2157
+ // so this second belt is reached only by a source that grew between the two — an in-place file being
2158
+ // written by something else. Kept because the check that runs first is the one that can be bypassed.
2159
+ if (bytes > this.config.assetMaxBytes) {
2160
+ removeTree(destination)
2161
+ throw new BlenderError(
2162
+ BlenderErrorCode.ASSET_TOO_LARGE,
2163
+ `the ingested asset is ${bytes} bytes, above the configured assetMaxBytes of ${this.config.assetMaxBytes}.`,
2164
+ { detail: { bytes, maxBytes: this.config.assetMaxBytes } },
2165
+ )
2166
+ }
2167
+ const sha256 = fileSha256(destination)
2168
+
2169
+ // The manifest is a ledger beside the bytes, not a second source of truth: a file
2170
+ // whose entry is missing is still usable, and an entry whose file is missing is
2171
+ // what `SCENE_ASSET_NOT_INGESTED` warns about. It is written after the copy so it
2172
+ // never describes something that is not there.
2173
+ const manifestPath = join(this.store.projectDirectory(projectId), 'assets', 'manifest.json')
2174
+ const manifest = readJsonSafe(manifestPath) ?? { schemaVersion: 'deepblend.assets/v1', assets: [] }
2175
+ const entry = {
2176
+ assetId,
2177
+ type,
2178
+ path: relativePath,
2179
+ sha256,
2180
+ bytes,
2181
+ // WHERE THE BYTES ACTUALLY CAME FROM, which is not always the URL that was approved: a redirect moves
2182
+ // the request, and the manifest is the copy a later reader trusts. It carries the approved URL (the
2183
+ // question "what did I ask for?") and, when the chain moved, the URL that answered ("what did I get?").
2184
+ source: sourceUrl !== null
2185
+ ? {
2186
+ kind: 'url',
2187
+ url: sourceUrl,
2188
+ ...fetchedChain.length > 1 ? { resolvedUrl: fetchedChain[fetchedChain.length - 1] } : {},
2189
+ }
2190
+ : { kind: 'local', path: sourcePath },
2191
+ // `null` rather than absent when nobody said: "no licence was given" and "this asset has no licence"
2192
+ // are different statements, and only the first one is true here.
2193
+ license,
2194
+ ingestedAt: new Date().toISOString(),
2195
+ }
2196
+ // REPLACED BY PATH AS WELL AS BY ID. A destination is shared by file name, so ingesting `model.glb` under a
2197
+ // second id leaves the first entry describing bytes that are no longer there — MEASURED: two concurrent
2198
+ // ingests, one file intact, and an entry whose hash belonged to neither. The artifact index already
2199
+ // de-duplicates by PATH ("re-emitting the same path replaces its entry"); this manifest de-duplicated by id
2200
+ // alone, which is the same fact in two places disagreeing.
2201
+ const assets = [
2202
+ ...(manifest.assets ?? []).filter(candidate => candidate.assetId !== assetId && candidate.path !== relativePath),
2203
+ entry,
2204
+ ]
2205
+ .sort((left, right) => (left.assetId < right.assetId ? -1 : left.assetId > right.assetId ? 1 : 0))
2206
+ writeJsonAtomic(manifestPath, { schemaVersion: 'deepblend.assets/v1', assets })
2207
+
2208
+ return {
2209
+ projectId,
2210
+ assetId,
2211
+ type,
2212
+ path: relativePath,
2213
+ sha256,
2214
+ bytes,
2215
+ source: entry.source,
2216
+ license,
2217
+ manifestPath: 'assets/manifest.json',
2218
+ currentRevision: record.currentRevision,
2219
+ // The approval showed a URL; a redirect moves the request somewhere else. Where it ended up is part of
2220
+ // the outcome, and it is null for a local source rather than an empty chain.
2221
+ redirectedFrom: fetchedChain.length > 1
2222
+ ? { hops: fetchedChain.length - 1, chain: fetchedChain.map(redactUrl) }
2223
+ : null,
2224
+ nextStep:
2225
+ `declare it with blender_scene_patch: {op: "asset.add", asset: {id: "${assetId}", type: "${type}", ` +
2226
+ `path: "${relativePath}", sha256: "${sha256}"` +
2227
+ // THE SCHEMA'S SHAPE, NOT A BARE STRING: `asset.license` is an object (`source`, `commercialUse`,
2228
+ // `attribution`), and the first version of this advice printed `license: "CC-BY-4.0"` — which the
2229
+ // patch schema REJECTS, so a model that followed the advice would get `SCENE_PATCH_INVALID` for doing
2230
+ // what it was told. The advice is asserted to validate (`contract/schema-refs.test.mjs`).
2231
+ `${license === null ? '' : `, license: ${JSON.stringify({ source: license })}`}}}`,
2232
+ }
2233
+ } finally {
2234
+ // THE FETCHED SCRATCH IS REMOVED ON EVERY PATH, including the happy one. It used to be removed
2235
+ // only when the FETCH failed, so every successful remote ingest left the downloaded bytes behind
2236
+ // under `<workspace>/tmp/asset-<uuid>/` — a second copy of a file that is already in
2237
+ // `assets/raw/`, growing without bound. MEASURED with one ingest against a loopback server:
2238
+ // `tmp/` still held `asset-<uuid>` afterwards. The caller's OWN file is never touched:
2239
+ // `fetchedScratch` is null for a local source.
2240
+ if (fetchedScratch !== null) removeTree(fetchedScratch)
2241
+ }
2242
+ }
2243
+
2244
+ /**
2245
+ * Fetch a remote asset into scratch space, with a byte cap enforced WHILE reading.
2246
+ *
2247
+ * The cap is applied to the stream rather than to `Content-Length`, because a header
2248
+ * is a claim and the bytes are the fact — a response that lies about its length, or
2249
+ * declares none and streams forever, must stop at the same place. The scratch file
2250
+ * is removed on every failure path: a half-downloaded asset is not an asset, and
2251
+ * leaving one behind would make the next attempt's "does it exist" answer wrong.
2252
+ *
2253
+ * THE URL IS FETCHED AS GIVEN AND QUOTED ONLY AFTER `redactUrl`, because the raw one is very often a
2254
+ * presigned link and every message here also lands in a job record and in the model's transcript.
2255
+ *
2256
+ * @param {string} url
2257
+ * @param {AbortSignal} [signal]
2258
+ * @returns {Promise<string>} absolute path to the fetched file
2259
+ */
2260
+ /**
2261
+ * Fetch a URL, following redirects one hop at a time under the same rule as the first.
2262
+ *
2263
+ * The chain is written into `chainOut` rather than attached to the Response: a `Response` is not extensible,
2264
+ * and `Object.defineProperty` on one throws — which the first version of this did, surfacing as the unhelpful
2265
+ * "could not be fetched: fetch failed".
2266
+ *
2267
+ * @param {URL} first - the URL the caller asked for (already protocol-checked).
2268
+ * @param {AbortSignal} [signal]
2269
+ * @param {string[]} [chainOut] - filled with every URL the request passed through, in order.
2270
+ * @returns {Promise<Response>}
2271
+ */
2272
+ async _fetchFollowingRedirects(first, signal, chainOut = []) {
2273
+ const chain = [first.toString()]
2274
+ let current = first
2275
+ for (let hop = 0; hop <= MAX_ASSET_REDIRECTS; hop += 1) {
2276
+ const response = await fetch(current, { redirect: 'manual', signal })
2277
+ if (response.status < 300 || response.status >= 400) {
2278
+ chainOut.push(...chain)
2279
+ return response
2280
+ }
2281
+ const location = response.headers.get('location')
2282
+ if (location === null || location === '') {
2283
+ chainOut.push(...chain)
2284
+ return response
2285
+ }
2286
+ if (hop === MAX_ASSET_REDIRECTS) {
2287
+ throw new BlenderError(
2288
+ BlenderErrorCode.ASSET_FETCH_FAILED,
2289
+ `"${redactUrl(first.toString())}" redirected more than ${MAX_ASSET_REDIRECTS} times; ` +
2290
+ 'this stops rather than following a chain of unknown length.',
2291
+ { detail: { url: redactUrl(first.toString()), hops: chain.length, chain: chain.map(redactUrl) } },
2292
+ )
2293
+ }
2294
+ // The redirect's own body has to be released before the next request: an unconsumed body keeps the socket,
2295
+ // and reusing it answers ECONNRESET — which the first version of this surfaced as "fetch failed" on a
2296
+ // perfectly healthy server.
2297
+ //
2298
+ // The `catch` here is unreachable in practice and stays dark: cancelling a response body this process owns
2299
+ // does not fail, and if it somehow did there is nothing useful to do about it — the next hop is still the
2300
+ // right move. It is written as a catch rather than an ignore because an unhandled rejection would take the
2301
+ // process down, which is a worse answer than continuing.
2302
+ await response.body?.cancel().catch(() => {})
2303
+ let next
2304
+ try {
2305
+ next = new URL(location, current)
2306
+ } catch {
2307
+ throw new BlenderError(
2308
+ BlenderErrorCode.ASSET_FETCH_FAILED,
2309
+ `"${redactUrl(current.toString())}" redirected to "${redactUrl(location)}", which is not a URL.`,
2310
+ { detail: { from: redactUrl(current.toString()), location: redactUrl(location) } },
2311
+ )
2312
+ }
2313
+ if (next.protocol !== 'https:' && next.protocol !== 'http:') {
2314
+ throw new BlenderError(
2315
+ BlenderErrorCode.ASSET_FETCH_FAILED,
2316
+ `"${redactUrl(current.toString())}" redirected to "${next.protocol}", which this will not fetch; ` +
2317
+ 'only http and https are followed, on the first URL and on every hop after it.',
2318
+ { detail: { from: redactUrl(current.toString()), protocol: next.protocol, chain: chain.map(redactUrl) } },
2319
+ )
2320
+ }
2321
+ chain.push(next.toString())
2322
+ current = next
2323
+ }
2324
+ /* c8 ignore next -- the loop returns or throws on every path; this is the compiler's floor. */
2325
+ throw new BlenderError(BlenderErrorCode.ASSET_FETCH_FAILED, 'unreachable: the redirect loop always returns')
2326
+ }
2327
+
2328
+ async _fetchAssetToScratch(url, signal, chainOut = []) {
2329
+ let parsed
2330
+ try {
2331
+ parsed = new URL(url)
2332
+ } catch {
2333
+ throw new BlenderError(BlenderErrorCode.ASSET_FETCH_FAILED, `"${redactUrl(url)}" is not a URL.`)
2334
+ }
2335
+ if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
2336
+ throw new BlenderError(
2337
+ BlenderErrorCode.ASSET_FETCH_FAILED,
2338
+ `"${parsed.protocol}" is not a protocol this will fetch; use http or https.`,
2339
+ { detail: { protocol: parsed.protocol } },
2340
+ )
2341
+ }
2342
+
2343
+ const scratchDirectory = resolveInside(
2344
+ this.store.workspaceRoot,
2345
+ join(this.store.workspaceRoot, 'tmp', `asset-${randomUUID()}`),
2346
+ 'asset scratch directory',
2347
+ )
2348
+ mkdirSync(scratchDirectory, { recursive: true })
2349
+ const target = join(scratchDirectory, 'download')
2350
+
2351
+ try {
2352
+ // REDIRECTS ARE FOLLOWED BY HAND, because `redirect: 'follow'` hands the decision to the network. The URL a
2353
+ // human approves is the one they are shown, and a server answering `302 Location: http://127.0.0.1:3080/…`
2354
+ // moves the request somewhere nobody approved — including this machine's own services. MEASURED before this
2355
+ // change: the only validation was the protocol of the ORIGINAL url, and every hop after it was unexamined.
2356
+ // So each hop is checked against the same rule, the chain is bounded, and the final URL is carried back to
2357
+ // the caller (and into the tool's warnings) so the approval's subject stays visible.
2358
+ //
2359
+ // Loopback is deliberately NOT refused: the approval gate is the control for "fetch this URL", local asset
2360
+ // servers are a legitimate use, and the test suite's own remote-ingest cases serve from 127.0.0.1. What was
2361
+ // missing was not a ban but the ability to SEE where the request ended up.
2362
+ const response = await this._fetchFollowingRedirects(parsed, signal, chainOut)
2363
+ if (!response.ok) {
2364
+ throw new BlenderError(
2365
+ BlenderErrorCode.ASSET_FETCH_FAILED,
2366
+ `${redactUrl(url)} answered HTTP ${response.status}.`,
2367
+ { detail: { url: redactUrl(url), status: response.status } },
2368
+ )
2369
+ }
2370
+ const chunks = []
2371
+ let received = 0
2372
+ for await (const chunk of response.body ?? []) {
2373
+ received += chunk.byteLength
2374
+ if (received > this.config.assetMaxBytes) {
2375
+ throw new BlenderError(
2376
+ BlenderErrorCode.ASSET_TOO_LARGE,
2377
+ `${redactUrl(url)} exceeds the configured assetMaxBytes of ${this.config.assetMaxBytes}; the download ` +
2378
+ 'was stopped rather than completed.',
2379
+ { detail: { url: redactUrl(url), received, maxBytes: this.config.assetMaxBytes } },
2380
+ )
2381
+ }
2382
+ chunks.push(chunk)
2383
+ }
2384
+ if (received === 0) {
2385
+ throw new BlenderError(BlenderErrorCode.ASSET_FETCH_FAILED, `${redactUrl(url)} answered with no bytes.`)
2386
+ }
2387
+ writeFileSync(target, Buffer.concat(chunks))
2388
+ return target
2389
+ } catch (cause) {
2390
+ removeTree(scratchDirectory)
2391
+ if (cause instanceof BlenderError) throw cause
2392
+ throw new BlenderError(
2393
+ BlenderErrorCode.ASSET_FETCH_FAILED,
2394
+ `${redactUrl(url)} could not be fetched: ${cause instanceof Error ? cause.message : String(cause)}`,
2395
+ { cause, detail: { url: redactUrl(url) } },
2396
+ )
2397
+ }
2398
+ }
2399
+
2400
+ /**
2401
+ * Move a project's current pointer to an existing revision.
2402
+ *
2403
+ * Restoration is a POINTER MOVE, not a new revision. The SPEC's revision model
2404
+ * is append-only: a "restore" that created a copy would double the storage for
2405
+ * no gain and, worse, would make the history a lie about what was committed.
2406
+ * The pointer move is recorded in the project record so the history is still
2407
+ * readable as a sequence of decisions.
2408
+ *
2409
+ * @param {object} request
2410
+ * @param {string} request.projectId
2411
+ * @param {string} request.revision
2412
+ * @returns {Promise<Record<string, unknown>>}
2413
+ */
2414
+ async restoreRevision(request) {
2415
+ const projectId = request?.projectId
2416
+ const target = request?.revision
2417
+ const record = this.store.readRecord(projectId)
2418
+ const directory = this.store.revisionDirectory(projectId, target)
2419
+ if (!isFile(join(directory, 'revision-manifest.json'))) {
2420
+ throw new BlenderError(
2421
+ BlenderErrorCode.REVISION_NOT_FOUND,
2422
+ `Project "${projectId}" has no revision ${target}.`,
2423
+ { detail: { projectId, revision: target, available: this.store.listRevisions(projectId) } },
2424
+ )
2425
+ }
2426
+ if (target === record.currentRevision) {
2427
+ const manifest = this.store.readRevisionManifest(projectId, target)
2428
+ return {
2429
+ restored: false,
2430
+ reason: `${target} is already the current revision`,
2431
+ ...toCanonicalRevisionSummary({
2432
+ manifest: { ...manifest, isCurrent: true },
2433
+ checkpointPath: manifest.checkpoint ?? null,
2434
+ }),
2435
+ }
2436
+ }
2437
+
2438
+ const manifest = this.store.readRevisionManifest(projectId, target)
2439
+ const nextRecord = {
2440
+ ...record,
2441
+ currentRevision: target,
2442
+ updatedAt: new Date().toISOString(),
2443
+ restorations: [
2444
+ ...(record.restorations ?? []),
2445
+ { from: record.currentRevision, to: target, at: new Date().toISOString() },
2446
+ ],
2447
+ }
2448
+ this.store.writeRecord(projectId, nextRecord)
2449
+
2450
+ return {
2451
+ restored: true,
2452
+ from: record.currentRevision,
2453
+ ...toCanonicalRevisionSummary({
2454
+ manifest: { ...manifest, isCurrent: true },
2455
+ checkpointPath: this.store.checkpointPath(projectId, target) === null
2456
+ ? null
2457
+ : `revisions/${target}/scene.blend`,
2458
+ }),
2459
+ }
2460
+ }
2461
+
2462
+ // ---------------------------------------------------------------------------
2463
+ // M3: the persistent render job (SPEC §10)
2464
+ //
2465
+ // The two halves of a delivery live here: RENDERING a frame sequence (long,
2466
+ // resumable, cancellable) and DELIVERING it (encode, verify, publish, manifest).
2467
+ // `startFinalRender` runs both; `exportProject` runs the second half alone, from
2468
+ // frames that already exist.
2469
+ // ---------------------------------------------------------------------------
2470
+
2471
+ /**
2472
+ * Start the restart reconciler without blocking composition.
2473
+ *
2474
+ * Deferred by one tick on purpose: the reconciler signs process groups and reads
2475
+ * directories, and a Host that is still mounting rows has no `subprocess`
2476
+ * service for a resume to use. The promise is kept so a caller can await the
2477
+ * answer rather than poll for it.
2478
+ */
2479
+ _kickReconciliation() {
2480
+ this._reconciliation = new Promise((resolvePass) => {
2481
+ setTimeout(() => {
2482
+ resolvePass(this.reconcileRenderJobs().catch((cause) => {
2483
+ // A reconciler that throws must not take the Host down with it: the
2484
+ // failure is recorded and the finding list stays empty, which reads as
2485
+ // "nothing was recovered" rather than as "nothing was checked".
2486
+ this._recoveryError = cause instanceof Error ? cause.message : String(cause)
2487
+ return []
2488
+ }))
2489
+ }, 0)
2490
+ })
2491
+ }
2492
+
2493
+ /** Await the reconciliation pass that started with this Host. */
2494
+ async awaitReconciliation() {
2495
+ if (this._reconciliation === null) return []
2496
+ return this._reconciliation
2497
+ }
2498
+
2499
+ /**
2500
+ * Reconcile every unfinished render job in every project (SPEC §10.3).
2501
+ *
2502
+ * @returns {Promise<object[]>} one finding per unfinished job.
2503
+ */
2504
+ async reconcileRenderJobs() {
2505
+ const unfinished = this.renderJobs.unfinishedAcross(this.store.listProjectIds())
2506
+ /** @type {object[]} */
2507
+ const findings = []
2508
+ for (const entry of unfinished) {
2509
+ const previous = entry.record
2510
+ try {
2511
+ findings.push(await reconcileRenderJob({
2512
+ store: this.renderJobs,
2513
+ readFrameLedger,
2514
+ projectId: entry.projectId,
2515
+ jobId: entry.jobId,
2516
+ record: previous,
2517
+ orphanGraceMs: this.config.orphanGraceMs,
2518
+ write: record => (previous === null
2519
+ ? this.renderJobs.write(record)
2520
+ : this.renderJobs.write(record, { previous })),
2521
+ }))
2522
+ } catch (cause) {
2523
+ // ONE UNWRITABLE JOB MUST NOT STOP THE PASS. MEASURED with a full volume
2524
+ // (`tools/disk-full-probe.mjs`): recording a recovery means writing a record, and a record
2525
+ // cannot be written to a disk that has no room — so the reconciler threw, and a Host whose
2526
+ // volume is full would recover NOTHING, including the unfinished jobs of every other
2527
+ // project on the same store. The failure is reported as a finding like any other, which is
2528
+ // what the finding list is for, and the pass moves on.
2529
+ //
2530
+ // The job stays unfinished on disk, which is the honest state: nothing here can record an
2531
+ // answer. The frames are intact, the renderer is not running, and the next pass with room
2532
+ // recovers it.
2533
+ findings.push({
2534
+ schemaVersion: 'deepblend.render-recovery/v1',
2535
+ jobId: entry.jobId,
2536
+ projectId: entry.projectId,
2537
+ reconciledAt: new Date().toISOString(),
2538
+ previousStatus: previous?.status ?? null,
2539
+ status: 'unwritable',
2540
+ process: null,
2541
+ ledger: null,
2542
+ notes: [
2543
+ `the recovery could not be recorded (${cause instanceof Error ? cause.message : String(cause)}), ` +
2544
+ 'so this job keeps its previous status on disk and is reported again on the next pass',
2545
+ ],
2546
+ })
2547
+ }
2548
+ }
2549
+ this._recoveryFindings = findings
2550
+ return findings
2551
+ }
2552
+
2553
+ /** Findings from the most recent reconciliation pass. */
2554
+ get recoveryFindings() {
2555
+ return this._recoveryFindings
2556
+ }
2557
+
2558
+ /**
2559
+ * Attach the DSH job controller, once, if the composition has a job registry.
2560
+ *
2561
+ * `jobs.start` refuses an owner no controller serves, and the DeepBlend render
2562
+ * is an UNOWNED job on purpose: the studio is one host-level service shared by
2563
+ * every session (SPEC §4.4), so a delivery started in one session must be
2564
+ * visible and cancellable from another. `attachController` is effect-scoped and
2565
+ * idempotent, so attaching more than once is harmless.
2566
+ */
2567
+ _attachJobController() {
2568
+ if (this._jobControllerAttached) return this.ctx.get('jobs') ?? null
2569
+ const jobs = this.ctx.get('jobs')
2570
+ if (jobs === undefined || typeof jobs.attachController !== 'function') return null
2571
+ try {
2572
+ this.ctx.effect(() => jobs.attachController('deepblend-render'))
2573
+ this._jobControllerAttached = true
2574
+ } catch (cause) {
2575
+ this._jobControllerError = cause instanceof Error ? cause.message : String(cause)
2576
+ this.ctx.logger?.warn(`${LOG_SCOPE}: could not attach the job controller: ${String(cause)}`)
2577
+ return null
2578
+ }
2579
+ return jobs
2580
+ }
2581
+
2582
+ // ---------------------------------------------------------------------------
2583
+ // The job surface (SPEC §7.2, §11)
2584
+ // ---------------------------------------------------------------------------
2585
+
2586
+ /**
2587
+ * Read one job, render job first.
2588
+ *
2589
+ * Two record types can answer to the same id, and a caller asking for `jobId`
2590
+ * should not have to know which store it came from: a render job is looked up in
2591
+ * `renders/`, and an M1 attempt log in `jobs/`. A render job wins because its id
2592
+ * namespace is disjoint (`render-NNNN`), and reporting the richer record is the
2593
+ * useful answer when a caller has both.
2594
+ *
2595
+ * @param {{ projectId: string, jobId: string }} request
2596
+ * @returns {Promise<object>}
2597
+ */
2598
+ async getJob(request) {
2599
+ const projectId = request?.projectId
2600
+ const jobId = request?.jobId
2601
+ // The project is checked FIRST, so a typo in a project id is reported as a
2602
+ // project problem. Falling through to "no such job" would send the caller
2603
+ // looking for a job id that was never the mistake.
2604
+ this.store.readRecord(projectId)
2605
+ const renderRecord = jobId !== undefined ? this.renderJobs.readSafe(projectId, jobId) : null
2606
+ if (renderRecord !== null) {
2607
+ return {
2608
+ ...toCanonicalJobRecord({
2609
+ schemaVersion: RENDER_JOB_VERSION,
2610
+ jobId: renderRecord.jobId,
2611
+ projectId: renderRecord.projectId,
2612
+ action: renderRecord.type,
2613
+ revision: renderRecord.revisionId,
2614
+ status: renderRecord.status,
2615
+ startedAt: new Date(renderRecord.createdAt).toISOString(),
2616
+ finishedAt: renderRecord.finishedAt === null ? null : new Date(renderRecord.finishedAt).toISOString(),
2617
+ durationMs: renderRecord.finishedAt === null ? null : renderRecord.finishedAt - renderRecord.createdAt,
2618
+ errorCode: renderRecord.errorCode ?? null,
2619
+ message: renderRecord.message ?? null,
2620
+ }),
2621
+ kind: 'render-job',
2622
+ renderJob: this._canonicalRenderJob(renderRecord),
2623
+ }
2624
+ }
2625
+ const attempt = this.store.readJobSafe(projectId, jobId)
2626
+ if (attempt !== null) return toCanonicalJobRecord(attempt)
2627
+ throw new BlenderError(
2628
+ BlenderErrorCode.RENDER_JOB_NOT_FOUND,
2629
+ `Project "${projectId}" has no job "${jobId}" in either store (render jobs under renders/, ` +
2630
+ 'attempt logs under jobs/).',
2631
+ { detail: { projectId, jobId } },
2632
+ )
2633
+ }
2634
+
2635
+ /**
2636
+ * List a project's RENDER jobs, newest last.
2637
+ *
2638
+ * The comment here used to add "then attempt logs", which the body never did — and the difference
2639
+ * matters, because the failed record of a preview or a patch is an ATTEMPT log under `jobs/`, while
2640
+ * this lists `renders/`. Reading one of those is `getJob`, which checks both stores by id; an error
2641
+ * that leaves a record now carries that id (`_failedRenderError`), so the record is reachable from
2642
+ * the failure itself rather than only from a directory listing nobody shows a user.
2643
+ *
2644
+ * @param {{ projectId: string, limit?: number }} request
2645
+ * @returns {Promise<object>}
2646
+ */
2647
+ async listJobs(request) {
2648
+ const projectId = request?.projectId
2649
+ // An unknown project must not read as "a project with no jobs": the second is a
2650
+ // true statement about a real project, and a caller cannot tell them apart.
2651
+ this.store.readRecord(projectId)
2652
+ const records = this.renderJobs.list(projectId)
2653
+ const limit = Number.isSafeInteger(request?.limit) ? request.limit : records.length
2654
+ const selected = limit >= records.length ? records : records.slice(records.length - limit)
2655
+ return {
2656
+ projectId,
2657
+ jobs: selected.map(record => this._canonicalRenderJob(record)),
2658
+ unfinished: records.filter(record => !RenderJobStore.isTerminal(record)).map(record => record.jobId),
2659
+ recovery: this._recoveryFindings
2660
+ .filter(finding => finding.projectId === projectId)
2661
+ .map(finding => ({
2662
+ jobId: finding.jobId,
2663
+ status: finding.status,
2664
+ notes: finding.notes,
2665
+ ledger: finding.ledger,
2666
+ reconciledAt: finding.reconciledAt,
2667
+ })),
2668
+ recoveryError: this._recoveryError ?? null,
2669
+ }
2670
+ }
2671
+
2672
+ // ---------------------------------------------------------------------------
2673
+ // M4 — the read plane the workbench UI is built on (SPEC §14.3, §14.4)
2674
+ //
2675
+ // These return DOMAIN data, not view models. The browser-facing projection
2676
+ // (`buildSceneTree`, `buildQaView`, …) lives in the UI package, so this facade
2677
+ // stays what the tools already consume and the two planes cannot drift into
2678
+ // two different definitions of "the current revision".
2679
+ //
2680
+ // They are also, deliberately, the ONLY things a UI route may call: every one
2681
+ // of them reads (or, for the M3 job methods, coordinates) — none of them
2682
+ // executes anything the browser chose.
2683
+ // ---------------------------------------------------------------------------
2684
+
2685
+ /**
2686
+ * Every project in the store, most recently touched first.
2687
+ *
2688
+ * @returns {Promise<{ projects: object[], count: number, projectsRoot: string }>}
2689
+ */
2690
+ async listProjects() {
2691
+ const projects = this.store.listProjectIds().map(projectId => {
2692
+ const record = this.store.readRecord(projectId)
2693
+ const specs = record.currentRevision === null
2694
+ ? null
2695
+ : (() => {
2696
+ try {
2697
+ return summarizeSceneSpec(this.store.readRevisionSpec(projectId, record.currentRevision), {
2698
+ revision: record.currentRevision,
2699
+ revisionNumber: parseRevisionId(record.currentRevision),
2700
+ })
2701
+ } catch {
2702
+ // A project whose current revision cannot be read is still a project.
2703
+ // Dropping it from the list would make the UI silently lose work.
2704
+ return null
2705
+ }
2706
+ })()
2707
+ return {
2708
+ projectId,
2709
+ title: record.title,
2710
+ goal: record.goal ?? null,
2711
+ currentRevision: record.currentRevision,
2712
+ revisionCount: record.revisionCount ?? this.store.listRevisions(projectId).length,
2713
+ createdAt: record.createdAt ?? null,
2714
+ updatedAt: record.updatedAt ?? null,
2715
+ unreadable: specs === null && record.currentRevision !== null,
2716
+ scene: specs,
2717
+ jobs: this.renderJobs.list(projectId).map(job => ({
2718
+ jobId: job.jobId,
2719
+ type: job.type,
2720
+ status: job.status,
2721
+ revisionId: job.revisionId,
2722
+ })),
2723
+ }
2724
+ })
2725
+ projects.sort((left, right) => String(right.updatedAt ?? '').localeCompare(String(left.updatedAt ?? '')))
2726
+ // The RESOLVED root, not the row's possibly-empty value: the workbench shows
2727
+ // this string to a user who is looking for their projects on disk.
2728
+ return { projects, count: projects.length, projectsRoot: this.projectsRoot }
2729
+ }
2730
+
2731
+ /**
2732
+ * One revision in full: what it decided, what it emitted, and how it validated.
2733
+ *
2734
+ * @param {{ projectId: string, revision?: string }} request
2735
+ * @returns {Promise<object>}
2736
+ */
2737
+ async getRevisionDetail(request) {
2738
+ const projectId = request?.projectId
2739
+ const record = this.store.readRecord(projectId)
2740
+ const revision = request?.revision ?? record.currentRevision
2741
+ if (revision === null || revision === undefined) {
2742
+ throw new BlenderError(
2743
+ BlenderErrorCode.REVISION_NOT_FOUND,
2744
+ `Project "${projectId}" has no revisions yet, so there is no revision to read.`,
2745
+ { detail: { projectId } },
2746
+ )
2747
+ }
2748
+ const manifest = this.store.readRevisionManifest(projectId, revision)
2749
+ if (manifest === null) {
2750
+ throw new BlenderError(
2751
+ BlenderErrorCode.REVISION_NOT_FOUND,
2752
+ `Project "${projectId}" has no revision "${revision}".`,
2753
+ { detail: { projectId, revision } },
2754
+ )
2755
+ }
2756
+ const directory = this.store.revisionDirectory(projectId, revision)
2757
+ const spec = this.store.readRevisionSpec(projectId, revision)
2758
+ return {
2759
+ projectId,
2760
+ revision,
2761
+ isCurrent: revision === record.currentRevision,
2762
+ manifest,
2763
+ scene: summarizeSceneSpec(spec, {
2764
+ revision,
2765
+ revisionNumber: parseRevisionId(revision),
2766
+ digest: manifest.digest ?? sceneSpecDigest(spec),
2767
+ }),
2768
+ validation: readJson(join(directory, 'validation.json')),
2769
+ operations: readJson(join(directory, 'operation-manifest.json')),
2770
+ request: readJson(join(directory, 'request.json')),
2771
+ checkpoint: this.store.checkpointPath(projectId, revision) === null ? null : `revisions/${revision}/scene.blend`,
2772
+ previews: manifest.previews ?? [],
2773
+ contactSheets: manifest.contactSheets ?? [],
2774
+ reviews: manifest.reviews ?? [],
2775
+ }
2776
+ }
2777
+
2778
+ /**
2779
+ * Two revisions' specs, for a structural diff.
2780
+ *
2781
+ * The diff itself is computed by the caller (the UI projects it with
2782
+ * `buildRevisionDiff`), so this stays a read of two documents rather than a
2783
+ * third definition of what "changed" means.
2784
+ *
2785
+ * @param {{ projectId: string, from?: string, to?: string }} request
2786
+ * @returns {Promise<object>}
2787
+ */
2788
+ async readRevisionPair(request) {
2789
+ const projectId = request?.projectId
2790
+ const detail = await this.getRevisionDetail({ projectId, revision: request?.to })
2791
+ // `GENESIS_REVISION` is an internal sentinel for "the revision before the first one", and the
2792
+ // first revision's manifest legitimately records it as its base. Passing it on would make the
2793
+ // store answer `REVISION_ID_INVALID: "r0000" is not a revision id` — an error about an id the
2794
+ // caller never wrote, for a question whose real answer is "there is nothing before this one".
2795
+ // So it is folded into `null` here and the guard below answers in those terms (D130).
2796
+ const recordedBase = detail.manifest.baseRevision ?? null
2797
+ const fromRevision = request?.from ?? (recordedBase === GENESIS_REVISION ? null : recordedBase)
2798
+ if (fromRevision === null || fromRevision === undefined) {
2799
+ throw new BlenderError(
2800
+ BlenderErrorCode.REVISION_NOT_FOUND,
2801
+ `Revision "${detail.revision}" records no base revision, so there is nothing to compare it against.`,
2802
+ { detail: { projectId, revision: detail.revision } },
2803
+ )
2804
+ }
2805
+ return {
2806
+ projectId,
2807
+ fromRevision,
2808
+ toRevision: detail.revision,
2809
+ from: this.store.readRevisionSpec(projectId, fromRevision),
2810
+ to: this.store.readRevisionSpec(projectId, detail.revision),
2811
+ fromManifest: this.store.readRevisionManifest(projectId, fromRevision),
2812
+ toManifest: detail.manifest,
2813
+ }
2814
+ }
2815
+
2816
+ /**
2817
+ * The QA record of a revision: the stored technical validation plus the newest
2818
+ * visual review, both unmerged (they are different evidence — M2 §3).
2819
+ *
2820
+ * @param {{ projectId: string, revision?: string }} request
2821
+ * @returns {Promise<object>}
2822
+ */
2823
+ async getQaRecord(request) {
2824
+ const detail = await this.getRevisionDetail({ projectId: request?.projectId, revision: request?.revision })
2825
+ const reviews = Array.isArray(detail.reviews) ? detail.reviews : []
2826
+ const newest = reviews.length === 0
2827
+ ? null
2828
+ : reviews.reduce((best, entry) => ((entry?.iteration ?? 0) >= (best?.iteration ?? 0) ? entry : best), reviews[0])
2829
+ const directory = this.store.projectDirectory(detail.projectId)
2830
+ const record = newest?.path === undefined || newest?.path === null
2831
+ ? null
2832
+ : readJson(resolveInside(directory, newest.path, 'visual review record'))
2833
+ return {
2834
+ projectId: detail.projectId,
2835
+ revision: detail.revision,
2836
+ validation: detail.validation,
2837
+ review: record === null ? null : (record.review ?? null),
2838
+ reviewViews: record === null ? [] : (record.views ?? []),
2839
+ reviewArtifact: newest,
2840
+ reviewCount: reviews.length,
2841
+ }
2842
+ }
2843
+
2844
+ /**
2845
+ * Every preview-ish artifact a project has emitted, grouped by revision.
2846
+ *
2847
+ * Preview Compare needs both ends of a comparison to be *renderable*, which
2848
+ * means the project-relative path is the important field here: the browser is
2849
+ * handed a route that serves that path, never the path itself as something to
2850
+ * open (SPEC §14.3).
2851
+ *
2852
+ * @param {{ projectId: string }} request
2853
+ * @returns {Promise<object>}
2854
+ */
2855
+ async listPreviewSets(request) {
2856
+ const projectId = request?.projectId
2857
+ const record = this.store.readRecord(projectId)
2858
+ const revisions = this.store.listRevisions(projectId).map(revision => {
2859
+ const manifest = this.store.readRevisionManifest(projectId, revision)
2860
+ return {
2861
+ revision,
2862
+ isCurrent: revision === record.currentRevision,
2863
+ createdAt: manifest?.createdAt ?? null,
2864
+ summary: manifest?.summary ?? null,
2865
+ digest: manifest?.digest ?? null,
2866
+ previews: manifest?.previews ?? [],
2867
+ contactSheets: manifest?.contactSheets ?? [],
2868
+ reviews: manifest?.reviews ?? [],
2869
+ }
2870
+ })
2871
+ return { projectId, currentRevision: record.currentRevision, revisions }
2872
+ }
2873
+
2874
+ /**
2875
+ * Read one artifact of one project, for the browser to display.
2876
+ *
2877
+ * The path MUST be project-relative and resolve inside the project directory:
2878
+ * this is the one method a URL parameter reaches, so the path guard is the
2879
+ * security boundary (SPEC §15.2 "工作区路径边界", "软链接逃逸防护"). A project
2880
+ * id and a path are both required, and neither can escape.
2881
+ *
2882
+ * @param {{ projectId: string, path: string }} request
2883
+ * @returns {Promise<{ path: string, bytes: Buffer, contentType: string, size: number }>}
2884
+ */
2885
+ async readArtifact(request) {
2886
+ const projectId = request?.projectId
2887
+ const relative = request?.path
2888
+ if (typeof relative !== 'string' || relative.length === 0) {
2889
+ throw new BlenderError(BlenderErrorCode.PATH_OUTSIDE_WORKSPACE, 'readArtifact needs a project-relative path.', { detail: { projectId } })
2890
+ }
2891
+ if (isAbsolute(relative)) {
2892
+ throw new BlenderError(
2893
+ BlenderErrorCode.PATH_OUTSIDE_WORKSPACE,
2894
+ `"${relative}" is absolute; artifacts are addressed relative to the project directory (SPEC §14.3).`,
2895
+ { detail: { projectId, path: relative } },
2896
+ )
2897
+ }
2898
+ const directory = this.store.projectDirectory(projectId)
2899
+ const resolved = resolveInside(directory, relative, 'artifact path')
2900
+ if (!isFile(resolved)) {
2901
+ throw new BlenderError(BlenderErrorCode.ARTIFACT_NOT_FOUND, `There is no artifact at "${relative}" in project "${projectId}".`, {
2902
+ detail: { projectId, path: relative },
2903
+ })
2904
+ }
2905
+ const bytes = readFileSync(resolved)
2906
+ return {
2907
+ path: relative,
2908
+ bytes,
2909
+ contentType: contentTypeForArtifact(relative),
2910
+ size: bytes.byteLength,
2911
+ }
2912
+ }
2913
+
2914
+ /**
2915
+ * Cancel a live render job, and prove the process is gone (SPEC §20 M3
2916
+ * "取消后无孤儿进程").
2917
+ *
2918
+ * The report distinguishes three things that are easy to conflate: the cancel was
2919
+ * REQUESTED, the process was SIGNALLED, and the process is GONE. Only the third
2920
+ * is the acceptance condition, so it is measured (`process.kill(-pid, 0)`) after
2921
+ * the signal rather than inferred from having sent one.
2922
+ *
2923
+ * @param {{ projectId: string, jobId: string, reason?: string }} request
2924
+ * @returns {Promise<object>}
2925
+ */
2926
+ async cancelJob(request) {
2927
+ const projectId = request?.projectId
2928
+ const jobId = request?.jobId
2929
+ const renderRecord = projectId !== undefined && jobId !== undefined
2930
+ ? this.renderJobs.readSafe(projectId, jobId)
2931
+ : null
2932
+
2933
+ if (renderRecord === null) {
2934
+ // An M1 attempt log has no live process by construction.
2935
+ const record = this.store.readJob(projectId, jobId)
2936
+ return {
2937
+ jobId: record.jobId,
2938
+ kind: 'attempt-log',
2939
+ status: record.status,
2940
+ cancelled: false,
2941
+ reason: `an M1 attempt log has no cancellable process; job is already ${record.status}`,
2942
+ processGone: true,
2943
+ }
2944
+ }
2945
+
2946
+ if (RenderJobStore.isTerminal(renderRecord)) {
2947
+ return {
2948
+ jobId,
2949
+ kind: 'render-job',
2950
+ status: renderRecord.status,
2951
+ cancelled: false,
2952
+ reason: `the render job is already ${renderRecord.status}`,
2953
+ processGone: true,
2954
+ }
2955
+ }
2956
+
2957
+ const live = this._liveRenders.get(jobId)
2958
+ const reason = request?.reason ?? 'cancelled by a caller'
2959
+
2960
+ // Stop the DSH projection first so the harness stops waiting on it and the
2961
+ // model sees `stopping` immediately; the process work follows.
2962
+ const dshJobId = live?.dshJobId ?? renderRecord.dshJobId ?? null
2963
+ if (dshJobId !== null) {
2964
+ const jobs = this.ctx.get('jobs')
2965
+ try {
2966
+ jobs?.kill(dshJobId, undefined, reason)
2967
+ } catch {
2968
+ // A projection that cannot be killed (already settled, or gone with a
2969
+ // previous Host) must not stop the process cancellation below.
2970
+ }
2971
+ }
2972
+
2973
+ let processReport = { attempted: false }
2974
+ const pid = renderRecord.pid ?? null
2975
+ if (live !== undefined) {
2976
+ live.cancelled = true
2977
+ live.cancelReason = reason
2978
+ // `terminate()` is synchronous and idempotent, and it walks the provider's
2979
+ // documented ladder: SIGTERM to the process group, grace, SIGKILL.
2980
+ if (live.handle !== null) {
2981
+ try {
2982
+ live.handle.terminate()
2983
+ // The ladder is named, and the SIGNAL is deliberately not. `terminate()`
2984
+ // walks the provider's own tiers (SIGTERM to the managed range, a grace
2985
+ // period, then SIGKILL) and does not report which tier stopped the
2986
+ // process; naming one here would be a guess dressed as a measurement.
2987
+ // What IS measured is the thing the acceptance condition asks for: the
2988
+ // process is gone.
2989
+ processReport = {
2990
+ attempted: true,
2991
+ via: 'subprocess-handle',
2992
+ pid,
2993
+ ladder: "the provider's terminate(): SIGTERM to the managed range, grace, then SIGKILL",
2994
+ }
2995
+ } catch (cause) {
2996
+ processReport = { attempted: true, via: 'subprocess-handle', pid, error: String(cause) }
2997
+ }
2998
+ }
2999
+ } else if (pid !== null) {
3000
+ // No live handle in THIS process — the renderer was started by a previous
3001
+ // Host, or by a reconcile that has not adopted it. Signal the group directly
3002
+ // and verify, which is the same thing the reconciler does.
3003
+ processReport = { attempted: true, via: 'process-group', ...(await stopProcessGroup({ pid, graceMs: this.config.orphanGraceMs })) }
3004
+ }
3005
+
3006
+ // Wait for the process to be REAPED, not merely signalled. MEASURED, and it is
3007
+ // the difference between a green test and a true one: immediately after
3008
+ // `terminate()`, `ps` still shows the pid as `(Blender)` — a zombie that has
3009
+ // exited but not yet been collected by this Node process — so `kill(pid, 0)`
3010
+ // SUCCEEDS and a liveness check taken too early reports a live renderer for a
3011
+ // process that is already dead. `handle.done` resolves after the child is
3012
+ // reaped, which is the fact the acceptance condition is actually about.
3013
+ if (live !== undefined && live.handle !== null) {
3014
+ await Promise.race([
3015
+ live.handle.done.catch(() => undefined),
3016
+ new Promise(resolveWait => setTimeout(resolveWait, 15_000)),
3017
+ ])
3018
+ }
3019
+
3020
+ // Verify rather than assume. A cancel that reports success while the renderer
3021
+ // keeps writing frames is the exact failure the acceptance condition names.
3022
+ let processGone = true
3023
+ let after = null
3024
+ if (pid !== null) {
3025
+ after = checkProcessAlive(pid)
3026
+ processGone = after.alive === false
3027
+ if (processGone === false && live === undefined) {
3028
+ // A group that survived a direct signal is escalated once, then reported.
3029
+ const escalated = await stopProcessGroup({ pid, graceMs: this.config.orphanGraceMs })
3030
+ after = checkProcessAlive(pid)
3031
+ processGone = after.alive === false
3032
+ processReport = { ...processReport, escalated }
3033
+ }
3034
+ }
3035
+
3036
+ const next = this.renderJobs.write({
3037
+ ...renderRecord,
3038
+ status: 'cancelled',
3039
+ cancelledAt: Date.now(),
3040
+ finishedAt: Date.now(),
3041
+ errorCode: null,
3042
+ message: `cancelled: ${reason}`,
3043
+ }, { previous: renderRecord })
3044
+
3045
+ // Settle the live work so the DSH projection is released and the render loop
3046
+ // stops touching the record — and WAIT for it. Returning while the render loop
3047
+ // is still unwinding means a caller that cancels and immediately resumes races
3048
+ // its own previous attempt for the same frame files.
3049
+ if (live !== undefined && live.settle !== null) {
3050
+ live.settle({ status: 'killed', detail: reason })
3051
+ await this._awaitLiveGone(jobId, 15_000)
3052
+ }
3053
+
3054
+ return {
3055
+ jobId,
3056
+ kind: 'render-job',
3057
+ status: next.status,
3058
+ cancelled: true,
3059
+ reason,
3060
+ process: { ...processReport, after, gone: processGone },
3061
+ processGone,
3062
+ completedFrames: next.completedFrames?.length ?? 0,
3063
+ }
3064
+ }
3065
+
3066
+ /**
3067
+ * Start a delivery render (SPEC §7.2 `startFinalRender`, §20 M3).
3068
+ *
3069
+ * RETURNS AS SOON AS THE RENDERER IS RUNNING. It does not await the render, the
3070
+ * encode or the manifest — a 450-frame delivery is ~3.4 hours on this machine,
3071
+ * and the first acceptance condition is that a long task does not block the
3072
+ * Agent. What comes back is a job reference; what follows is a filesystem, a DSH
3073
+ * job whose output carries progress, and a durable record that survives the Host
3074
+ * being killed.
3075
+ *
3076
+ * @param {object} request
3077
+ * @param {string} request.projectId
3078
+ * @param {string} [request.revision]
3079
+ * @param {number} [request.frameStart]
3080
+ * @param {number} [request.frameEnd]
3081
+ * @param {string} [request.cameraId]
3082
+ * @param {string} [request.profileName]
3083
+ * @param {number} [request.samples]
3084
+ * @param {number[]} [request.frames]
3085
+ * @returns {Promise<object>}
3086
+ */
3087
+ async startFinalRender(request) {
3088
+ const projectId = request?.projectId
3089
+ const record = this.store.readRecord(projectId)
3090
+ const revision = request?.revision ?? record.currentRevision
3091
+ const spec = this.store.readRevisionSpec(projectId, revision)
3092
+ const profileName = request?.profileName ?? this.config.finalRenderProfile
3093
+ const profile = this._resolveRenderProfile(spec, profileName, revision)
3094
+
3095
+ const range = this._resolveDeliveryRange({ spec, request })
3096
+ const frames = range.frames
3097
+
3098
+ // An open project may hold only one delivery render at a time, and the check
3099
+ // comes before everything below for the same reason as the approval gate: a
3100
+ // refusal must not have allocated anything.
3101
+ this._assertNoActiveRender(projectId)
3102
+
3103
+ // ── the approval gate (SPEC §15.1, architecture-decisions Q7) ────────────
3104
+ //
3105
+ // Above the configured threshold, a delivery render is REFUSED unless the
3106
+ // caller presents a grant. Until M5 this was only a warning attached after the
3107
+ // job had started, which is a description of the cost rather than a control on
3108
+ // it — and the panel said so out loud ("display-only") because saying nothing
3109
+ // would have implied a guarantee that did not exist.
3110
+ //
3111
+ // The refusal is where the enforcement belongs rather than in the tool: the
3112
+ // tool plane is not the only caller (the workbench starts renders too), and a
3113
+ // control that only one of two callers respects is not a control. What the tool
3114
+ // plane adds is the means to ANSWER it — it holds the agent and the open turn
3115
+ // that `ctx.approval.request` needs — so it asks and re-issues with the grant.
3116
+ //
3117
+ // `approved` is the caller's assertion that a person said yes. The host cannot
3118
+ // verify it, and the honest reading is that it is a deliberate act either way:
3119
+ // the model may only set it after `approval.request` returned `'allowed-once'`,
3120
+ // and a human clicking "render" in the panel has approved it by clicking.
3121
+ const approvalRequired = frames.length > this.config.requireApprovalAboveFrames
3122
+ if (approvalRequired && request?.approved !== true) {
3123
+ throw new BlenderError(
3124
+ BlenderErrorCode.RENDER_APPROVAL_REQUIRED,
3125
+ `This delivery renders ${frames.length} frames, above the configured approval threshold of ` +
3126
+ `${this.config.requireApprovalAboveFrames} (SPEC §15.1). Nothing has been started. ` +
3127
+ 'Ask the operator (the approval prompt in the workbench), then re-issue with approved:true — ' +
3128
+ 'or render a smaller range first to check the scene.',
3129
+ {
3130
+ detail: {
3131
+ projectId,
3132
+ revision,
3133
+ frames: frames.length,
3134
+ threshold: this.config.requireApprovalAboveFrames,
3135
+ frameStart: frames[0] ?? null,
3136
+ frameEnd: frames[frames.length - 1] ?? null,
3137
+ },
3138
+ },
3139
+ )
3140
+ }
3141
+
3142
+ const jobId = this.renderJobs.allocateJobId(projectId)
3143
+ /** @type {object[]} */
3144
+ const warnings = []
3145
+ // THE SAME RESOLUTION A PREVIEW USES, and it is the same rule: the SceneSpec is the source of truth
3146
+ // (SPEC §8.1), so a revision committed with `saveCheckpoint: false` is COMPILED for this render instead of
3147
+ // silently rendering an earlier revision's `.blend`. MEASURED before this was fixed: a delivery of such a
3148
+ // revision used the nearest earlier checkpoint, published frames of the PREVIOUS scene under the new
3149
+ // revision's name, and said nothing — the preview path had been fixed for exactly this trap and the
3150
+ // delivery path had kept a resolver that only looks backwards (`_resolveDeliveryCheckpoint`).
3151
+ const checkpoint = await this._resolveCheckpointForRender({
3152
+ projectId, revision, spec, jobId, warnings, signal: request.signal,
3153
+ })
3154
+ const cameraId = request?.cameraId ?? this._deliveryCameraId(spec)
3155
+ const requestedSamples = request?.samples ?? profile.samples
3156
+ const effective = this._deliverySamples(profile, requestedSamples, revision)
3157
+
3158
+ const now = Date.now()
3159
+ for (const notice of range.notices) {
3160
+ warnings.push(warning(BlenderWarningCode.SCENE_COMPILER_DECISION, notice, { kind: 'delivery-range' }))
3161
+ }
3162
+ if (effective.warning !== null) warnings.push(effective.warning)
3163
+ if (approvalRequired) {
3164
+ // Reached only with a grant, because the gate above refuses otherwise. Recorded
3165
+ // as a fact about THIS job rather than as a standing note, so the workbench can
3166
+ // show that this particular render was approved and by which path.
3167
+ warnings.push(warning(
3168
+ BlenderWarningCode.SCENE_COMPILER_DECISION,
3169
+ `this delivery renders ${frames.length} frames, above the configured approval threshold of ` +
3170
+ `${this.config.requireApprovalAboveFrames} (SPEC §15.1 "高成本最终渲染达阈值审批"); ` +
3171
+ 'the caller presented an approval before it was started',
3172
+ { frames: frames.length, threshold: this.config.requireApprovalAboveFrames, approval: 'granted' },
3173
+ ))
3174
+ }
3175
+
3176
+ // THE RULE IS CHECKED AGAIN HERE, and it has to be: everything between the check above and this write is
3177
+ // fast EXCEPT the compile, which launches Blender for a revision with no checkpoint of its own. MEASURED
3178
+ // by the M5 concurrency suite: with the resolution awaited between the two, two simultaneous deliveries
3179
+ // both passed the first check and both wrote a job — the second one under the same allocated id, so the
3180
+ // store ended up holding one job where the acceptance condition says exactly one caller must win and the
3181
+ // other must be refused. Re-checking costs a directory listing and closes that window.
3182
+ this._assertNoActiveRender(projectId)
3183
+
3184
+ const created = this.renderJobs.write({
3185
+ schemaVersion: RENDER_JOB_VERSION,
3186
+ jobId,
3187
+ projectId,
3188
+ revisionId: revision,
3189
+ runId: null,
3190
+ type: 'final-render',
3191
+ status: 'queued',
3192
+ attempt: 0,
3193
+ pid: null,
3194
+ processGroupId: null,
3195
+ frameStart: frames[0],
3196
+ frameEnd: frames[frames.length - 1],
3197
+ expectedFrames: frames.length,
3198
+ completedFrames: [],
3199
+ missingFrames: frames,
3200
+ corruptFrames: [],
3201
+ framesDirectory: this.renderJobs.framesDirectory(projectId, jobId),
3202
+ jobDirectory: this.renderJobs.jobDirectory(projectId, jobId),
3203
+ filePrefix: 'frame_',
3204
+ filePadding: 4,
3205
+ fps: spec.project.fps,
3206
+ sceneFrameRange: [spec.project.frameStart, spec.project.frameEnd],
3207
+ profileName,
3208
+ renderConfig: null,
3209
+ cameraId,
3210
+ checkpointPath: checkpoint.path,
3211
+ sceneSpecDigest: sceneSpecDigest(spec),
3212
+ dshJobId: null,
3213
+ delivery: null,
3214
+ outputManifest: null,
3215
+ errorCode: null,
3216
+ message: null,
3217
+ createdAt: now,
3218
+ updatedAt: now,
3219
+ startedAt: null,
3220
+ finishedAt: null,
3221
+ warnings,
3222
+ })
3223
+
3224
+ const launched = await this._launchRenderer({
3225
+ // `effective.profile`, NOT `profile`. MEASURED by
3226
+ // `composition/hardening.e2e.mjs`: this line used to pass the spec's profile,
3227
+ // so `_deliverySamples` computed the clamped sample count, pushed a warning
3228
+ // saying so, and then the renderer was handed the UNCLAMPED profile. Both
3229
+ // directions were wrong and only one of them was loud:
3230
+ //
3231
+ // blender_final_render {samples: 100000} -> warning "reduced to 128",
3232
+ // renderer told 8 (the profile's)
3233
+ // blender_final_render {samples: 4} -> no warning at all,
3234
+ // renderer told 8 — four times the
3235
+ // cost the caller asked for, silently
3236
+ //
3237
+ // The resume path below already passed `effective.profile`; the start path did
3238
+ // not. The suite asserts what the renderer was handed, read from the `plan.json`
3239
+ // the provider writes before spawning, because the job record is what the HOST
3240
+ // believes and the plan is what the child was told.
3241
+ record: created, spec, profile: effective.profile, profileName, checkpoint, cameraId, frames, reason: 'start',
3242
+ })
3243
+
3244
+ return {
3245
+ jobId,
3246
+ projectId,
3247
+ revision,
3248
+ type: 'final-render',
3249
+ // The projection's id is returned, not just stored: correlating a DeepBlend
3250
+ // render with the harness job list is how a caller finds the progress stream
3251
+ // and how a human finds it in the Jobs panel.
3252
+ dshJobId: launched.dshJobId,
3253
+ status: this.renderJobs.read(projectId, jobId).status,
3254
+ frameStart: frames[0],
3255
+ frameEnd: frames[frames.length - 1],
3256
+ frames: frames.length,
3257
+ warnings,
3258
+ message:
3259
+ `Delivery render of ${frames.length} frame(s) started for ${projectId}/${revision}. It runs in the ` +
3260
+ 'background; poll it with blender_job_status. If the harness or Blender is interrupted, the frames ' +
3261
+ 'already rendered are kept and blender_final_render {resumeJobId} continues from the missing ones.',
3262
+ }
3263
+ }
3264
+
3265
+ /**
3266
+ * Continue an interrupted delivery render, rendering ONLY the frames that are
3267
+ * not already complete (SPEC §10.3 step 6, §20 M3 "可只渲缺失帧").
3268
+ *
3269
+ * The set to render comes from the frame LEDGER, never from the record's cached
3270
+ * `completedFrames`: the cache is written by a process that may have died, and a
3271
+ * frame file written by a process killed mid-write exists without being a frame.
3272
+ *
3273
+ * @param {{ projectId: string, jobId: string, frameStart?: number, frameEnd?: number, samples?: number }} request
3274
+ * @returns {Promise<object>}
3275
+ */
3276
+ async resumeRenderJob(request) {
3277
+ const projectId = request?.projectId
3278
+ const jobId = request?.jobId
3279
+ const record = this.renderJobs.read(projectId, jobId)
3280
+
3281
+ // A failed or cancelled job IS resumable: its frames are on disk and its work
3282
+ // is unfinished, and refusing would mean a Blender crash in hour three costs
3283
+ // the whole render. Only a completed job is finished — re-delivering it is
3284
+ // `exportProject`, which does not need a renderer at all.
3285
+ if (record.status === 'completed') {
3286
+ throw new BlenderError(
3287
+ BlenderErrorCode.RENDER_JOB_STATE_INVALID,
3288
+ `Render job ${jobId} is completed; its delivery is already published. Use blender_export to ` +
3289
+ 're-encode it, which spends no Blender time.',
3290
+ { detail: { jobId, status: record.status } },
3291
+ )
3292
+ }
3293
+ const live = this._liveRenders.get(jobId)
3294
+ if (live !== undefined && live.handle !== null) {
3295
+ throw new BlenderError(
3296
+ BlenderErrorCode.RENDER_JOB_CONFLICT,
3297
+ `Render job ${jobId} is already running in this Host.`,
3298
+ { detail: { jobId } },
3299
+ )
3300
+ }
3301
+
3302
+ const spec = this.store.readRevisionSpec(projectId, record.revisionId)
3303
+ const profileName = record.profileName ?? this.config.finalRenderProfile
3304
+ const profile = this._resolveRenderProfile(spec, profileName, record.revisionId)
3305
+ // The checkpoint the RENDER used, when the record carries one (it does for anything `startFinalRender`
3306
+ // produced): re-resolving here would compile a second scratch copy for a revision that has no checkpoint of
3307
+ // its own. An EXPORT of a job whose record predates that field still resolves for itself.
3308
+ //
3309
+ // AN `if`/`else`, NOT A TERNARY, and the reason is measurable: V8 gives the alternate of a multi-line
3310
+ // ternary a zero-count range whose span runs PAST the expression — over the statements that follow it — and
3311
+ // the coverage merge judges a line by the innermost range covering its first code character, so those
3312
+ // following lines read as never executed although the function ran (MEASURED on this very code: lines
3313
+ // 3087-3092 were reported dark while a temporary `process.stderr.write` on 3088 printed twice). A statement
3314
+ // has no such range, so the reading stays true.
3315
+ let checkpoint
3316
+ if (record.checkpointPath !== undefined && record.checkpointPath !== null) {
3317
+ checkpoint = { revision: record.revisionId, path: record.checkpointPath, compiled: null }
3318
+ } else {
3319
+ checkpoint = await this._resolveCheckpointForRender({
3320
+ projectId, revision: record.revisionId, spec, jobId, warnings: [], signal: undefined,
3321
+ })
3322
+ }
3323
+
3324
+ const expected = this.renderJobs.expectedFrames(record)
3325
+ const ledger = readFrameLedger({
3326
+ framesDirectory: this.renderJobs.framesDirectory(projectId, jobId),
3327
+ expected,
3328
+ expectedSize: {
3329
+ width: record.renderConfig?.resolution?.[0],
3330
+ height: record.renderConfig?.resolution?.[1],
3331
+ prefix: record.filePrefix,
3332
+ padding: record.filePadding,
3333
+ },
3334
+ })
3335
+
3336
+ const samples = request?.samples ?? profile.samples
3337
+ const effective = this._deliverySamples(profile, samples, record.revisionId)
3338
+
3339
+ await this._launchRenderer({
3340
+ record: {
3341
+ ...record,
3342
+ completedFrames: ledger.present.map(entry => entry.frame),
3343
+ missingFrames: ledger.toRender,
3344
+ corruptFrames: ledger.corrupt,
3345
+ renderConfig: record.renderConfig ?? { resolution: profile.resolution, samples: effective.samples },
3346
+ },
3347
+ spec,
3348
+ profile: effective.profile,
3349
+ profileName,
3350
+ checkpoint,
3351
+ cameraId: record.cameraId ?? this._deliveryCameraId(spec),
3352
+ frames: ledger.toRender,
3353
+ reason: 'resume',
3354
+ expectedFrames: expected,
3355
+ })
3356
+
3357
+ return {
3358
+ jobId,
3359
+ projectId,
3360
+ revision: record.revisionId,
3361
+ status: this.renderJobs.read(projectId, jobId).status,
3362
+ frameStart: record.frameStart,
3363
+ frameEnd: record.frameEnd,
3364
+ alreadyComplete: ledger.presentCount,
3365
+ resumed: ledger.toRender.length,
3366
+ resumedFrames: ledger.toRender,
3367
+ corrupt: ledger.corrupt.map(entry => ({ frame: entry.frame, reason: entry.reason })),
3368
+ warnings: [],
3369
+ message: ledger.toRender.length === 0
3370
+ ? 'Every frame is already present and complete; the job is finishing its delivery instead of re-rendering.'
3371
+ : `Resuming ${ledger.toRender.length} frame(s): ${ledger.missingCount} absent, ${ledger.corruptCount} incomplete.`,
3372
+ }
3373
+ }
3374
+
3375
+ /**
3376
+ * Encode and publish a delivery from frames that already exist (SPEC §7.2
3377
+ * `exportProject`).
3378
+ *
3379
+ * WHY THIS IS SEPARATE FROM `startFinalRender`
3380
+ * --------------------------------------------
3381
+ * It spends no Blender time. Re-encoding after a settings change, re-publishing
3382
+ * a package whose manifest was lost, or producing the video for a render another
3383
+ * session finished are all real, and all of them would otherwise mean rendering
3384
+ * 450 frames again.
3385
+ *
3386
+ * @param {{ projectId: string, jobId?: string, revision?: string }} request
3387
+ * @returns {Promise<object>}
3388
+ */
3389
+ async exportProject(request) {
3390
+ const projectId = request?.projectId
3391
+ const record = request?.jobId !== undefined
3392
+ ? this.renderJobs.read(projectId, request.jobId)
3393
+ : this._newestDeliverableJob(projectId, request?.revision)
3394
+
3395
+ if (record === null) {
3396
+ throw new BlenderError(
3397
+ BlenderErrorCode.RENDER_JOB_NOT_FOUND,
3398
+ `Project "${projectId}" has no render job to export. Start one with blender_final_render first.`,
3399
+ { detail: { projectId } },
3400
+ )
3401
+ }
3402
+
3403
+ const spec = this.store.readRevisionSpec(projectId, record.revisionId)
3404
+ const delivery = await this._deliverJob({ record, spec, reason: 'export' })
3405
+ return {
3406
+ jobId: record.jobId,
3407
+ projectId,
3408
+ revision: record.revisionId,
3409
+ status: delivery.status,
3410
+ video: delivery.video,
3411
+ manifest: delivery.manifest,
3412
+ verified: delivery.verified,
3413
+ problems: delivery.problems,
3414
+ completeness: delivery.completeness,
3415
+ warnings: [],
3416
+ message: delivery.verified
3417
+ ? `Delivery package published: ${delivery.video?.path}. ` +
3418
+ `${delivery.video?.probed?.frameCount} frame(s), ${delivery.video?.probed?.durationSeconds}s, ` +
3419
+ `${delivery.video?.probed?.width}x${delivery.video?.probed?.height} @ ${delivery.video?.probed?.fps} fps.`
3420
+ : `Delivery encoded but its properties do not match the job's own claims: ${JSON.stringify(delivery.problems)}`,
3421
+ }
3422
+ }
3423
+
3424
+ // ---------------------------------------------------------------------------
3425
+ // M3 internals
3426
+ // ---------------------------------------------------------------------------
3427
+
3428
+ /** The render profile a delivery uses, or a stable refusal. */
3429
+ _resolveRenderProfile(spec, profileName, revision) {
3430
+ const profile = spec?.renderProfiles?.[profileName]
3431
+ if (profile === undefined) {
3432
+ const available = Object.keys(spec?.renderProfiles ?? {})
3433
+ throw new BlenderError(
3434
+ BlenderErrorCode.RENDER_PROFILE_MISSING,
3435
+ `Revision ${revision} declares no "${profileName}" render profile, so there is nothing to render a ` +
3436
+ `delivery with. Declared profiles: ${available.length > 0 ? available.join(', ') : 'none'}.`,
3437
+ { detail: { revision, requested: profileName, available } },
3438
+ )
3439
+ }
3440
+ return profile
3441
+ }
3442
+
3443
+ /**
3444
+ * Refuse a delivery render while the project already holds one.
3445
+ *
3446
+ * Called TWICE by `startFinalRender` — once before anything is allocated, and once immediately before the job
3447
+ * record is written — because the compile in between is slow enough for a second caller to get through the
3448
+ * first check. One copy of the sentence, so both refusals read the same.
3449
+ *
3450
+ * @param {string} projectId
3451
+ */
3452
+ _assertNoActiveRender(projectId) {
3453
+ const active = this._activeRenderJob(projectId)
3454
+ if (active === null) return
3455
+ throw new BlenderError(
3456
+ BlenderErrorCode.RENDER_JOB_CONFLICT,
3457
+ `Project "${projectId}" already has ${active.jobId} in state "${active.status}" ` +
3458
+ `(${active.completedFrames?.length ?? 0}/${(active.frameEnd ?? 0) - (active.frameStart ?? 0) + 1} frames). ` +
3459
+ 'Resume it with blender_final_render {resumeJobId}, or cancel it first: two renderers writing one ' +
3460
+ 'project\'s frames would produce files neither can vouch for.',
3461
+ { detail: { projectId, activeJob: active.jobId, status: active.status } },
3462
+ )
3463
+ }
3464
+
3465
+ /** The frame range a delivery covers, with the notices explaining any narrowing. */
3466
+ _resolveDeliveryRange(input) {
3467
+ const project = input.spec.project
3468
+ const { frames: projectFrames, error } = frameNumbers(project.frameStart, project.frameEnd)
3469
+ if (error !== null) {
3470
+ throw new BlenderError(
3471
+ BlenderErrorCode.RENDER_RANGE_INVALID,
3472
+ `the SceneSpec's own frame range is invalid: ${error}`,
3473
+ { detail: { frameStart: project.frameStart, frameEnd: project.frameEnd } },
3474
+ )
3475
+ }
3476
+ const notices = []
3477
+ if (Array.isArray(input.request?.frames) && input.request.frames.length > 0) {
3478
+ // `Number(null)`, `Number('')` and `Number([])` are all 0 — so a frame list with a null in it
3479
+ // used to acquire a frame the caller never wrote, and the refusal that followed named frame 0
3480
+ // as "outside the range". Only values that really are numbers (or non-blank numeric strings)
3481
+ // are candidates; anything else is dropped HERE, where the guard below reports it as "no usable
3482
+ // frame numbers" rather than as a frame that was out of range.
3483
+ const asFrame = frame => (frame === null || frame === undefined || (typeof frame === 'string' && frame.trim() === '')
3484
+ ? Number.NaN
3485
+ : Number(frame))
3486
+ const requested = [...new Set(input.request.frames.map(asFrame))]
3487
+ .filter(frame => Number.isSafeInteger(frame))
3488
+ .sort((left, right) => left - right)
3489
+ const outside = requested.filter(frame => frame < project.frameStart || frame > project.frameEnd)
3490
+ if (requested.length === 0) {
3491
+ throw new BlenderError(
3492
+ BlenderErrorCode.RENDER_RANGE_INVALID,
3493
+ 'the requested frame list contains no usable frame numbers',
3494
+ { detail: { frames: input.request.frames } },
3495
+ )
3496
+ }
3497
+ if (outside.length > 0) {
3498
+ notices.push(
3499
+ `${outside.length} requested frame(s) fall outside the project's own range ` +
3500
+ `${project.frameStart}..${project.frameEnd} and were dropped: ${outside.slice(0, 8).join(', ')}`,
3501
+ )
3502
+ }
3503
+ const kept = requested.filter(frame => frame >= project.frameStart && frame <= project.frameEnd)
3504
+ if (kept.length === 0) {
3505
+ throw new BlenderError(
3506
+ BlenderErrorCode.RENDER_RANGE_INVALID,
3507
+ `every requested frame falls outside the project's range ${project.frameStart}..${project.frameEnd}`,
3508
+ { detail: { frames: input.request.frames } },
3509
+ )
3510
+ }
3511
+ return { frames: kept, notices }
3512
+ }
3513
+
3514
+ const start = input.request?.frameStart ?? project.frameStart
3515
+ const end = input.request?.frameEnd ?? project.frameEnd
3516
+ const { frames, error: rangeError } = frameNumbers(start, end)
3517
+ if (rangeError !== null) {
3518
+ throw new BlenderError(
3519
+ BlenderErrorCode.RENDER_RANGE_INVALID,
3520
+ `the requested delivery range is invalid: ${rangeError}`,
3521
+ { detail: { frameStart: start, frameEnd: end, projectRange: [project.frameStart, project.frameEnd] } },
3522
+ )
3523
+ }
3524
+ if (start !== project.frameStart || end !== project.frameEnd) {
3525
+ notices.push(
3526
+ `this delivery covers frames ${start}..${end}, not the project's own range ` +
3527
+ `${project.frameStart}..${project.frameEnd}; the delivery manifest records both`,
3528
+ )
3529
+ }
3530
+ return { frames, notices }
3531
+ }
3532
+
3533
+ /**
3534
+ * The samples a delivery may use, and the warning when the ceiling bit.
3535
+ *
3536
+ * Deliberately NOT bounded by `maxPreviewSamples`: that ceiling exists to stop a
3537
+ * model spending money on previews, and applying it here would silently rewrite
3538
+ * a delivery's own profile — the M3 brief calls this out as the trap waiting in
3539
+ * the final-render path. The profile's `maxSamplesBudget` is the intended
3540
+ * ceiling, with `maxFinalSamples` as the operator's backstop.
3541
+ */
3542
+ _deliverySamples(profile, requested, revision) {
3543
+ const ceiling = Math.min(
3544
+ profile.maxSamplesBudget ?? this.config.maxFinalSamples,
3545
+ this.config.maxFinalSamples,
3546
+ )
3547
+ if (requested === undefined || requested === null) {
3548
+ return { samples: profile.samples ?? null, profile, warning: null }
3549
+ }
3550
+ const effective = Math.min(requested, ceiling)
3551
+ if (effective === requested) {
3552
+ return { samples: effective, profile: { ...profile, samples: effective }, warning: null }
3553
+ }
3554
+ return {
3555
+ samples: effective,
3556
+ profile: { ...profile, samples: effective },
3557
+ warning: warning(
3558
+ BlenderWarningCode.RENDER_SAMPLES_REDUCED,
3559
+ `delivery samples reduced from ${requested} to ${effective} by the profile budget ` +
3560
+ `(profile budget ${profile.maxSamplesBudget ?? this.config.maxFinalSamples}, host ceiling ` +
3561
+ `${this.config.maxFinalSamples}); the preview ceiling maxPreviewSamples=${this.config.maxPreviewSamples} ` +
3562
+ 'deliberately does not apply to a delivery render',
3563
+ { requested, used: effective, revision },
3564
+ ),
3565
+ }
3566
+ }
3567
+
3568
+ /**
3569
+ * The camera a delivery renders from: the one the scene declares active, else
3570
+ * the first declared.
3571
+ *
3572
+ * Read from `cameras[].role` rather than from the compiled scene's
3573
+ * `activeCamera`, because the SceneSpec is the source of truth and the role is
3574
+ * the author's own statement (the same reasoning as D37 — an ordering fallback
3575
+ * is a guess, and the view plan already learned what guessing costs).
3576
+ */
3577
+ _deliveryCameraId(spec) {
3578
+ const cameras = Array.isArray(spec?.cameras) ? spec.cameras : []
3579
+ const active = cameras.find(camera => camera.role === 'active-camera')
3580
+ if (active !== undefined) return active.id
3581
+ if (cameras.length === 0) {
3582
+ throw new BlenderError(
3583
+ BlenderErrorCode.SCENE_CAMERA_MISSING,
3584
+ 'this SceneSpec declares no camera, so there is nothing to render a delivery from.',
3585
+ )
3586
+ }
3587
+ return cameras[0].id
3588
+ }
3589
+
3590
+ /** Wait, bounded, for a render loop to release its handle on a job. */
3591
+ async _awaitLiveGone(jobId, timeoutMs) {
3592
+ const deadline = Date.now() + timeoutMs
3593
+ while (Date.now() < deadline) {
3594
+ if (!this._liveRenders.has(jobId)) return true
3595
+ await new Promise(resolveWait => setTimeout(resolveWait, 50))
3596
+ }
3597
+ return !this._liveRenders.has(jobId)
3598
+ }
3599
+
3600
+ /** The non-terminal render job for a project, if any. */
3601
+ _activeRenderJob(projectId) {
3602
+ for (const record of this.renderJobs.list(projectId)) {
3603
+ if (UNFINISHED_STATUSES.includes(record.status)) return record
3604
+ }
3605
+ return null
3606
+ }
3607
+
3608
+ /** The newest render job whose frames are complete, for `exportProject`. */
3609
+ _newestDeliverableJob(projectId, revision) {
3610
+ const candidates = this.renderJobs.list(projectId)
3611
+ .filter(record => revision === undefined || record.revisionId === revision)
3612
+ for (let index = candidates.length - 1; index >= 0; index -= 1) {
3613
+ const record = candidates[index]
3614
+ if (record.delivery?.status === 'published') return record
3615
+ if (record.status === 'completed') return record
3616
+ const expected = record.expectedFrames ?? (record.frameEnd - record.frameStart + 1)
3617
+ if ((record.completedFrames?.length ?? 0) >= expected) return record
3618
+ }
3619
+ return candidates.length > 0 ? candidates[candidates.length - 1] : null
3620
+ }
3621
+
3622
+ /**
3623
+ * Launch a renderer for a record and return once it is RUNNING.
3624
+ *
3625
+ * The record has already been written; everything below happens in the
3626
+ * background, and the returned promise resolves as soon as the child has been
3627
+ * spawned so a tool call can answer with a job id instead of a 3.4-hour wait.
3628
+ */
3629
+ async _launchRenderer(input) {
3630
+ const { record, profile, profileName, checkpoint } = input
3631
+ const projectId = record.projectId
3632
+ const jobId = record.jobId
3633
+ const frames = input.frames
3634
+ const jobs = this._attachJobController()
3635
+
3636
+ /** @type {object} */
3637
+ const live = {
3638
+ jobId,
3639
+ cancelled: false,
3640
+ cancelReason: null,
3641
+ handle: null,
3642
+ run: null,
3643
+ dshJobId: null,
3644
+ output: '',
3645
+ verified: new Set(record.completedFrames ?? []),
3646
+ // Filled in below, from the profile actually being applied. Taking it from
3647
+ // `record.renderConfig` would be null on a FIRST attempt — the record that
3648
+ // carries it is written a few lines later — so the per-frame check would
3649
+ // verify existence and completeness but not dimensions, while the ledger
3650
+ // verifies all three. Two checks of different strength on the same question
3651
+ // is the shape of defect D57.
3652
+ expectedSize: { width: undefined, height: undefined },
3653
+ settle: null,
3654
+ done: null,
3655
+ journal: new JournalTail(join(this.renderJobs.jobDirectory(projectId, jobId), 'events.jsonl')),
3656
+ attemptToken: null,
3657
+ startedAt: Date.now(),
3658
+ }
3659
+
3660
+ const expected = input.expectedFrames ?? this.renderJobs.expectedFrames(record)
3661
+ const renderConfig = record.renderConfig ?? {
3662
+ resolution: profile.resolution,
3663
+ samples: profile.samples,
3664
+ engine: profile.engine,
3665
+ viewTransform: profile.colorManagement?.viewTransform ?? null,
3666
+ fps: record.fps,
3667
+ }
3668
+ live.expectedSize = { width: renderConfig.resolution?.[0], height: renderConfig.resolution?.[1] }
3669
+
3670
+ let next = this.renderJobs.write({
3671
+ ...record,
3672
+ status: 'running',
3673
+ attempt: (record.attempt ?? 0) + 1,
3674
+ // The pid of the PREVIOUS attempt is dropped here, and this is a measured fix
3675
+ // rather than tidiness. Carrying it forward left a resumed job naming a
3676
+ // process that no longer exists: the record said `pid: 87209` while the
3677
+ // renderer actually writing frames was 87455. Everything that finds a
3678
+ // renderer by pid then looks at the wrong process — a second restart would
3679
+ // "stop" the dead one and leave the live one running forever, which is
3680
+ // exactly the orphan the acceptance condition forbids. It surfaced only in a
3681
+ // real 60-frame delivery that was killed and resumed; every short test had
3682
+ // finished before the second attempt began.
3683
+ pid: null,
3684
+ processGroupId: null,
3685
+ completedFrames: [...live.verified],
3686
+ missingFrames: expected.filter(frame => !live.verified.has(frame)),
3687
+ renderConfig,
3688
+ startedAt: record.startedAt ?? Date.now(),
3689
+ errorCode: null,
3690
+ message: input.reason === 'resume'
3691
+ ? `resumed: rendering ${frames.length} missing frame(s)`
3692
+ : `rendering ${frames.length} frame(s)`,
3693
+ }, { previous: record })
3694
+
3695
+ this._liveRenders.set(jobId, live)
3696
+
3697
+ // The DSH projection, when the composition has a job registry. Absence is
3698
+ // reported, never silent: a caller that asked for a background job and got an
3699
+ // unprojected one must be able to see why.
3700
+ if (jobs !== null) {
3701
+ live.done = new Promise((resolveDone) => { live.settle = resolveDone })
3702
+ try {
3703
+ live.dshJobId = jobs.start({
3704
+ kind: 'blender-render',
3705
+ label: `render ${projectId}/${record.revisionId} frames ${record.frameStart}..${record.frameEnd}`,
3706
+ run: () => ({
3707
+ cancel: (reason) => {
3708
+ if (live.cancelled) return
3709
+ live.cancelled = true
3710
+ live.cancelReason = typeof reason === 'string' && reason.length > 0 ? reason : 'cancelled'
3711
+ try {
3712
+ live.handle?.terminate()
3713
+ } catch {
3714
+ /* the settle path records what happened */
3715
+ }
3716
+ },
3717
+ done: live.done,
3718
+ readOutput: () => {
3719
+ const text = live.output
3720
+ live.output = ''
3721
+ return text
3722
+ },
3723
+ }),
3724
+ })
3725
+ next = this.renderJobs.write({ ...next, dshJobId: live.dshJobId }, { previous: next })
3726
+ } catch (cause) {
3727
+ live.settle = null
3728
+ live.done = null
3729
+ this.ctx.logger?.warn(`${LOG_SCOPE}: render job ${jobId} could not be projected into ctx.jobs: ${String(cause)}`)
3730
+ next = this.renderJobs.write({
3731
+ ...next,
3732
+ warnings: [
3733
+ ...(next.warnings ?? []),
3734
+ warning(
3735
+ BlenderWarningCode.JOB_PROJECTION_UNAVAILABLE,
3736
+ `this render could not be registered as a DSH background job (${String(cause)}), so it will not ` +
3737
+ 'appear in the harness job list. The render itself is unaffected and its durable record is ' +
3738
+ 'still authoritative.',
3739
+ { jobId },
3740
+ ),
3741
+ ],
3742
+ }, { previous: next })
3743
+ }
3744
+ } else {
3745
+ // TWO DIFFERENT FACTS, TWO DIFFERENT SENTENCES. "no `jobs` service is composed in this
3746
+ // process" is a true statement about a composition and a false one about a deployment whose
3747
+ // registry threw while attaching its controller. The model reads this warning to decide
3748
+ // whether the missing background job is its own mistake, so the reason is carried from the
3749
+ // place that knows it (`_jobControllerError`) instead of being assumed here.
3750
+ next = this.renderJobs.write({
3751
+ ...next,
3752
+ warnings: [
3753
+ ...(next.warnings ?? []),
3754
+ warning(
3755
+ BlenderWarningCode.JOB_PROJECTION_UNAVAILABLE,
3756
+ this._jobControllerError === null
3757
+ ? 'no `jobs` service is composed in this process, so this render has no DSH background-job ' +
3758
+ 'projection; progress is still recorded durably and readable through blender_job_status.'
3759
+ : `this render could not be registered as a DSH background job (the job controller could not be ` +
3760
+ `attached: ${this._jobControllerError}), so it will not appear in the harness job list. The ` +
3761
+ 'render itself is unaffected and its durable record is still authoritative.',
3762
+ { jobId },
3763
+ ),
3764
+ ],
3765
+ }, { previous: next })
3766
+ }
3767
+
3768
+ // Spawn, then hand the rest to the background. `startFrameSequence` is the
3769
+ // only await here: it resolves once the child exists, which is what makes the
3770
+ // returned job id true rather than optimistic.
3771
+ const attemptToken = randomUUID()
3772
+ live.attemptToken = attemptToken
3773
+ const run = await this.runtime.startFrameSequence({
3774
+ checkpointPath: record.checkpointPath ?? checkpoint.path,
3775
+ frames,
3776
+ jobDirectory: this.renderJobs.jobDirectory(projectId, jobId),
3777
+ cameraId: input.cameraId,
3778
+ profileName,
3779
+ profile,
3780
+ frameRange: record.sceneFrameRange,
3781
+ jobId,
3782
+ attemptToken,
3783
+ })
3784
+ live.run = run
3785
+ live.handle = run.handle
3786
+
3787
+ if (live.cancelled) {
3788
+ // Cancelled between the record write and the spawn: the child exists and
3789
+ // must not be left behind.
3790
+ try {
3791
+ run.handle.terminate()
3792
+ } catch {
3793
+ /* reported by the settle path */
3794
+ }
3795
+ }
3796
+
3797
+ // Background completion. Nothing awaits this but the record and the DSH job — so a
3798
+ // rejection here has no caller, and Node turns an unhandled rejection into process death.
3799
+ // `_driveRender` promises never to throw and now survives a full disk doing it; this catch
3800
+ // is what makes that a property of the CALL rather than a promise in a comment.
3801
+ void this._driveRender({ live, run, record: next, expected, spec: input.spec, profile, profileName }).catch(cause => {
3802
+ this.ctx.logger?.error(`${LOG_SCOPE}: the render driver for ${jobId} threw: ${cause?.stack ?? cause}`)
3803
+ live.settle?.({ status: 'failed', detail: cause instanceof Error ? cause.message : String(cause) })
3804
+ })
3805
+
3806
+ return { jobId, dshJobId: live.dshJobId }
3807
+ }
3808
+
3809
+ /**
3810
+ * The background half: watch the render, keep the record true, then deliver.
3811
+ *
3812
+ * Never throws — it settles the record and the DSH projection instead. A
3813
+ * background task that rejects has no caller to catch it, and an unhandled
3814
+ * rejection would take the Host down in the middle of a delivery.
3815
+ */
3816
+ async _driveRender(input) {
3817
+ const { live, run, expected, spec } = input
3818
+ const projectId = input.record.projectId
3819
+ const jobId = input.record.jobId
3820
+ let record = input.record
3821
+
3822
+ let progressFailures = 0
3823
+ const tick = setInterval(() => {
3824
+ this._absorbProgress(live, run, projectId, jobId).catch((cause) => {
3825
+ progressFailures += 1
3826
+ // Reported ONCE, and the render is not stopped for it: a progress tick that
3827
+ // fails does not make the frames wrong, and killing a three-hour render over
3828
+ // a bookkeeping error would be far worse than a stale percentage. What it
3829
+ // must not do is disappear — a swallowed error is indistinguishable from a
3830
+ // tick that had nothing to do.
3831
+ if (progressFailures === 1) {
3832
+ this.ctx.logger?.warn(`${LOG_SCOPE}: progress reporting for ${jobId} failed: ${String(cause)}`)
3833
+ this._appendOutput(live, `progress reporting failed: ${String(cause)}\n`)
3834
+ }
3835
+ })
3836
+ }, this.config.progressPollMs ?? 1000)
3837
+ // `unref` so a Host that is tearing down is not held open by a poller for a
3838
+ // render that has already been terminated.
3839
+ tick.unref?.()
3840
+
3841
+ try {
3842
+ const outcome = await this.runtime.awaitFrameSequence(run)
3843
+ clearInterval(tick)
3844
+ // The final absorb is also the only one that may call the journal torn: the
3845
+ // writer has stopped, so an incomplete last line is evidence rather than a
3846
+ // line still arriving.
3847
+ await this._absorbProgress(live, run, projectId, jobId, { final: true })
3848
+
3849
+ // The frames are the authority for what happened, whatever the envelope says
3850
+ // — a killed process writes no envelope, and a successful one can still have
3851
+ // been lied to by a truncated frame.
3852
+ const ledger = this._readJobLedger(record, expected)
3853
+
3854
+ if (live.cancelled) {
3855
+ const already = this.renderJobs.read(projectId, jobId)
3856
+ if (RenderJobStore.isTerminal(already)) {
3857
+ // `cancelJob` settled this record and is waiting for this loop to let go.
3858
+ // Writing again would be a second, possibly disagreeing, account of one
3859
+ // cancellation.
3860
+ this._appendOutput(live, `render job ${jobId} cancelled\n`)
3861
+ live.settle?.({ status: 'killed', detail: live.cancelReason ?? 'cancelled' })
3862
+ return
3863
+ }
3864
+ record = this.renderJobs.write({
3865
+ ...already,
3866
+ status: 'cancelled',
3867
+ pid: null,
3868
+ processGroupId: null,
3869
+ completedFrames: ledger.present.map(entry => entry.frame),
3870
+ missingFrames: ledger.toRender,
3871
+ cancelledAt: Date.now(),
3872
+ finishedAt: Date.now(),
3873
+ message: `cancelled: ${live.cancelReason ?? 'cancelled'}`,
3874
+ }, { previous: this.renderJobs.read(projectId, jobId) })
3875
+ this._appendOutput(live, `render job ${jobId} cancelled after ${ledger.presentCount} frame(s)\n`)
3876
+ live.settle?.({ status: 'killed', detail: `cancelled after ${ledger.presentCount} frame(s)` })
3877
+ return
3878
+ }
3879
+
3880
+ const envelopeStatus = outcome.envelope?.status ?? null
3881
+ if (envelopeStatus !== 'success' && ledger.toRenderCount > 0) {
3882
+ const envelopeError = outcome.envelope?.error ?? null
3883
+ const errorCode = envelopeError?.code ?? (outcome.signal !== null
3884
+ ? BlenderErrorCode.ABORTED
3885
+ : BlenderErrorCode.NONZERO_EXIT)
3886
+ const current = this.renderJobs.read(projectId, jobId)
3887
+ record = this.renderJobs.write({
3888
+ ...current,
3889
+ status: 'failed',
3890
+ pid: null,
3891
+ processGroupId: null,
3892
+ completedFrames: ledger.present.map(entry => entry.frame),
3893
+ missingFrames: ledger.toRender,
3894
+ corruptFrames: ledger.corrupt,
3895
+ errorCode,
3896
+ message:
3897
+ `the renderer exited ${outcome.exitCode ?? 'without a code'} before every frame was written: ` +
3898
+ `${envelopeError?.message ?? 'no error document was produced'}; ${ledger.toRenderCount} frame(s) ` +
3899
+ `remain and can be resumed with blender_final_render {resumeJobId: "${jobId}"}`,
3900
+ finishedAt: Date.now(),
3901
+ renderDurationMs: outcome.durationMs,
3902
+ }, { previous: current })
3903
+ this._appendOutput(live, `render job ${jobId} failed: ${record.message}\n`)
3904
+ live.settle?.({ status: 'failed', detail: record.message })
3905
+ return
3906
+ }
3907
+
3908
+ // Every frame is present AND complete. Now the delivery half.
3909
+ record = this.renderJobs.write({
3910
+ ...this.renderJobs.read(projectId, jobId),
3911
+ status: 'running',
3912
+ pid: null,
3913
+ processGroupId: null,
3914
+ completedFrames: ledger.present.map(entry => entry.frame),
3915
+ missingFrames: [],
3916
+ corruptFrames: [],
3917
+ renderDurationMs: outcome.durationMs,
3918
+ meanMsPerFrame: live.journal.frameDurations().length > 0
3919
+ ? Math.round(live.journal.frameDurations().reduce((total, value) => total + value, 0) /
3920
+ live.journal.frameDurations().length)
3921
+ : null,
3922
+ message: `${ledger.presentCount} frame(s) rendered; encoding`,
3923
+ }, { previous: this.renderJobs.read(projectId, jobId) })
3924
+
3925
+ this._appendOutput(live, `all ${ledger.presentCount} frame(s) rendered; encoding the delivery\n`)
3926
+ const delivery = await this._deliverJob({ record, spec, reason: 'render' })
3927
+
3928
+ if (delivery.status === 'completed') {
3929
+ this._appendOutput(
3930
+ live,
3931
+ `delivery published: ${delivery.video?.path} (${delivery.video?.probed?.frameCount} frames, ` +
3932
+ `${delivery.video?.probed?.durationSeconds}s, ${delivery.video?.probed?.width}x` +
3933
+ `${delivery.video?.probed?.height} @ ${delivery.video?.probed?.fps} fps)\n`,
3934
+ )
3935
+ live.settle?.({
3936
+ status: 'completed',
3937
+ detail: `${delivery.video?.probed?.frameCount} frame(s) delivered to ${delivery.video?.path}`,
3938
+ })
3939
+ return
3940
+ }
3941
+ live.settle?.({ status: 'failed', detail: delivery.message ?? 'the delivery did not complete' })
3942
+ } catch (cause) {
3943
+ clearInterval(tick)
3944
+ const message = cause instanceof Error ? cause.message : String(cause)
3945
+ const code = isStorageExhausted(cause)
3946
+ ? BlenderErrorCode.DISK_FULL
3947
+ : (cause instanceof BlenderError ? cause.code : BlenderErrorCode.SCRIPT_ERROR)
3948
+
3949
+ // STOP THE RENDERER FIRST. Whatever went wrong here, this process owns a Blender that is
3950
+ // still writing frames, and a Host that has given up while a renderer has not is exactly
3951
+ // the orphan the M3 acceptance forbids.
3952
+ try {
3953
+ run.handle.terminate()
3954
+ } catch {
3955
+ // Already gone, or a handle that refuses a second terminate. Either way the
3956
+ // process-gone question belongs to the job record, not to this handler.
3957
+ }
3958
+
3959
+ // BEST-EFFORT BOOKKEEPING, AND THIS BLOCK IS THE POINT OF THE WHOLE FIX. MEASURED with a
3960
+ // full volume (`tools/disk-full-probe.mjs`): the write below threw ENOSPC *from inside this
3961
+ // catch*, escaped `_driveRender`, and — because the caller is `void this._driveRender(...)`
3962
+ // — became an unhandled rejection that killed the Host process. A full disk took down the
3963
+ // harness and every session in it, rather than failing one render. The doc comment above
3964
+ // this function already promised it never throws; a full disk is precisely the case where
3965
+ // the record cannot be written, so the record must not be the thing that fails.
3966
+ try {
3967
+ const current = this.renderJobs.readSafe(projectId, jobId)
3968
+ if (current !== null && !RenderJobStore.isTerminal(current)) {
3969
+ this.renderJobs.write({
3970
+ ...current,
3971
+ status: 'failed',
3972
+ pid: null,
3973
+ processGroupId: null,
3974
+ errorCode: code,
3975
+ message,
3976
+ finishedAt: Date.now(),
3977
+ }, { previous: current })
3978
+ }
3979
+ } catch (writeCause) {
3980
+ this.ctx.logger?.warn(
3981
+ `${LOG_SCOPE}: render job ${jobId} failed (${code}) and the failure could not be recorded on disk ` +
3982
+ `(${writeCause?.message ?? writeCause}); the live job is settled below instead`,
3983
+ )
3984
+ }
3985
+
3986
+ try {
3987
+ this._appendOutput(live, `render job ${jobId} failed: ${message}\n`)
3988
+ } catch {
3989
+ // The journal lives on the same volume the frames do.
3990
+ }
3991
+ live.settle?.({ status: 'failed', detail: message })
3992
+ this.ctx.logger?.warn(`${LOG_SCOPE}: render job ${jobId} failed: ${message}`)
3993
+ } finally {
3994
+ clearInterval(tick)
3995
+ this._liveRenders.delete(jobId)
3996
+ // THE COMPILE THIS RENDER PAID FOR IS REMOVED ONLY WHEN THE JOB IS FINISHED, and that word is doing
3997
+ // real work here. The preview and views paths each remove their own compiled scratch in a `finally`;
3998
+ // the DELIVERY path had none, and nothing else owns this directory — MEASURED: a delivery of a revision
3999
+ // with no checkpoint left `tmp/render-r0002-…` behind, holding a compiled `.blend`.
4000
+ //
4001
+ // It cannot simply be removed on every path, because a FAILED or CANCELLED job is RESUMABLE
4002
+ // (`resumeRenderJob` says so, and it reuses `record.checkpointPath`) — deleting the scratch there would
4003
+ // turn "continue this render" into "the renderer cannot open its scene". So: `completed` is the one
4004
+ // status from which nothing resumes, and that is exactly when the bytes stop being live state.
4005
+ if (this.renderJobs.readSafe(projectId, jobId)?.status === 'completed') {
4006
+ this._removeCompiledScratch(record.checkpointPath)
4007
+ }
4008
+ }
4009
+ }
4010
+
4011
+ /**
4012
+ * Remove a COMPILE SCRATCH directory, and never a revision's own checkpoint.
4013
+ *
4014
+ * The two are told apart by where they live: a compile writes into `<workspaceRoot>/tmp/render-<revision>-…`
4015
+ * (`compileRevisionForRender`), while a revision's checkpoint is `<project>/revisions/<id>/scene.blend`.
4016
+ * Anything that is not exactly the former is left alone — the cost of a wrong guess here is somebody's
4017
+ * committed scene.
4018
+ *
4019
+ * @param {string|null|undefined} checkpointPath
4020
+ */
4021
+ _removeCompiledScratch(checkpointPath) {
4022
+ if (typeof checkpointPath !== 'string' || checkpointPath.length === 0) return
4023
+ const directory = dirname(checkpointPath)
4024
+ if (dirname(directory) !== join(this.store.workspaceRoot, 'tmp')) return
4025
+ if (!basename(directory).startsWith('render-')) return
4026
+ removeTree(directory)
4027
+ }
4028
+
4029
+ /**
4030
+ * Fold the child's journal into the record, verifying every frame it claims.
4031
+ *
4032
+ * The claim is checked against the frame's BYTES before it counts: a journal line
4033
+ * says the renderer intended to write a frame, and the ledger is what decides
4034
+ * whether a frame is there. One verification per claimed frame — not a full
4035
+ * directory scan per tick, which on 450 frames would cost more than the render.
4036
+ */
4037
+ async _absorbProgress(live, run, projectId, jobId, options = {}) {
4038
+ const fresh = live.journal.drain()
4039
+ let changed = fresh.some(event => isFrameClaim(event))
4040
+ for (const event of fresh) {
4041
+ if (isFrameClaim(event)) {
4042
+ const sample = sampleFrame(join(this.renderJobs.framesDirectory(projectId, jobId), frameFileName(event.frame)))
4043
+ const verdict = inspectFrameSample(sample, live.expectedSize)
4044
+ if (verdict.ok) live.verified.add(event.frame)
4045
+ }
4046
+ }
4047
+ if (fresh.length > 0) {
4048
+ // The log reports what the writer SAID; only the counting above needs a claim
4049
+ // to be well-formed. So a frame event with no usable number still gets a line —
4050
+ // with a `?` where the number should be, rather than the word "undefined",
4051
+ // which reads like a frame that exists.
4052
+ for (const event of fresh) {
4053
+ if (event?.type === 'frame') {
4054
+ this._appendOutput(live, `frame ${event.frame ?? '?'} rendered (${event.ms ?? '?'} ms)\n`)
4055
+ } else if (event?.type === 'frame_failed') {
4056
+ this._appendOutput(live, `frame ${event.frame} FAILED: ${event.error ?? event.verify?.reason ?? 'unknown'}\n`)
4057
+ } else if (event?.type === 'unparseable-line') {
4058
+ this._appendOutput(live, 'the render journal contained a complete but unparseable line — a defect in the writer, not a torn kill\n')
4059
+ }
4060
+ }
4061
+ }
4062
+ // A torn line is what a kill mid-write looks like, and it must be visible: it means the
4063
+ // journal is not a complete account of what the renderer did, which is exactly why the ledger
4064
+ // is built from the frames instead. The four conditions that decide it — the file ends
4065
+ // mid-line, the writer has stopped, the kill was not one the caller asked for, and it has not
4066
+ // been said before — live in `incompleteJournalWarning`, because the contract suite can drive
4067
+ // them by hand there and a real `SIGKILL` almost always lands on a line boundary.
4068
+ //
4069
+ // Said twice, in the two places a reader actually looks, and the second one is DURABLE: the
4070
+ // output line goes to the harness job, which is drained by whoever reads it first and is gone
4071
+ // after a restart, while the warning lands on the job record and is what `blender_job_status`
4072
+ // shows after the process that watched the render is dead.
4073
+ const tornWarning = incompleteJournalWarning(live.journal, {
4074
+ stopped: options.final === true,
4075
+ cancelled: live.cancelled === true,
4076
+ jobId,
4077
+ attemptToken: live.attemptToken,
4078
+ })
4079
+ if (tornWarning !== null) {
4080
+ this._appendOutput(
4081
+ live,
4082
+ 'the render journal ends mid-line, so its last event never completed — what a kill between the write and ' +
4083
+ 'the flush leaves behind; progress is counted from the frame files themselves, so the count is unaffected\n',
4084
+ )
4085
+ }
4086
+
4087
+ // The pid arrives from the child, and only the child can supply it (measured:
4088
+ // `ctx.subprocess.spawn` exposes no pid). Recording it is what makes an orphan
4089
+ // findable after this Host dies.
4090
+ const current = this.renderJobs.readSafe(projectId, jobId)
4091
+ if (current === null) return
4092
+ if (current.pid === null && existsSync(run.processPath)) {
4093
+ try {
4094
+ const identity = readJson(run.processPath)
4095
+ // The token is required, not merely preferred. A resumed attempt runs in the
4096
+ // SAME job directory as the attempt it continues, so a leftover identity
4097
+ // document is the normal case rather than a rare one — and reading it would
4098
+ // record a dead process as the live one. Deleting the stale file is the
4099
+ // provider's job; refusing an identity that is not this attempt's is the
4100
+ // host's, so the two together make "the pid is current" a checked fact.
4101
+ const identityIsCurrent = identity?.attemptToken !== undefined &&
4102
+ identity.attemptToken !== null &&
4103
+ identity.attemptToken === live.attemptToken
4104
+ if (identityIsCurrent && Number.isSafeInteger(identity.pid)) {
4105
+ this.renderJobs.write({
4106
+ ...current,
4107
+ pid: identity.pid,
4108
+ processGroupId: identity.processGroupId ?? null,
4109
+ attemptToken: identity.attemptToken,
4110
+ }, { previous: current })
4111
+ changed = true
4112
+ }
4113
+ } catch {
4114
+ // Mid-write; the next tick re-reads it.
4115
+ }
4116
+ }
4117
+
4118
+ if (changed || tornWarning !== null) {
4119
+ const latest = this.renderJobs.readSafe(projectId, jobId)
4120
+ if (latest === null || RenderJobStore.isTerminal(latest)) return
4121
+ const completed = [...live.verified].sort((left, right) => left - right)
4122
+ const expectedCount = latest.expectedFrames ?? (latest.frameEnd - latest.frameStart + 1)
4123
+ const estimate = estimateRemaining({
4124
+ perFrameMs: live.journal.frameDurations(),
4125
+ remainingFrames: Math.max(0, expectedCount - completed.length),
4126
+ })
4127
+ // A torn journal alone changes no number, so it is `changed` for the record's sake: the write
4128
+ // below is the only way this fact survives the process that watched the render. The wording is
4129
+ // attempt-scoped on purpose — a resumed attempt appends to the SAME journal, so by the time the
4130
+ // job is complete the file may no longer end mid-line while an earlier attempt still did.
4131
+ const next = {
4132
+ ...latest,
4133
+ ...(tornWarning === null ? {} : { warnings: [...(latest.warnings ?? []), tornWarning] }),
4134
+ completedFrames: completed,
4135
+ missingFrames: this.renderJobs.expectedFrames(latest).filter(frame => !live.verified.has(frame)),
4136
+ percent: renderProgressPercent({ expected: expectedCount, done: completed.length }),
4137
+ meanMsPerFrame: estimate.meanMsPerFrame === null ? null : Math.round(estimate.meanMsPerFrame),
4138
+ estimatedRemainingMs: estimate.estimatedRemainingMs,
4139
+ }
4140
+ this.renderJobs.write(next, { previous: latest })
4141
+ }
4142
+ }
4143
+
4144
+ _appendOutput(live, text) {
4145
+ live.output += text
4146
+ // Bounded: a 450-frame render produces one line per frame, and an unbounded
4147
+ // buffer in a process that lives for hours is a leak with a friendly name.
4148
+ if (live.output.length > 256 * 1024) live.output = live.output.slice(-128 * 1024)
4149
+ }
4150
+
4151
+ /** The ledger for a record's own frames, with its own expected size. */
4152
+ _readJobLedger(record, expected) {
4153
+ return readFrameLedger({
4154
+ framesDirectory: record.framesDirectory ?? this.renderJobs.framesDirectory(record.projectId, record.jobId),
4155
+ expected,
4156
+ expectedSize: {
4157
+ width: record.renderConfig?.resolution?.[0],
4158
+ height: record.renderConfig?.resolution?.[1],
4159
+ prefix: record.filePrefix,
4160
+ padding: record.filePadding,
4161
+ },
4162
+ })
4163
+ }
4164
+
4165
+ /**
4166
+ * Encode, verify, publish, and write the delivery manifest.
4167
+ *
4168
+ * Refuses to encode an incomplete frame set. Encoding "whatever is there" is how
4169
+ * a delivery silently ships 447 of 450 frames: ffmpeg would happily produce a
4170
+ * shorter video and every property check downstream would agree with the wrong
4171
+ * number unless the expected count came from the job.
4172
+ */
4173
+ async _deliverJob(input) {
4174
+ const record = input.record
4175
+ const projectId = record.projectId
4176
+ const jobId = record.jobId
4177
+
4178
+ // ONE ENCODER PER JOB. `encodedPath` is `<job>/encoded/<jobId>.mp4` — one path however many deliveries run —
4179
+ // and an encode is not instantaneous, so a second call while the first is encoding writes the same file.
4180
+ // MEASURED before this guard: two concurrent exports of one job BOTH failed with `ENCODE_VERIFY_FAILED`,
4181
+ // because each verified a file the other was still writing; the message blamed the video's properties rather
4182
+ // than the collision. A refusal that names the other call is the honest answer — and it costs the caller
4183
+ // nothing, because the encode it was about to duplicate is already happening.
4184
+ const deliveryKey = `${projectId}/${jobId}`
4185
+ const inFlight = this._deliveriesInFlight.get(deliveryKey)
4186
+ if (inFlight !== undefined) {
4187
+ throw new BlenderError(
4188
+ BlenderErrorCode.EXPORT_IN_PROGRESS,
4189
+ `${inFlight} is already encoding ${projectId}/${jobId} into ` +
4190
+ `${encodedPath(this.renderJobs.jobDirectory(projectId, jobId), jobId)}. Two encoders on one ` +
4191
+ 'file produce a video that verifies as neither, so this one was refused rather than started. Wait for ' +
4192
+ 'the first to finish — blender_job_status reports it — or cancel the job and start again.',
4193
+ { detail: { projectId, jobId, inFlight, reason: input.reason ?? null } },
4194
+ )
4195
+ }
4196
+ this._deliveriesInFlight.set(deliveryKey, input.reason === 'export' ? 'a delivery export' : 'the render\u2019s own delivery')
4197
+ try {
4198
+ return await this._deliverJobBody(input)
4199
+ } finally {
4200
+ // Cleared on EVERY path, including a throw: a flag that leaks would refuse every later delivery of this job
4201
+ // forever, which is a worse failure than the collision it prevents.
4202
+ this._deliveriesInFlight.delete(deliveryKey)
4203
+ }
4204
+ }
4205
+
4206
+ /** The body of {@link _deliverJob}, under the one-encoder-per-job guard. */
4207
+ async _deliverJobBody(input) {
4208
+ const record = input.record
4209
+ const projectId = record.projectId
4210
+ const jobId = record.jobId
4211
+ const expected = this.renderJobs.expectedFrames(record)
4212
+ const ledger = this._readJobLedger(record, expected)
4213
+ const total = expected.length
4214
+
4215
+ if (ledger.presentCount !== total) {
4216
+ const current = this.renderJobs.read(projectId, jobId)
4217
+ const message =
4218
+ `${total - ledger.presentCount} of ${total} frame(s) are not complete ` +
4219
+ `(${ledger.missingCount} absent, ${ledger.corruptCount} incomplete), so there is nothing to encode yet. ` +
4220
+ `Continue with blender_final_render {resumeJobId: "${jobId}"}.`
4221
+ // An EXPORT of an already-terminal job changes nothing. Writing `failed` over
4222
+ // a cancelled or failed record would be an illegal transition (and the store
4223
+ // is right to refuse it) — and it would also destroy the record of what the
4224
+ // job actually is, in order to report a fact the caller can already see.
4225
+ if (input.reason === 'export' || RenderJobStore.isTerminal(current.status)) {
4226
+ if (current.status !== 'completed') {
4227
+ const next = {
4228
+ ...current,
4229
+ completedFrames: ledger.present.map(entry => entry.frame),
4230
+ missingFrames: ledger.toRender,
4231
+ corruptFrames: ledger.corrupt,
4232
+ }
4233
+ this.renderJobs.write(next, { previous: current })
4234
+ }
4235
+ throw new BlenderError(BlenderErrorCode.RENDER_FRAMES_INCOMPLETE, message, {
4236
+ detail: { jobId, expected: total, present: ledger.presentCount, toRender: ledger.toRender },
4237
+ })
4238
+ }
4239
+ this.renderJobs.write({
4240
+ ...current,
4241
+ status: 'failed',
4242
+ completedFrames: ledger.present.map(entry => entry.frame),
4243
+ missingFrames: ledger.toRender,
4244
+ corruptFrames: ledger.corrupt,
4245
+ errorCode: BlenderErrorCode.RENDER_FRAMES_INCOMPLETE,
4246
+ message,
4247
+ finishedAt: Date.now(),
4248
+ }, { previous: current })
4249
+ return { status: 'failed', message, problems: [], verified: false }
4250
+ }
4251
+
4252
+ // Re-delivering a COMPLETED job must not re-open it: `completed` has no
4253
+ // outgoing transition on purpose, and an export that briefly called a finished
4254
+ // delivery "running" would make every reader of the record see a regression.
4255
+ const current = this.renderJobs.read(projectId, jobId)
4256
+ const liveStatus = current.status === 'completed' ? 'completed' : 'running'
4257
+ const deliveryAttempt = (current.delivery?.attempt ?? 0) + 1
4258
+ this.renderJobs.write({
4259
+ ...current,
4260
+ status: liveStatus,
4261
+ delivery: { status: 'encoding', startedAt: Date.now(), attempt: deliveryAttempt },
4262
+ message: `encoding ${total} frame(s) into MP4`,
4263
+ }, { previous: current })
4264
+
4265
+ const jobDirectory = this.renderJobs.jobDirectory(projectId, jobId)
4266
+ const output = encodedPath(jobDirectory, jobId)
4267
+ mkdirSync(join(jobDirectory, 'encoded'), { recursive: true })
4268
+
4269
+ // RECORDING THE ATTEMPT IS WHAT MAKES RECORDING ITS OUTCOME THIS FUNCTION'S JOB — including when
4270
+ // the encode THROWS, which is the state a machine without ffmpeg is in. MEASURED (round 30, the
4271
+ // M3 tool plane run with a bogus `ffmpegPath`): the job was marked `failed` by the caller's catch
4272
+ // while its `delivery` stayed at `encoding`, so `blender_job_status` printed a job that had
4273
+ // stopped and an encode that was still running in the same block. A reader who believes the
4274
+ // second line waits for something that will never finish.
4275
+ //
4276
+ // The job's own STATUS is left alone here: the caller decides it (a failed RE-export of a
4277
+ // completed job must not reopen it). What this records is the delivery attempt, which is exactly
4278
+ // the thing that failed.
4279
+ let encode
4280
+ let probed
4281
+ try {
4282
+ encode = await encodeFrameSequence({
4283
+ ctx: this.ctx,
4284
+ ffmpegPath: this.config.ffmpegPath,
4285
+ framesDirectory: record.framesDirectory ?? this.renderJobs.framesDirectory(projectId, jobId),
4286
+ firstFrame: record.frameStart,
4287
+ frameCount: total,
4288
+ fps: record.fps,
4289
+ outputPath: output,
4290
+ filePrefix: record.filePrefix,
4291
+ filePadding: record.filePadding,
4292
+ crf: this.config.encodeCrf,
4293
+ preset: this.config.encodePreset,
4294
+ })
4295
+
4296
+ probed = await probeVideo({ ctx: this.ctx, ffprobePath: this.config.ffprobePath, path: output })
4297
+ } catch (cause) {
4298
+ const failedFrom = this.renderJobs.read(projectId, jobId)
4299
+ this.renderJobs.write({
4300
+ ...failedFrom,
4301
+ delivery: {
4302
+ status: 'failed',
4303
+ attempt: deliveryAttempt,
4304
+ errorCode: cause instanceof BlenderError ? cause.code : BlenderErrorCode.ENCODE_FAILED,
4305
+ message: cause instanceof Error ? cause.message : String(cause),
4306
+ videoPath: existsSync(output) ? output : null,
4307
+ completedAt: Date.now(),
4308
+ },
4309
+ }, { previous: failedFrom })
4310
+ throw cause
4311
+ }
4312
+ const sources = this._deliverySources(projectId, record.revisionId)
4313
+ const publishRoot = join(this.store.projectDirectory(projectId), 'output')
4314
+ mkdirSync(publishRoot, { recursive: true })
4315
+ const videoPath = join(publishRoot, 'final.mp4')
4316
+ const manifestPath = join(publishRoot, 'delivery-manifest.json')
4317
+
4318
+ const verdict = verifyVideoProperties({
4319
+ claimed: {
4320
+ frameStart: record.frameStart,
4321
+ frameEnd: record.frameEnd,
4322
+ frameCount: total,
4323
+ fps: record.fps,
4324
+ width: record.renderConfig?.resolution?.[0],
4325
+ height: record.renderConfig?.resolution?.[1],
4326
+ },
4327
+ probed: {
4328
+ durationSeconds: probed.durationSeconds,
4329
+ fps: probed.fps,
4330
+ width: probed.width,
4331
+ height: probed.height,
4332
+ nbFrames: probed.nbFrames,
4333
+ codec: probed.codec,
4334
+ },
4335
+ })
4336
+
4337
+ if (!verdict.ok) {
4338
+ const message =
4339
+ `the encoded video does not match the job's own claims: ${verdict.problems
4340
+ .map(problem => `${problem.field} claimed ${JSON.stringify(problem.claimed)} but probed ${JSON.stringify(problem.probed)}`)
4341
+ .join('; ')}`
4342
+ const before = this.renderJobs.read(projectId, jobId)
4343
+ this.renderJobs.write({
4344
+ ...before,
4345
+ // A re-export that fails leaves a completed delivery completed; the failure
4346
+ // belongs to the delivery attempt, which is where it is recorded.
4347
+ status: before.status === 'completed' ? 'completed' : 'failed',
4348
+ delivery: {
4349
+ status: 'failed',
4350
+ videoPath: output,
4351
+ problems: verdict.problems,
4352
+ attempt: before.delivery?.attempt ?? 1,
4353
+ completedAt: Date.now(),
4354
+ },
4355
+ errorCode: BlenderErrorCode.ENCODE_VERIFY_FAILED,
4356
+ message,
4357
+ finishedAt: Date.now(),
4358
+ }, { previous: before })
4359
+ if (input.reason === 'export') {
4360
+ throw new BlenderError(BlenderErrorCode.ENCODE_VERIFY_FAILED, message, { detail: { problems: verdict.problems } })
4361
+ }
4362
+ return { status: 'failed', message, problems: verdict.problems, verified: false, video: null }
4363
+ }
4364
+
4365
+ // Publish FIRST, then describe what was published. The manifest carries a
4366
+ // sha256 of the video, and an earlier version hashed `output/final.mp4` before
4367
+ // copying it there — so every manifest recorded `video.sha256: null`, which is
4368
+ // a delivery that cannot be checked against its own bytes. Copying to a
4369
+ // temporary name and renaming keeps the publication atomic, so a reader never
4370
+ // sees a half-written `final.mp4`.
4371
+ copyFileSync(encode.outputPath, `${videoPath}.tmp`)
4372
+ renameSync(`${videoPath}.tmp`, videoPath)
4373
+
4374
+ const manifest = buildDeliveryManifest({
4375
+ record: { ...record, framesDirectory: record.framesDirectory ?? this.renderJobs.framesDirectory(projectId, jobId) },
4376
+ ledger,
4377
+ probed,
4378
+ encode,
4379
+ publish: { videoPath, relativeTo: this.store.projectDirectory(projectId) },
4380
+ sources,
4381
+ qa: sources.qaSummary,
4382
+ runtimeIdentity: this._runtimeIdentity(projectId, record.revisionId),
4383
+ })
4384
+
4385
+ writeJsonAtomic(join(jobDirectory, 'manifest.json'), manifest)
4386
+ this.renderJobs.write({
4387
+ ...this.renderJobs.read(projectId, jobId),
4388
+ durationSeconds: probed.durationSeconds,
4389
+ videoFrameCount: probed.nbFrames,
4390
+ fpsProbed: probed.fps,
4391
+ resolution: [probed.width, probed.height],
4392
+ }, { previous: this.renderJobs.read(projectId, jobId) })
4393
+
4394
+ writeJsonAtomic(manifestPath, manifest)
4395
+ const renderManifestPath = join(jobDirectory, 'manifest.json')
4396
+ const recordManifestPath = join(this.store.projectDirectory(projectId), 'renders', 'manifest.json')
4397
+ writeJsonAtomic(recordManifestPath, manifest)
4398
+
4399
+ const before = this.renderJobs.read(projectId, jobId)
4400
+ const delivered = this.renderJobs.write({
4401
+ ...before,
4402
+ status: 'completed',
4403
+ errorCode: null,
4404
+ outputManifest: recordManifestPath,
4405
+ delivery: {
4406
+ status: 'published',
4407
+ videoPath,
4408
+ manifestPath,
4409
+ renderManifestPath,
4410
+ bytes: encode.bytes,
4411
+ sha256: manifest.video.sha256,
4412
+ verified: true,
4413
+ problems: [],
4414
+ encodeDurationMs: encode.durationMs,
4415
+ completedAt: Date.now(),
4416
+ },
4417
+ percent: 100,
4418
+ finishedAt: Date.now(),
4419
+ errorCode: null,
4420
+ message: `delivered ${probed.nbFrames} frame(s) as ${probed.width}x${probed.height} ${probed.codec} @ ${probed.fps} fps`,
4421
+ }, { previous: before })
4422
+
4423
+ return {
4424
+ status: 'completed',
4425
+ verified: true,
4426
+ problems: [],
4427
+ completeness: manifest.completeness,
4428
+ video: {
4429
+ path: videoPath,
4430
+ bytes: encode.bytes,
4431
+ sha256: manifest.video.sha256,
4432
+ probed: manifest.video.probed,
4433
+ expected: manifest.video.expected,
4434
+ },
4435
+ manifest: { path: manifestPath, renderManifestPath, recordManifest: delivered.outputManifest },
4436
+ message: delivered.message,
4437
+ }
4438
+ }
4439
+
4440
+ /**
4441
+ * The SceneSpec, checkpoint and QA artifacts a delivery manifest references.
4442
+ *
4443
+ * The QA report is READ, not merely pointed at: SPEC §19.8 requires the final
4444
+ * package to contain QA, and the M3 brief's test for the manifest is that a reader
4445
+ * can judge completeness WITHOUT opening the package. A path alone fails that test
4446
+ * — the reader cannot tell a passing QA report from a missing one. The full
4447
+ * document is not embedded either: `cameraParameters` alone is thousands of bytes
4448
+ * describing geometry the manifest already digests, so what travels is the
4449
+ * verdict plus the counts that make it checkable.
4450
+ */
4451
+ _deliverySources(projectId, revision) {
4452
+ const revisionDirectory = this.store.revisionDirectory(projectId, revision)
4453
+ const qaPath = join(revisionDirectory, 'validation.json')
4454
+ const qaReport = readJson(qaPath)
4455
+ return {
4456
+ sceneSpec: join(revisionDirectory, 'scene-spec.json'),
4457
+ checkpoint: this.store.checkpointPath(projectId, revision),
4458
+ qa: qaPath,
4459
+ qaSummary: qaReport === null ? null : this._qaSummary(qaReport),
4460
+ }
4461
+ }
4462
+
4463
+ /** The checkable part of a revision's QA report. */
4464
+ _qaSummary(report) {
4465
+ const technical = report?.technical ?? {}
4466
+ const semantic = report?.semantic ?? {}
4467
+ return {
4468
+ schemaVersion: report?.schemaVersion ?? null,
4469
+ revision: report?.revision ?? null,
4470
+ ok: technical.ok === true && semantic.ok !== false,
4471
+ technicalOk: technical.ok === true,
4472
+ semanticOk: semantic.ok !== false,
4473
+ errorCount: Array.isArray(technical.errors) ? technical.errors.length : null,
4474
+ counts: technical.counts ?? null,
4475
+ frameRange: technical.frameRange ?? null,
4476
+ fps: technical.fps ?? null,
4477
+ engine: technical.engine ?? null,
4478
+ activeCamera: technical.activeCamera ?? null,
4479
+ notices: Array.isArray(semantic.notices) ? semantic.notices.length : null,
4480
+ }
4481
+ }
4482
+
4483
+ /**
4484
+ * Runtime identity for the manifest (SPEC §9.5): what rendered this, so the
4485
+ * delivery can be reproduced or explained without guessing.
4486
+ */
4487
+ _runtimeIdentity(projectId, revision) {
4488
+ // Read from the revision's own manifest rather than from a fresh probe: the
4489
+ // identity that matters for reproducing a delivery is the one recorded when
4490
+ // the checkpoint was compiled (SPEC §9.5), and re-probing would report
4491
+ // whatever Blender is installed NOW, which may be a different build.
4492
+ const manifest = this.store.readRevisionManifest(projectId, revision)
4493
+ const runtime = manifest?.runtime ?? null
4494
+ return {
4495
+ source: 'revision-manifest',
4496
+ blenderVersion: runtime?.blenderVersion ?? null,
4497
+ engine: runtime?.engine ?? manifest?.validation?.engine ?? null,
4498
+ requestedEngine: runtime?.requestedEngine ?? null,
4499
+ protocolVersion: runtime?.protocolVersion ?? null,
4500
+ }
4501
+ }
4502
+
4503
+ /** The canonical render-job projection shared by `getJob` and `listJobs`. */
4504
+ _canonicalRenderJob(record) {
4505
+ const completed = Array.isArray(record.completedFrames) ? record.completedFrames : []
4506
+ const expected = record.expectedFrames ?? (record.frameEnd - record.frameStart + 1)
4507
+ return {
4508
+ jobId: record.jobId,
4509
+ projectId: record.projectId,
4510
+ revisionId: record.revisionId,
4511
+ type: record.type,
4512
+ status: record.status,
4513
+ attempt: record.attempt,
4514
+ dshJobId: record.dshJobId ?? null,
4515
+ pid: record.pid ?? null,
4516
+ frameStart: record.frameStart,
4517
+ frameEnd: record.frameEnd,
4518
+ expectedFrames: expected,
4519
+ completedFrames: completed.length,
4520
+ completedFrameList: completed,
4521
+ missingFrames: record.missingFrames ?? [],
4522
+ corruptFrames: record.corruptFrames ?? [],
4523
+ percent: record.percent ?? renderProgressPercent({ expected, done: completed.length }),
4524
+ meanMsPerFrame: record.meanMsPerFrame ?? null,
4525
+ estimatedRemainingMs: record.estimatedRemainingMs ?? null,
4526
+ fps: record.fps,
4527
+ profileName: record.profileName ?? null,
4528
+ renderConfig: record.renderConfig ?? null,
4529
+ framesDirectory: record.framesDirectory ?? null,
4530
+ delivery: record.delivery ?? null,
4531
+ outputManifest: record.outputManifest ?? null,
4532
+ errorCode: record.errorCode ?? null,
4533
+ message: record.message ?? null,
4534
+ // The warnings were already stored with the record; M4 surfaces them
4535
+ // because one of them IS the approval requirement (SPEC §15.1 "高成本最终
4536
+ // 渲染达阈值审批"). Recomputing that judgement in the UI from a threshold
4537
+ // would be a second opinion about a fact the job already recorded.
4538
+ warnings: record.warnings ?? [],
4539
+ recovery: this._recoveryFindings.find(finding => finding.jobId === record.jobId) ?? null,
4540
+ description: describeRenderJob({ ...record, completedFrames: completed }),
4541
+ createdAt: record.createdAt,
4542
+ updatedAt: record.updatedAt,
4543
+ startedAt: record.startedAt ?? null,
4544
+ finishedAt: record.finishedAt ?? null,
4545
+ }
4546
+ }
4547
+ }
4548
+
4549
+ /**
4550
+ * The exact text the vision reviewer is asked to answer.
4551
+ *
4552
+ * Structure, and why each part is there:
4553
+ *
4554
+ * 1. What it is looking at, view by view, with the CELL each view occupies on the
4555
+ * sheet. Without the layout, a model asked about "the top view" has to infer
4556
+ * which tile that is, and a mislabelled finding produces a confident, wrong fix.
4557
+ * 2. The measurements, as prose. They are the score's own basis, so the model and
4558
+ * the scorer argue about the same facts.
4559
+ * 3. A response contract: strict JSON, findings grounded in a named view, and
4560
+ * operations limited to the vocabulary that actually exists. A free-form answer
4561
+ * is unparseable; an unconstrained operation list would be a hole in the
4562
+ * "no free-form edit" rule (SPEC §11.1).
4563
+ *
4564
+ * @param {object} review
4565
+ * @param {object[]} views
4566
+ * @returns {string}
4567
+ */
4568
+ export function buildReviewerPrompt(review, views) {
4569
+ const lines = []
4570
+ lines.push('You are reviewing a rendered 3D scene for an animation pipeline. One contact sheet is attached:')
4571
+ lines.push('each cell is one view, in reading order left to right then top to bottom.')
4572
+ lines.push('')
4573
+ lines.push('Views on the sheet:')
4574
+ const placements = Array.isArray(review.sheet?.placements) ? review.sheet.placements : []
4575
+ for (const placement of placements) {
4576
+ const view = (views ?? []).find(entry => entry.viewId === placement.viewId)
4577
+ const purpose = view?.purpose ? ` — ${view.purpose}` : ''
4578
+ lines.push(
4579
+ ` cell (row ${placement.row + 1}, column ${placement.column + 1}) = view "${placement.viewId}"` +
4580
+ `, camera ${view?.cameraId ?? '?'}, frame ${view?.frame ?? '?'}${purpose}`,
4581
+ )
4582
+ }
4583
+ lines.push('')
4584
+ lines.push('Measurements taken from the rendered pixels (these are facts, not estimates):')
4585
+ for (const view of views ?? []) {
4586
+ const luminance = view.luminance
4587
+ if (luminance !== null && luminance !== undefined) {
4588
+ lines.push(
4589
+ ` view "${view.viewId}": mean luminance ${luminance.mean}, p05 ${luminance.p05}, p95 ${luminance.p95}, ` +
4590
+ `clipped dark ${luminance.clippedDarkFraction}, clipped bright ${luminance.clippedBrightFraction}`,
4591
+ )
4592
+ }
4593
+ for (const object of view.objects ?? []) {
4594
+ lines.push(
4595
+ ` object "${object.id}": ${object.visiblePixels} visible px of ${object.silhouettePixels} silhouette px` +
4596
+ `${Array.isArray(object.bbox) ? `, bbox ${JSON.stringify(object.bbox)}` : ''}` +
4597
+ `${Array.isArray(object.centroid) ? `, centroid ${JSON.stringify(object.centroid)}` : ''}` +
4598
+ `, fully inside frame: ${object.inFrame === true}`,
4599
+ )
4600
+ }
4601
+ }
4602
+ lines.push('')
4603
+ lines.push(`An automated scorer measured ${review.issues.length} problem(s) and scored this ${review.score}/100:`)
4604
+ if (review.issues.length === 0) {
4605
+ lines.push(' (none)')
4606
+ } else {
4607
+ for (const issue of review.issues) {
4608
+ lines.push(
4609
+ ` [${issue.severity}] ${issue.code} in view "${issue.viewId}"` +
4610
+ `${issue.objectId ? ` on "${issue.objectId}"` : ''}: ${issue.evidence}`,
4611
+ )
4612
+ }
4613
+ }
4614
+ lines.push('')
4615
+ lines.push('Answer with STRICT JSON only, no prose around it, in exactly this shape:')
4616
+ lines.push('{')
4617
+ lines.push(' "findings": [')
4618
+ lines.push(' { "category": "composition" | "exposure" | "occlusion",')
4619
+ lines.push(' "viewId": "<one of the view ids above>",')
4620
+ lines.push(' "objectId": "<object id or null>",')
4621
+ lines.push(' "severity": "minor" | "major" | "critical",')
4622
+ lines.push(' "confidence": 0.0-1.0,')
4623
+ lines.push(' "evidence": "<what you can see on the sheet that shows this>" }')
4624
+ lines.push(' ],')
4625
+ lines.push(' "operations": [ { "op": "<operation>", ... } ],')
4626
+ lines.push(' "note": "<one sentence on what you changed and why>"')
4627
+ lines.push('}')
4628
+ lines.push('')
4629
+ lines.push('Rules:')
4630
+ lines.push(' - Report only what the IMAGE shows. Do not restate a measurement; add what a number cannot say.')
4631
+ lines.push(' - A finding whose viewId is not one of the views above is DISCARDED, so name a real view.')
4632
+ lines.push(' - "operations" must use only these ScenePatch operations, and they are applied all-or-nothing:')
4633
+ lines.push(' entity.transform.update {entityId, location?, rotationEuler?, scale?}')
4634
+ lines.push(' entity.visibility.set {entityId, visible}')
4635
+ lines.push(' entity.material.set {entityId, materialId}')
4636
+ lines.push(' material.parameter.update {materialId, parameter, value}')
4637
+ lines.push(' light.update {lightId, energy?, color?, size?, transform?}')
4638
+ lines.push(' camera.update {cameraId, lens?, transform?, targetEntityId?, targetPoint?}')
4639
+ lines.push(' render.profile.set {profileName, profile}')
4640
+ lines.push(' - Prefer the SMALLEST change that fixes the largest problem. An empty list is a valid answer.')
4641
+ return lines.join('\n')
4642
+ }
4643
+
4644
+ /**
4645
+ * Parse the reviewer's answer out of whatever the model actually produced.
4646
+ *
4647
+ * Models wrap JSON in prose or fences even when told not to, so the first `{` to the
4648
+ * last `}` is extracted rather than trusting the whole reply. A reply that genuinely
4649
+ * contains no JSON yields empty findings and no operations — a review with no
4650
+ * proposal, which the loop treats as "nothing to do" rather than as a crash.
4651
+ *
4652
+ * @param {string} raw
4653
+ * @returns {{ findings: object[], operations: object[], note: string|null }}
4654
+ */
4655
+ export function parseReviewerAnswer(raw) {
4656
+ const text = typeof raw === 'string' ? raw : ''
4657
+ const start = text.indexOf('{')
4658
+ const end = text.lastIndexOf('}')
4659
+ if (start === -1 || end <= start) return { findings: [], operations: [], note: null }
4660
+ let parsed
4661
+ try {
4662
+ parsed = JSON.parse(text.slice(start, end + 1))
4663
+ } catch {
4664
+ return { findings: [], operations: [], note: null }
4665
+ }
4666
+ if (parsed === null || typeof parsed !== 'object') return { findings: [], operations: [], note: null }
4667
+ return {
4668
+ findings: Array.isArray(parsed.findings) ? parsed.findings : [],
4669
+ operations: Array.isArray(parsed.operations) ? parsed.operations : [],
4670
+ note: typeof parsed.note === 'string' ? parsed.note : null,
4671
+ }
4672
+ }
4673
+
4674
+ /**
4675
+ * A file-name-safe form of a view id.
4676
+ *
4677
+ * View ids come from a view plan, which a caller may supply, and they become file
4678
+ * names inside a published revision. Anything that could traverse or separate is
4679
+ * replaced rather than rejected: a view id is a label, and refusing a whole review
4680
+ * over a stray slash would be a worse trade than a normalised file name — but the
4681
+ * normalisation must happen, because the alternative is writing outside the
4682
+ * revision directory (SPEC §15.2).
4683
+ *
4684
+ * @param {string} viewId
4685
+ * @returns {string}
4686
+ */
4687
+ function safeFileName(viewId) {
4688
+ const cleaned = String(viewId).replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^[.-]+/, '')
4689
+ return cleaned.length > 0 ? cleaned.slice(0, 96) : 'view'
4690
+ }
4691
+
4692
+ /**
4693
+ * Settings namespace name re-exported for the UI half, which must not import the
4694
+ * host implementation merely to learn a string.
4695
+ */
4696
+ export { BLENDER_SETTINGS_NAMESPACE }
4697
+
4698
+ /** Re-exported so the UI host half can render a project id without the store. */
4699
+ export { GENESIS_REVISION }
4700
+
4701
+ /**
4702
+ * Store and transaction surface, re-exported for tests and for a future
4703
+ * migration/repair tool. Kept on the main entry rather than requiring callers to
4704
+ * reach into a subpath: these ARE the M1 host API, and a test that had to import
4705
+ * an internal module to exercise conflict handling would be testing something
4706
+ * other than what ships.
4707
+ */
4708
+ export { ProjectStore, formatRevisionId, parseRevisionId } from './project-store.js'
4709
+ export {
4710
+ RevisionTransaction,
4711
+ deriveIdempotencyKey,
4712
+ resolveIdempotencyKey,
4713
+ defaultSceneSpec,
4714
+ } from './revision-transaction.js'
4715
+
4716
+ /**
4717
+ * M3 surface, re-exported on the same terms as the M1 store above.
4718
+ *
4719
+ * The frame ledger is deliberately reachable from outside the package: it is the
4720
+ * rule that decides whether a delivery ships short or a three-hour render is
4721
+ * repeated, and a rule that can only be exercised through a running Host is a rule
4722
+ * whose failure modes are only discovered in production. The contract suite drives
4723
+ * it directly against frames whose bytes it controls.
4724
+ */
4725
+ export { readFrameLedger, framesOnDisk, sampleFrame } from './frame-ledger.js'
4726
+ export { RenderJobStore, formatRenderJobId, UNFINISHED_STATUSES } from './render-job-store.js'
4727
+ export {
4728
+ JournalTail,
4729
+ incompleteJournalWarning,
4730
+ isFrameClaim,
4731
+ } from './render-journal.js'
4732
+ export {
4733
+ checkProcessAlive,
4734
+ identifyProcess,
4735
+ reconcileRenderJob,
4736
+ stopProcessGroup,
4737
+ } from './render-reconciler.js'
4738
+ export { encodeFrameSequence, probeVideo } from './video-encoder.js'
4739
+ export { buildDeliveryManifest, relativeTo } from './delivery-manifest.js'
4740
+
4741
+ /**
4742
+ * Is this failure the filesystem being full?
4743
+ *
4744
+ * A volume with no room left is an operational event rather than a bug, and it deserves its own
4745
+ * code: `DISK_FULL` says "free space and resume", where the fallback `SCRIPT_ERROR` says "this is
4746
+ * a DeepBlend bug". The errno is read where there is one, with the text as a fallback because
4747
+ * Blender's own stderr arrives as a string.
4748
+ *
4749
+ * @param {unknown} cause
4750
+ * @returns {boolean}
4751
+ */
4752
+ function isStorageExhausted(cause) {
4753
+ const code = cause?.code ?? cause?.errno
4754
+ if (code === 'ENOSPC' || code === -28) return true
4755
+ const text = cause instanceof Error ? cause.message : String(cause ?? '')
4756
+ return /ENOSPC|no space left on device|not enough space/i.test(text)
4757
+ }
4758
+
4759
+ /**
4760
+ * The first `length` bytes of a file, without reading the rest of it.
4761
+ *
4762
+ * `readFileSync` would be the obvious call and the wrong one: an ingested asset is allowed to
4763
+ * be a gigabyte (SPEC §15's example ceiling), and the content check needs 512 bytes of it.
4764
+ *
4765
+ * @param {string} path
4766
+ * @param {number} length
4767
+ * @returns {Buffer}
4768
+ */
4769
+ function readFileHead(path, length) {
4770
+ const descriptor = openSync(path, 'r')
4771
+ try {
4772
+ const buffer = Buffer.alloc(length)
4773
+ const read = readSync(descriptor, buffer, 0, length, 0)
4774
+ return buffer.subarray(0, read)
4775
+ } finally {
4776
+ closeSync(descriptor)
4777
+ }
4778
+ }