@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.
@@ -0,0 +1,177 @@
1
+ /**
2
+ * Delivery manifest — what a delivered package contains, without opening it.
3
+ *
4
+ * SPEC §10.4 and §20 M3 require the final package to carry the SceneSpec, the
5
+ * `.blend`, the video, the manifest and QA, and the M3 brief's design question 5
6
+ * states the test: "它要能让人**不看磁盘**就判断交付是否完整" — a reader must be
7
+ * able to judge completeness from the manifest alone. So this module writes down
8
+ * everything that judgement needs: the frame range and per-frame inventory, the
9
+ * video's CLAIMED properties, the MEASURED properties ffprobe reported, the
10
+ * disagreements between them, and the digests that tie the video back to the exact
11
+ * frames and the exact scene.
12
+ *
13
+ * WHY THE CLAIM AND THE MEASUREMENT BOTH APPEAR
14
+ * ---------------------------------------------
15
+ * A manifest that only recorded what the encoder intended would be a manifest
16
+ * that agrees with itself. Recording `video.probed` beside `video.expected`, plus
17
+ * the problems found between them, is what makes the file falsifiable by anyone
18
+ * who reads it — and it is why `verified` is a fact about a comparison rather than
19
+ * a boolean the encoder sets.
20
+ *
21
+ * Owner: DeepBlend Studio — M3
22
+ * Plane: Host composition
23
+ */
24
+
25
+ import { basename } from 'node:path'
26
+
27
+ import {
28
+ DELIVERY_MANIFEST_VERSION,
29
+ deliveryCompleteness,
30
+ verifyVideoProperties,
31
+ } from '@deepblend/dsh-blender-contracts'
32
+
33
+ import { fileSha256, fileSize } from './paths.js'
34
+
35
+ /**
36
+ * Build the delivery manifest for a finished render job.
37
+ *
38
+ * @param {object} input
39
+ * @param {object} input.record - the render job record
40
+ * @param {object} input.ledger - the verified frame ledger
41
+ * @param {object} input.probed - the ffprobe measurement
42
+ * @param {object} input.encode - `{ outputPath, bytes, argv, durationMs }`
43
+ * @param {object} input.publish - `{ videoPath, relativeTo }`
44
+ * @param {object} input.sources - `{ sceneSpec, checkpoint, qa }` absolute paths
45
+ * @param {object} input.runtimeIdentity - Blender/runtime identity (SPEC §9.5)
46
+ * @returns {object}
47
+ */
48
+ export function buildDeliveryManifest(input) {
49
+ const record = input.record
50
+ const frameCount = record.frameEnd - record.frameStart + 1
51
+ const relative = absolute => relativeTo(input.publish.relativeTo, absolute)
52
+
53
+ const claimed = {
54
+ frameStart: record.frameStart,
55
+ frameEnd: record.frameEnd,
56
+ frameCount,
57
+ fps: record.fps,
58
+ width: record.renderConfig?.resolution?.[0],
59
+ height: record.renderConfig?.resolution?.[1],
60
+ }
61
+ const verdict = verifyVideoProperties({
62
+ claimed,
63
+ probed: {
64
+ durationSeconds: input.probed.durationSeconds,
65
+ fps: input.probed.fps,
66
+ width: input.probed.width,
67
+ height: input.probed.height,
68
+ nbFrames: input.probed.nbFrames,
69
+ codec: input.probed.codec,
70
+ },
71
+ })
72
+
73
+ const manifest = {
74
+ schemaVersion: DELIVERY_MANIFEST_VERSION,
75
+ generatedAt: new Date().toISOString(),
76
+ projectId: record.projectId,
77
+ revisionId: record.revisionId,
78
+ jobId: record.jobId,
79
+ attempt: record.attempt,
80
+
81
+ source: {
82
+ sceneSpec: sourceEntry(input.sources.sceneSpec, relative),
83
+ checkpoint: sourceEntry(input.sources.checkpoint, relative),
84
+ qa: sourceEntry(input.sources.qa, relative),
85
+ sceneSpecDigest: record.sceneSpecDigest ?? null,
86
+ },
87
+
88
+ frames: {
89
+ directory: relative(record.framesDirectory ?? null),
90
+ prefix: record.filePrefix ?? 'frame_',
91
+ padding: record.filePadding ?? 4,
92
+ first: record.frameStart,
93
+ last: record.frameEnd,
94
+ expected: frameCount,
95
+ rendered: input.ledger.presentCount,
96
+ // The per-frame inventory is a list of NUMBERS, not of digests: 450 sha256
97
+ // lines would triple the manifest to answer a question ("is every frame
98
+ // there") that the count plus the ledger verdict already answers.
99
+ renderedFrames: input.ledger.present.map(entry => entry.frame),
100
+ corrupt: input.ledger.corrupt.map(entry => ({ frame: entry.frame, reason: entry.reason })),
101
+ bytes: input.ledger.present.reduce((total, entry) => total + entry.bytes, 0),
102
+ },
103
+
104
+ render: {
105
+ profileName: record.profileName ?? null,
106
+ config: record.renderConfig ?? null,
107
+ cameraId: record.cameraId ?? null,
108
+ meanMsPerFrame: record.meanMsPerFrame ?? null,
109
+ renderDurationMs: record.renderDurationMs ?? null,
110
+ blender: input.runtimeIdentity ?? null,
111
+ },
112
+
113
+ video: {
114
+ path: relative(input.publish.videoPath),
115
+ container: 'mp4',
116
+ bytes: input.encode.bytes,
117
+ sha256: fileSha256(input.publish.videoPath),
118
+ expected: claimed,
119
+ probed: {
120
+ durationSeconds: input.probed.durationSeconds,
121
+ fps: input.probed.fps,
122
+ width: input.probed.width,
123
+ height: input.probed.height,
124
+ frameCount: input.probed.nbFrames,
125
+ // Kept separate from `frameCount` on purpose: the container's own claim and
126
+ // the decoded count are different facts, and a delivery where they disagree
127
+ // is worth seeing even when the decoded count is right.
128
+ containerClaimedFrameCount: input.probed.nbClaimedFrames ?? null,
129
+ codec: input.probed.codec,
130
+ },
131
+ problems: verdict.problems,
132
+ verified: verdict.ok,
133
+ encode: {
134
+ durationMs: input.encode.durationMs,
135
+ argv: input.encode.argv,
136
+ },
137
+ },
138
+
139
+ // The QA verdict travels WITH the manifest, for the same reason the video's
140
+ // measured properties do: a reader must be able to judge the package without
141
+ // opening it, and "the QA report exists at this path" cannot answer "did it
142
+ // pass". The path is recorded beside it (`source.qa`) for the audit trail.
143
+ qa: {
144
+ path: relative(input.sources.qa ?? null),
145
+ report: input.qa ?? record.qa ?? null,
146
+ },
147
+ }
148
+
149
+ manifest.completeness = deliveryCompleteness(manifest)
150
+ return manifest
151
+ }
152
+
153
+ function sourceEntry(path, relative) {
154
+ if (path === null || path === undefined) return { path: null, bytes: null, sha256: null }
155
+ return { path: relative(path), bytes: fileSize(path), sha256: fileSha256(path) }
156
+ }
157
+
158
+ /**
159
+ * Express a path relative to the project root, or in full when it is outside it.
160
+ *
161
+ * A stored manifest must survive the workspace being moved (the same rule the
162
+ * M1 artifact records follow), so paths are project-relative. A path that cannot
163
+ * be made relative is reported in full rather than as a plausible-looking relative
164
+ * one: a silently wrong path in a delivery manifest is worse than an absolute one.
165
+ *
166
+ * @param {string} root
167
+ * @param {string|null|undefined} absolute
168
+ * @returns {string|null}
169
+ */
170
+ export function relativeTo(root, absolute) {
171
+ if (absolute === null || absolute === undefined) return null
172
+ if (root === null || root === undefined) return absolute
173
+ const normalizedRoot = root.endsWith('/') ? root.slice(0, -1) : root
174
+ if (absolute === normalizedRoot) return basename(absolute)
175
+ if (absolute.startsWith(`${normalizedRoot}/`)) return absolute.slice(normalizedRoot.length + 1)
176
+ return absolute
177
+ }
@@ -0,0 +1,189 @@
1
+ /**
2
+ * Frame ledger — what a render job has actually delivered, read off disk.
3
+ *
4
+ * THE RULE THIS MODULE EXISTS FOR
5
+ * -------------------------------
6
+ * **The frames are the authority; the job record's `completedFrames[]` is a cache
7
+ * of that authority and may never overrule it.**
8
+ *
9
+ * The M3 restart probe measured why this matters. A `kill -9` of the process that
10
+ * started a render left three facts behind:
11
+ *
12
+ * 1. the Blender child SURVIVED (it is spawned `detached`, so it leads its own
13
+ * process group) and was still writing into the directory a recovery was
14
+ * about to describe;
15
+ * 2. the child's `events.jsonl` journal was intact up to its last fsynced line —
16
+ * a fact about the journal, not about the frames;
17
+ * 3. a process killed between `create` and `finish` leaves a file that EXISTS
18
+ * and is not a frame.
19
+ *
20
+ * So a record that said "frames 1..3 are done" would have been right by luck, and
21
+ * a record that trusted file existence would have been wrong whenever the kill
22
+ * landed mid-write. This module reads the frames.
23
+ *
24
+ * WHY IT SAMPLES RATHER THAN READS
25
+ * --------------------------------
26
+ * A 450-frame delivery is ~424 MiB. The ledger is rebuilt on every recovery and
27
+ * on every progress tick, so reading all of it would cost more than the render.
28
+ * `inspectFrameSample` in the contracts package decides the question from a stat
29
+ * plus 45 bytes per frame — the PNG signature and IHDR at the front, the IEND
30
+ * trailer at the back — which is the same question answered from the artifact.
31
+ *
32
+ * Owner: DeepBlend Studio — M3
33
+ * Plane: Host composition
34
+ */
35
+
36
+ import { closeSync, existsSync, openSync, readSync, readdirSync, statSync } from 'node:fs'
37
+ import { join } from 'node:path'
38
+
39
+ import {
40
+ frameFileName,
41
+ inspectFrameSample as inspectFrameSampleFromContracts,
42
+ resolveFrameLedger,
43
+ } from '@deepblend/dsh-blender-contracts'
44
+
45
+ /** Bytes of the file read from the front. Covers the signature and the IHDR. */
46
+ const HEAD_BYTES = 33
47
+ /** Bytes of the file read from the back. Covers a whole IEND chunk and its CRC. */
48
+ const TAIL_BYTES = 12
49
+
50
+ /**
51
+ * Sample one frame without reading it.
52
+ *
53
+ * `exists` is part of the result and not inferable from `size`, and that is a
54
+ * measured fix rather than tidiness: an ABSENT file and an EMPTY file both have
55
+ * size 0, so a reader that only saw the size reported every missing frame as
56
+ * "empty" — a corrupt frame. The render set came out right by luck (both are
57
+ * re-rendered), but the diagnosis was wrong, so a status line saying "3 absent"
58
+ * where the truth was "3 corrupt" was a lie the type system could not catch.
59
+ *
60
+ * @param {string} path
61
+ * @returns {{exists: boolean, size: number, header?: Buffer, tail?: Buffer}}
62
+ */
63
+ export function sampleFrame(path) {
64
+ let size
65
+ try {
66
+ size = statSync(path).size
67
+ } catch {
68
+ return { exists: false, size: 0 }
69
+ }
70
+ let descriptor = null
71
+ try {
72
+ descriptor = openSync(path, 'r')
73
+ const header = Buffer.alloc(Math.min(HEAD_BYTES, size))
74
+ if (header.length > 0) readSync(descriptor, header, 0, header.length, 0)
75
+ const tailLength = Math.min(TAIL_BYTES, size)
76
+ const tail = Buffer.alloc(tailLength)
77
+ if (tailLength > 0) readSync(descriptor, tail, 0, tailLength, size - tailLength)
78
+ return { exists: true, size, header, tail }
79
+ } catch {
80
+ // An unreadable file is a fact to report, not an exception to escape this
81
+ // function: the ledger's whole job is to describe the directory truthfully.
82
+ // It EXISTS — the stat succeeded — and its bytes could not be read.
83
+ return { exists: true, size }
84
+ } finally {
85
+ if (descriptor !== null) {
86
+ try {
87
+ closeSync(descriptor)
88
+ } catch {
89
+ // Not for "already closed" — the descriptor was opened here and nothing else closes it. It is here
90
+ // because a throw inside `finally` REPLACES the error being propagated, and the caller needs the read
91
+ // failure rather than a close failure.
92
+ }
93
+ }
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Build the ledger for one job's frames.
99
+ *
100
+ * @param {object} input
101
+ * @param {string} input.framesDirectory - absolute path of the frames directory.
102
+ * @param {number[]} input.expected - every frame this job is responsible for.
103
+ * @param {{width?: number, height?: number, prefix?: string, padding?: number}} [input.expectedSize]
104
+ * @returns {{present: object[], corrupt: object[], missing: number[], toRender: number[],
105
+ * presentCount: number, corruptCount: number, missingCount: number, toRenderCount: number}}
106
+ */
107
+ /**
108
+ * The same decision applied to one frame, re-exported so a caller that is
109
+ * verifying a single claimed frame (the progress path) does not have to build a
110
+ * whole ledger to ask about it.
111
+ *
112
+ * @param {{size: number, header?: Buffer, tail?: Buffer}} sample
113
+ * @param {{width?: number, height?: number}} [expected]
114
+ */
115
+ export function inspectFrameSample(sample, expected = {}) {
116
+ return inspectFrameSampleFromContracts(sample, expected)
117
+ }
118
+
119
+ /**
120
+ * Build the ledger for one job's frames.
121
+ *
122
+ * @param {object} input
123
+ * @param {string} input.framesDirectory - absolute path of the frames directory.
124
+ * @param {number[]} input.expected - every frame this job is responsible for.
125
+ * @param {{width?: number, height?: number, prefix?: string, padding?: number}} [input.expectedSize]
126
+ * @returns {{present: object[], corrupt: object[], missing: number[], toRender: number[],
127
+ * presentCount: number, corruptCount: number, missingCount: number, toRenderCount: number}}
128
+ */
129
+ export function readFrameLedger(input) {
130
+ const prefix = input.expectedSize?.prefix ?? 'frame_'
131
+ const padding = input.expectedSize?.padding ?? 4
132
+ /** @type {Map<number, {ok: boolean, reason?: string|null, bytes?: number}>} */
133
+ const observed = new Map()
134
+ for (const frame of input.expected) {
135
+ const path = join(input.framesDirectory, frameFileName(frame, prefix, padding))
136
+ const sample = sampleFrame(path)
137
+ // An absent frame is left OUT of the observation map rather than recorded as a
138
+ // zero-byte file: `resolveFrameLedger` reports what it does not observe as
139
+ // MISSING, which is a different fact from a present-but-incomplete frame and
140
+ // the two lead a reader to different actions.
141
+ if (sample.exists !== true) continue
142
+ const verdict = inspectFrameSample(sample, {
143
+ width: input.expectedSize?.width,
144
+ height: input.expectedSize?.height,
145
+ })
146
+ observed.set(frame, { ok: verdict.ok, reason: verdict.reason, bytes: verdict.bytes })
147
+ }
148
+ const ledger = resolveFrameLedger({ expected: input.expected, observed })
149
+ return {
150
+ ...ledger,
151
+ presentCount: ledger.present.length,
152
+ corruptCount: ledger.corrupt.length,
153
+ missingCount: ledger.missing.length,
154
+ toRenderCount: ledger.toRender.length,
155
+ }
156
+ }
157
+
158
+ /**
159
+ * Every PNG present in a frames directory, as frame numbers.
160
+ *
161
+ * Used only for DIAGNOSIS — reporting frames on disk that this job does not
162
+ * expect. It is deliberately not part of the ledger: a directory listing cannot
163
+ * tell a complete frame from a torn one, and it cannot tell a frame of THIS job
164
+ * from a leftover of another.
165
+ *
166
+ * Directory ENTRIES are skipped, which is a measured fix: a directory named
167
+ * `frame_0004.png` was listed here as a frame on disk, and the listing is the one
168
+ * place a reader looks to see whether the frames are really there. What the entry
169
+ * RESOLVES to is a question for the ledger (`sampleFrame` follows links and reads
170
+ * bytes), so a symlink to a PNG is still listed — it is a usable frame.
171
+ *
172
+ * @param {string} framesDirectory
173
+ * @param {{prefix?: string}} [options]
174
+ * @returns {number[]}
175
+ */
176
+ export function framesOnDisk(framesDirectory, options = {}) {
177
+ if (!existsSync(framesDirectory)) return []
178
+ const prefix = options.prefix ?? 'frame_'
179
+ const found = []
180
+ for (const entry of readdirSync(framesDirectory, { withFileTypes: true })) {
181
+ if (entry.isDirectory()) continue
182
+ const name = entry.name
183
+ if (!name.startsWith(prefix) || !name.endsWith('.png')) continue
184
+ const digits = name.slice(prefix.length, -4)
185
+ if (!/^\d+$/.test(digits)) continue
186
+ found.push(Number.parseInt(digits, 10))
187
+ }
188
+ return found.sort((left, right) => left - right)
189
+ }