@deepblend/dsh-blender-host 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/delivery-manifest.js +177 -0
- package/lib/frame-ledger.js +189 -0
- package/lib/index.js +4778 -0
- package/lib/paths.js +314 -0
- package/lib/project-store.js +528 -0
- package/lib/render-job-store.js +290 -0
- package/lib/render-journal.js +236 -0
- package/lib/render-reconciler.js +340 -0
- package/lib/revision-transaction.js +1064 -0
- package/lib/video-encoder.js +345 -0
- package/package.json +34 -0
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
|
+
}
|