@deepblend/dsh-blender-contracts 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/redact.js ADDED
@@ -0,0 +1,53 @@
1
+ /**
2
+ * What a URL looks like after it has been through this product's messages and records.
3
+ *
4
+ * WHY THIS EXISTS
5
+ * ---------------
6
+ * SPEC §15.2's "日志脱敏" was half done and the table said so (`milestone-status.md` §7 #10): no secret
7
+ * ever reaches a child process (the environment whitelist), but nothing filtered what this plugin
8
+ * itself WRITES. And what it wrote, in five places, was the asset URL verbatim — including into the
9
+ * job record and into the approval prompt an operator reads. A presigned URL
10
+ * (`…?X-Amz-Signature=…`) or a URL carrying an API token is the ordinary way a model is handed a
11
+ * model file, so "the URL is not a secret" is a claim the product cannot make.
12
+ *
13
+ * THE RULE, AND WHY IT DROPS RATHER THAN MASKS
14
+ * -------------------------------------------
15
+ * Credentials, the query string and the fragment are REMOVED, and the removal is SAID in the text:
16
+ * a reader has to be able to tell `https://host/a.glb` from `https://host/a.glb?token=…` that was
17
+ * cleaned, because the second one may behave differently on a retry. Masking individual parameter
18
+ * values would need a list of parameter names to trust, and that list is exactly the thing that
19
+ * rots: a signature parameter nobody thought of is a leak, while a dropped query is at worst a
20
+ * less useful message.
21
+ *
22
+ * A string that is not a URL at all cannot be parsed, so it cannot be cleaned — it is truncated
23
+ * instead. That is not redaction and does not pretend to be: it bounds what a record can accumulate
24
+ * from an arbitrary string, and the caller that passed a non-URL has already seen it.
25
+ *
26
+ * Owner: DeepBlend Studio — M5
27
+ */
28
+
29
+ /** How much of an unparseable source is kept. Long enough to recognise, short enough to bound a record. */
30
+ const UNPARSEABLE_LIMIT = 120
31
+
32
+ /**
33
+ * A URL with its credentials, query string and fragment removed, saying what was removed.
34
+ *
35
+ * @param {unknown} raw
36
+ * @returns {string}
37
+ */
38
+ export function redactUrl(raw) {
39
+ const text = typeof raw === 'string' ? raw : String(raw)
40
+ let parsed
41
+ try {
42
+ parsed = new URL(text)
43
+ } catch {
44
+ return text.length > UNPARSEABLE_LIMIT ? `${text.slice(0, UNPARSEABLE_LIMIT)}…` : text
45
+ }
46
+ const removed = []
47
+ if (parsed.username !== '' || parsed.password !== '') removed.push('credentials')
48
+ if (parsed.search !== '') removed.push('query')
49
+ if (parsed.hash !== '') removed.push('fragment')
50
+ const clean = `${parsed.protocol}//${parsed.host}${parsed.pathname}`
51
+ if (removed.length === 0) return clean
52
+ return `${clean} (${removed.join(' and ')} removed)`
53
+ }
@@ -0,0 +1,459 @@
1
+ /**
2
+ * Render-job vocabulary — the durable, restart-survivable half of SPEC §10.
3
+ *
4
+ * WHY THIS IS A SEPARATE DOCUMENT TYPE FROM `deepblend.job/v1`
5
+ * -----------------------------------------------------------
6
+ * M1 already writes one `deepblend.job/v1` record per Blender action beneath
7
+ * `<project>/jobs/`. That record is an ATTEMPT LOG: written once when the action
8
+ * starts, once when it ends, and nothing in between, because every M1/M2 action
9
+ * finishes inside the tool call that started it. Its schema mirrors to
10
+ * `deepblend/schemas/job-result.schema.json`, and one is written per action under
11
+ * `<project>/jobs/` — the number on disk depends on what has been run, so it is not
12
+ * stated here (a count of operator state is a claim this repository cannot keep true).
13
+ *
14
+ * SPEC §10.2 describes a different object: a record with `attempt`, `pid`,
15
+ * `completedFrames[]`, `jobDirectory`, `outputManifest` and the statuses
16
+ * `queued | running | stopping | recovering | completed | failed | cancelled`.
17
+ * Folding the two together would mean rewriting a mirrored schema, invalidating
18
+ * the records already on disk, and asking one document to mean two things. So the
19
+ * durable render job is its own type, in its own place:
20
+ *
21
+ * <project>/jobs/<jobId>.json attempt log (unchanged, M1)
22
+ * <project>/renders/<jobId>/job.json render job (new, M3)
23
+ *
24
+ * THE ONE RULE THIS MODULE EXISTS TO ENFORCE
25
+ * ------------------------------------------
26
+ * **The frames on disk are the authority for "what is done"; `completedFrames[]`
27
+ * is a cache of that authority and may never overrule it.**
28
+ *
29
+ * That is not a stylistic preference — it is what the M3 restart probe measured.
30
+ * After a `kill -9` of the process that started a render:
31
+ *
32
+ * - the Blender child SURVIVED (detached process group), still writing into the
33
+ * very directory a recovery is about to describe;
34
+ * - `events.jsonl` was intact up to the last fsynced line, which is a fact about
35
+ * the journal and not about the frames;
36
+ * - a process killed mid-write leaves a file that EXISTS and is not a frame.
37
+ *
38
+ * So this module computes the ledger from the frames, and the host derives the
39
+ * render set as `missing + corrupt`. The probe's own ledger implementation was
40
+ * written first and this module reproduces it; `render-job.test.mjs` pins the
41
+ * parts that must agree with `deepblend_frames.py` byte for byte.
42
+ *
43
+ * Owner: DeepBlend Studio — M3
44
+ */
45
+
46
+ /** Document type of the durable render job record. */
47
+ export const RENDER_JOB_VERSION = 'deepblend.render-job/v1'
48
+ /** Document type of a provider invocation's frame plan. */
49
+ export const FRAME_PLAN_VERSION = 'deepblend.frame-plan/v1'
50
+ /** Document type written by the Blender child to claim its own pid. */
51
+ export const PROCESS_IDENTITY_VERSION = 'deepblend.process/v1'
52
+ /** Document type of the delivery manifest (SPEC §10.4, §20 M3). */
53
+ export const DELIVERY_MANIFEST_VERSION = 'deepblend.delivery-manifest/v1'
54
+
55
+ /**
56
+ * Statuses a render job may hold (SPEC §10.2).
57
+ *
58
+ * `recovering` is the only one that exists purely for restart: a job that was
59
+ * `running` when the process died is not yet failed — the frames it finished are
60
+ * real, and the honest state is "being recovered", which a reader can act on.
61
+ */
62
+ export const RENDER_JOB_STATUSES = Object.freeze([
63
+ 'queued',
64
+ 'running',
65
+ 'stopping',
66
+ 'recovering',
67
+ 'completed',
68
+ 'failed',
69
+ 'cancelled',
70
+ ])
71
+
72
+ /**
73
+ * Statuses in which nothing happens to a job on its own.
74
+ *
75
+ * This is the reconciler's filter, not a claim that the job can never move again:
76
+ * a `failed` or `cancelled` job is deliberately re-openable by an explicit resume
77
+ * (see {@link canTransitionRenderJob}). Read it as "no automatic work".
78
+ */
79
+ export const RENDER_JOB_TERMINAL_STATUSES = Object.freeze(['completed', 'failed', 'cancelled'])
80
+
81
+ /** What a render job produces. SPEC §10.2 fixes this vocabulary. */
82
+ export const RENDER_JOB_TYPES = Object.freeze(['preview', 'final-render', 'export'])
83
+
84
+ /**
85
+ * Legal transitions. Anything else is a bug in the caller, not a race to paper over.
86
+ *
87
+ * TWO DIFFERENT IDEAS THAT LOOK LIKE ONE
88
+ * --------------------------------------
89
+ * `isTerminalRenderJobStatus` answers "does anything happen to this job on its
90
+ * own?" — it is what the reconciler scans by, and `completed`, `failed` and
91
+ * `cancelled` all answer no.
92
+ *
93
+ * This table answers "may a caller move it?", and there the three differ. A
94
+ * `completed` job is finished: its frames are rendered, its video is encoded and
95
+ * verified, its package is published, and re-delivering it is `exportProject`,
96
+ * which leaves the status alone. A `failed` or `cancelled` job is a job with
97
+ * frames on disk and work left, and refusing to re-open it would mean a Blender
98
+ * crash or a change of mind in hour three forces the whole 3.4-hour render to
99
+ * start over — which is precisely what `attempt` exists to count.
100
+ */
101
+ const TRANSITIONS = Object.freeze({
102
+ // `recovering` is reachable from `queued` because a Host can be killed in the
103
+ // window between writing the record and spawning the renderer, which leaves a
104
+ // job that never rendered a single frame and that the reconciler must still be
105
+ // able to describe. Omitting it made the reconciler throw on exactly the crash
106
+ // it exists for.
107
+ queued: Object.freeze(['running', 'stopping', 'recovering', 'failed', 'cancelled']),
108
+ running: Object.freeze(['stopping', 'recovering', 'completed', 'failed', 'cancelled']),
109
+ // `recovering` from `stopping`: a Host killed while cancelling leaves a job that
110
+ // owes frames and carries a cancellation nobody is left to complete.
111
+ stopping: Object.freeze(['cancelled', 'failed', 'completed', 'recovering']),
112
+ recovering: Object.freeze(['running', 'completed', 'failed', 'cancelled']),
113
+ completed: Object.freeze([]),
114
+ failed: Object.freeze(['recovering', 'running', 'cancelled']),
115
+ cancelled: Object.freeze(['recovering', 'running']),
116
+ })
117
+
118
+ /** @param {string} status */
119
+ export function isTerminalRenderJobStatus(status) {
120
+ return RENDER_JOB_TERMINAL_STATUSES.includes(status)
121
+ }
122
+
123
+ /**
124
+ * Is `to` reachable from `from`?
125
+ * @param {string} from
126
+ * @param {string} to
127
+ */
128
+ export function canTransitionRenderJob(from, to) {
129
+ if (!RENDER_JOB_STATUSES.includes(from) || !RENDER_JOB_STATUSES.includes(to)) return false
130
+ if (from === to) return true
131
+ return TRANSITIONS[from].includes(to)
132
+ }
133
+
134
+ /**
135
+ * Assert a transition, so an illegal one is a named failure rather than a record
136
+ * that quietly ends up in a state no reader can act on.
137
+ * @param {string} from
138
+ * @param {string} to
139
+ * @returns {{ok: boolean, reason: string|null}}
140
+ */
141
+ export function checkTransition(from, to) {
142
+ if (canTransitionRenderJob(from, to)) return { ok: true, reason: null }
143
+ return {
144
+ ok: false,
145
+ reason: `a render job cannot move from "${from}" to "${to}"`,
146
+ }
147
+ }
148
+
149
+ /* -------------------------------------------------------------------------- */
150
+ /* Frame naming — the half that must agree with deepblend_frames.py */
151
+ /* -------------------------------------------------------------------------- */
152
+
153
+ /** Default prefix and zero padding, matching the Python renderer. */
154
+ export const FRAME_FILE_PREFIX = 'frame_'
155
+ export const FRAME_FILE_PADDING = 4
156
+
157
+ /**
158
+ * The file name one frame is written to.
159
+ *
160
+ * MEASURED, and the reason this is a shared function rather than a format string
161
+ * at each call site: `scene.render.frame_path(frame=f)` PREDICTS
162
+ * `<dir>/frame_0001.png` while `bpy.ops.render.render(write_still=True)` writes
163
+ * `<dir>/frame_.png`. The Python renderer therefore sets the exact path itself,
164
+ * and this side must produce the same string or every frame reads as missing.
165
+ *
166
+ * @param {number} frame
167
+ * @param {string} [prefix]
168
+ * @param {number} [padding]
169
+ * @returns {string}
170
+ */
171
+ export function frameFileName(frame, prefix = FRAME_FILE_PREFIX, padding = FRAME_FILE_PADDING) {
172
+ return `${prefix}${String(frame).padStart(padding, '0')}.png`
173
+ }
174
+
175
+ /**
176
+ * The frames of an inclusive range.
177
+ *
178
+ * A range is validated rather than trusted: an inverted range is a caller bug
179
+ * that would otherwise render nothing and report success, which is exactly the
180
+ * failure mode SPEC §11.1 forbids.
181
+ *
182
+ * @param {number} frameStart
183
+ * @param {number} frameEnd
184
+ * @returns {{frames: number[], error: string|null}}
185
+ */
186
+ export function frameNumbers(frameStart, frameEnd) {
187
+ if (!Number.isSafeInteger(frameStart) || !Number.isSafeInteger(frameEnd)) {
188
+ return { frames: [], error: 'frame bounds must be integers' }
189
+ }
190
+ if (frameEnd < frameStart) {
191
+ return {
192
+ frames: [],
193
+ error: `the frame range ${frameStart}..${frameEnd} is inverted, so it contains no frames`,
194
+ }
195
+ }
196
+ const frames = []
197
+ for (let frame = frameStart; frame <= frameEnd; frame += 1) frames.push(frame)
198
+ return { frames, error: null }
199
+ }
200
+
201
+ /* -------------------------------------------------------------------------- */
202
+ /* The frame ledger */
203
+ /* -------------------------------------------------------------------------- */
204
+
205
+ /**
206
+ * A PNG is only a frame if it carries at least its signature and one chunk.
207
+ * Mirrors `MIN_FRAME_BYTES` in `deepblend_frames.py`.
208
+ */
209
+ export const MIN_FRAME_BYTES = 512
210
+
211
+ /**
212
+ * Decide whether one candidate frame IS a frame, from a sample of its bytes.
213
+ *
214
+ * WHY A SAMPLE AND NOT THE WHOLE FILE
215
+ * -----------------------------------
216
+ * The ledger is rebuilt from disk on every recovery AND on every progress tick,
217
+ * and a 450-frame delivery is ~424 MiB of PNG. Reading all of it to answer "which
218
+ * frames are done" would cost more than the render it is describing. The four
219
+ * facts that decide the question all live at the ends of the file — the PNG
220
+ * signature and the IHDR dimensions in the first 33 bytes, the size, and the IEND
221
+ * trailer — so a stat plus two small reads answers it, and the file's own bytes
222
+ * still answer it rather than a cached list.
223
+ *
224
+ * `inspectFrameBytes` is the same rule applied to a buffer already in memory, and
225
+ * exists so a test can pin the two against each other.
226
+ *
227
+ * @param {{size: number, header?: Uint8Array, tail?: Uint8Array}} sample
228
+ * @param {{width?: number, height?: number}} [expected]
229
+ * @returns {{ok: boolean, reason: string|null, width?: number, height?: number, bytes: number}}
230
+ */
231
+ export function inspectFrameSample(sample, expected = {}) {
232
+ const size = sample?.size ?? 0
233
+ if (size < MIN_FRAME_BYTES) {
234
+ return { ok: false, reason: size === 0 ? 'empty' : 'truncated', bytes: size }
235
+ }
236
+ const header = sample.header
237
+ if (header === undefined || header.length < 24) {
238
+ return { ok: false, reason: 'unreadable', bytes: size }
239
+ }
240
+ const signature = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]
241
+ for (let index = 0; index < signature.length; index += 1) {
242
+ if (header[index] !== signature[index]) return { ok: false, reason: 'not-a-png', bytes: size }
243
+ }
244
+ const width = ((header[16] << 24) | (header[17] << 16) | (header[18] << 8) | header[19]) >>> 0
245
+ const height = ((header[20] << 24) | (header[21] << 16) | (header[22] << 8) | header[23]) >>> 0
246
+ if (width <= 0 || height <= 0) return { ok: false, reason: 'bad-dimensions', bytes: size }
247
+ if (expected.width !== undefined && expected.height !== undefined &&
248
+ (width !== expected.width || height !== expected.height)) {
249
+ return {
250
+ ok: false,
251
+ reason: `wrong-dimensions ${width}x${height}, expected ${expected.width}x${expected.height}`,
252
+ width, height, bytes: size,
253
+ }
254
+ }
255
+ const tail = sample.tail
256
+ // A killed process leaves a file with no IEND chunk. This is the cheap
257
+ // structural end-of-file check; full decoding would cost more than the render.
258
+ if (tail === undefined || tail.length < 8 ||
259
+ tail[tail.length - 8] !== 0x49 || tail[tail.length - 7] !== 0x45 ||
260
+ tail[tail.length - 6] !== 0x4e || tail[tail.length - 5] !== 0x44) {
261
+ return { ok: false, reason: 'unterminated', width, height, bytes: size }
262
+ }
263
+ return { ok: true, reason: null, width, height, bytes: size }
264
+ }
265
+
266
+ /**
267
+ * The same rule applied to a complete in-memory PNG.
268
+ * @param {Uint8Array} bytes
269
+ * @param {{width?: number, height?: number}} [expected]
270
+ */
271
+ export function inspectFrameBytes(bytes, expected = {}) {
272
+ const size = bytes?.length ?? 0
273
+ return inspectFrameSample({
274
+ size,
275
+ header: size >= 24 ? bytes.subarray(0, 33) : undefined,
276
+ tail: size >= 12 ? bytes.subarray(size - 12) : undefined,
277
+ }, expected)
278
+ }
279
+
280
+ /**
281
+ * Turn per-frame observations into the ledger the host acts on.
282
+ *
283
+ * `toRender` is the point of the whole module: it is the exact set the renderer
284
+ * is handed, and it is `missing + corrupt` — a torn frame is re-rendered, not
285
+ * accepted, and not double-counted.
286
+ *
287
+ * @param {object} input
288
+ * @param {number[]} input.expected - every frame the job is responsible for.
289
+ * @param {Map<number, {ok: boolean, reason?: string|null, bytes?: number}>} input.observed
290
+ * @returns {{present: object[], corrupt: object[], missing: number[], toRender: number[]}}
291
+ */
292
+ export function resolveFrameLedger(input) {
293
+ const present = []
294
+ const corrupt = []
295
+ const missing = []
296
+ for (const frame of input.expected) {
297
+ const observation = input.observed.get(frame)
298
+ if (observation === undefined) {
299
+ missing.push(frame)
300
+ continue
301
+ }
302
+ if (observation.ok === true) {
303
+ present.push({ frame, bytes: observation.bytes ?? 0 })
304
+ continue
305
+ }
306
+ corrupt.push({ frame, reason: observation.reason ?? 'unknown', bytes: observation.bytes ?? 0 })
307
+ }
308
+ return {
309
+ present,
310
+ corrupt,
311
+ missing,
312
+ toRender: [...missing, ...corrupt.map(entry => entry.frame)],
313
+ }
314
+ }
315
+
316
+ /**
317
+ * Progress as an integer percentage, for the DSH job projection and the UI.
318
+ *
319
+ * Counted in FRAMES, not in frames-times-bytes: a delivered frame is worth one
320
+ * frame regardless of how well it compresses, and a progress bar that moves at a
321
+ * different rate from the work is worse than none.
322
+ *
323
+ * @param {{expected: number, done: number}} input
324
+ * @returns {number}
325
+ */
326
+ export function renderProgressPercent(input) {
327
+ if (!Number.isFinite(input.expected) || input.expected <= 0) return 0
328
+ const ratio = Math.max(0, Math.min(1, input.done / input.expected))
329
+ return Math.round(ratio * 100)
330
+ }
331
+
332
+ /**
333
+ * The number of milliseconds a completed run implies per frame, and the estimate
334
+ * remaining for the rest. Used for the model-readable status line only — nothing
335
+ * schedules against it.
336
+ *
337
+ * @param {{perFrameMs: number[], remainingFrames: number}} input
338
+ * @returns {{meanMsPerFrame: number|null, estimatedRemainingMs: number|null}}
339
+ */
340
+ export function estimateRemaining(input) {
341
+ const samples = input.perFrameMs.filter(value => Number.isFinite(value) && value > 0)
342
+ if (samples.length === 0) return { meanMsPerFrame: null, estimatedRemainingMs: null }
343
+ const mean = samples.reduce((total, value) => total + value, 0) / samples.length
344
+ return { meanMsPerFrame: mean, estimatedRemainingMs: Math.round(mean * input.remainingFrames) }
345
+ }
346
+
347
+ /* -------------------------------------------------------------------------- */
348
+ /* Delivery manifest and the video-property check (SPEC §10.4, §20 M3) */
349
+ /* -------------------------------------------------------------------------- */
350
+
351
+ /**
352
+ * Verify a probed video against the delivery manifest's own claims.
353
+ *
354
+ * WHY THIS IS A PURE FUNCTION AND NOT AN ASSERTION INSIDE THE ENCODER
355
+ * ------------------------------------------------------------------
356
+ * "最终视频属性正确" is an acceptance condition, and the thing that makes it
357
+ * checkable is that the CLAIM and the MEASUREMENT are separate inputs. The
358
+ * encoder writes what it intended; `ffprobe` reports what the file is; this
359
+ * function is the only place the two meet, and it returns every disagreement
360
+ * rather than the first — a video that is wrong in two ways is two problems.
361
+ *
362
+ * Duration is compared with a tolerance because a container stores it as a
363
+ * rational and 450/30 s is not exactly representable in every muxer.
364
+ *
365
+ * @param {object} input
366
+ * @param {{frameStart: number, frameEnd: number, fps: number, width: number, height: number, frameCount: number}} input.claimed
367
+ * @param {{durationSeconds: number|null, fps: number|null, width: number|null, height: number|null, nbFrames: number|null, codec: string|null}} input.probed
368
+ * @param {number} [input.durationToleranceSeconds]
369
+ * @returns {{ok: boolean, problems: object[]}}
370
+ */
371
+ export function verifyVideoProperties(input) {
372
+ const { claimed, probed } = input
373
+ const tolerance = input.durationToleranceSeconds ?? 0.05
374
+ const problems = []
375
+
376
+ const expectedDuration = claimed.frameCount / claimed.fps
377
+ if (probed.width !== claimed.width || probed.height !== claimed.height) {
378
+ problems.push({
379
+ field: 'resolution',
380
+ claimed: `${claimed.width}x${claimed.height}`,
381
+ probed: `${probed.width}x${probed.height}`,
382
+ })
383
+ }
384
+ if (probed.fps === null || Math.abs(probed.fps - claimed.fps) > 0.001) {
385
+ problems.push({ field: 'fps', claimed: claimed.fps, probed: probed.fps })
386
+ }
387
+ if (probed.nbFrames !== null && probed.nbFrames !== claimed.frameCount) {
388
+ problems.push({ field: 'frameCount', claimed: claimed.frameCount, probed: probed.nbFrames })
389
+ }
390
+ if (probed.durationSeconds === null ||
391
+ Math.abs(probed.durationSeconds - expectedDuration) > tolerance) {
392
+ problems.push({
393
+ field: 'durationSeconds',
394
+ claimed: Number(expectedDuration.toFixed(6)),
395
+ probed: probed.durationSeconds,
396
+ tolerance,
397
+ })
398
+ }
399
+ if (probed.codec === null || !/h264|hevc|av1|vp9/i.test(probed.codec)) {
400
+ problems.push({ field: 'codec', claimed: 'h264 (or another delivery codec)', probed: probed.codec })
401
+ }
402
+ return { ok: problems.length === 0, problems }
403
+ }
404
+
405
+ /**
406
+ * Does a delivery manifest describe a complete delivery? (SPEC §20 M3
407
+ * "最终包始终包含 SceneSpec、.blend、视频、Manifest 和 QA")
408
+ *
409
+ * Kept here so the tool, the UI and any future export path answer the same
410
+ * question the same way — a second opinion about completeness is a second thing
411
+ * that can be wrong.
412
+ *
413
+ * @param {object} manifest
414
+ * @returns {{complete: boolean, missing: string[]}}
415
+ */
416
+ export function deliveryCompleteness(manifest) {
417
+ const missing = []
418
+ const frames = manifest?.frames ?? {}
419
+ // The source artifacts live under `source`, and QA under `qa.report`. Read from
420
+ // the document that is actually written: an earlier version of this function
421
+ // expected `manifest.sceneSpec` and reported every complete delivery as missing
422
+ // its SceneSpec and checkpoint — a checker that disagrees with its own producer
423
+ // is worse than no checker, because it is believed.
424
+ const source = manifest?.source ?? {}
425
+ const present = entry => entry !== undefined && entry !== null && entry.path !== undefined && entry.path !== null
426
+
427
+ if (manifest?.video?.path === undefined || manifest?.video?.path === null) missing.push('video')
428
+ if (!Number.isFinite(frames.rendered) || frames.rendered <= 0) missing.push('frames')
429
+ if (Number.isFinite(frames.rendered) && Number.isFinite(frames.expected) && frames.rendered !== frames.expected) {
430
+ missing.push(`frames-incomplete (${frames.rendered}/${frames.expected})`)
431
+ }
432
+ if (manifest?.video?.verified !== true) missing.push('video-unverified')
433
+ if (!present(source.sceneSpec)) missing.push('scene-spec')
434
+ if (!present(source.checkpoint)) missing.push('checkpoint')
435
+ if (manifest?.qa?.report === undefined || manifest.qa.report === null) missing.push('qa')
436
+ return { complete: missing.length === 0, missing }
437
+ }
438
+
439
+ /**
440
+ * Render a render-job record as the compact line a model reads.
441
+ *
442
+ * @param {object} record
443
+ * @returns {string}
444
+ */
445
+ export function describeRenderJob(record) {
446
+ const total = (record.frameEnd ?? 0) - (record.frameStart ?? 0) + 1
447
+ const done = Array.isArray(record.completedFrames) ? record.completedFrames.length : 0
448
+ const parts = [
449
+ `${record.jobId} [${record.type}] ${record.status}`,
450
+ `project ${record.projectId}`,
451
+ `revision ${record.revisionId}`,
452
+ `frames ${record.frameStart}..${record.frameEnd} (${done}/${total})`,
453
+ `attempt ${record.attempt}`,
454
+ ]
455
+ if (record.pid !== undefined && record.pid !== null) parts.push(`pid ${record.pid}`)
456
+ if (record.errorCode !== undefined && record.errorCode !== null) parts.push(`error ${record.errorCode}`)
457
+ if (record.message !== undefined && record.message !== null) parts.push(record.message)
458
+ return parts.join(' | ')
459
+ }