@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/asset-content.js +134 -0
- package/lib/bitmap-font.js +149 -0
- package/lib/canonical.js +98 -0
- package/lib/config-surface.js +88 -0
- package/lib/contact-sheet.js +233 -0
- package/lib/deployment-paths.js +153 -0
- package/lib/index.js +829 -0
- package/lib/json-schema.js +337 -0
- package/lib/png.js +452 -0
- package/lib/projections.js +209 -0
- package/lib/redact.js +53 -0
- package/lib/render-job.js +459 -0
- package/lib/scene-patch.js +947 -0
- package/lib/scene-spec.js +901 -0
- package/lib/schemas/job-result.schema.json +295 -0
- package/lib/schemas/scene-patch.schema.json +1307 -0
- package/lib/schemas/scene-spec.schema.json +791 -0
- package/lib/ui-api.js +695 -0
- package/lib/visual-composition.js +574 -0
- package/lib/visual-issue.js +738 -0
- package/lib/visual-loop.js +401 -0
- package/package.json +19 -0
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
|
+
}
|