@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
|
@@ -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
|
+
}
|