@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,345 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Video encoder — frame sequence in, verified MP4 out (SPEC §10.4).
|
|
3
|
+
*
|
|
4
|
+
* WHY ffmpeg AND NOT BLENDER'S OWN FFMPEG OUTPUT
|
|
5
|
+
* ----------------------------------------------
|
|
6
|
+
* SPEC §10.4 is explicit and the reason is the whole of M3: "不建议直接让 Blender
|
|
7
|
+
* 一次输出不可恢复的视频文件作为唯一产物". A video written directly by the render
|
|
8
|
+
* process cannot be resumed, cannot be inspected frame by frame, and cannot be
|
|
9
|
+
* re-encoded without re-rendering. The frame sequence is the durable artifact;
|
|
10
|
+
* the MP4 is derived from it, and deriving it is idempotent — re-running the
|
|
11
|
+
* encoder on the same frames produces the same video without touching Blender.
|
|
12
|
+
*
|
|
13
|
+
* WHY THE ENCODE IS VERIFIED RATHER THAN TRUSTED
|
|
14
|
+
* ----------------------------------------------
|
|
15
|
+
* "最终视频属性正确" is an acceptance condition, so the claim and the measurement
|
|
16
|
+
* are kept apart on purpose: ffmpeg is told what to produce, `ffprobe` reports
|
|
17
|
+
* what the file IS, and `verifyVideoProperties` (contracts) is the only place the
|
|
18
|
+
* two meet. An encoder that returned its own argv as proof would pass for a file
|
|
19
|
+
* with the wrong duration.
|
|
20
|
+
*
|
|
21
|
+
* WHY `-count_frames`
|
|
22
|
+
* -------------------
|
|
23
|
+
* An MP4 header usually has no `nb_frames` for a stream, and a container that
|
|
24
|
+
* says "30 frames" while holding 29 is exactly the failure this milestone is
|
|
25
|
+
* meant to catch. `-count_frames` decodes and counts, so the number in the
|
|
26
|
+
* manifest is a measurement.
|
|
27
|
+
*
|
|
28
|
+
* Owner: DeepBlend Studio — M3
|
|
29
|
+
* Plane: Host composition
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { existsSync, statSync } from 'node:fs'
|
|
33
|
+
import { join } from 'node:path'
|
|
34
|
+
|
|
35
|
+
import { BlenderError, BlenderErrorCode, frameFileName } from '@deepblend/dsh-blender-contracts'
|
|
36
|
+
|
|
37
|
+
/** Grace given to a stuck ffmpeg before it is killed. */
|
|
38
|
+
const TERMINATE_GRACE_MS = 10_000
|
|
39
|
+
|
|
40
|
+
/** Seconds allowed for one encode or probe subprocess. */
|
|
41
|
+
const ENCODE_TIMEOUT_MS = 3_600_000
|
|
42
|
+
const PROBE_TIMEOUT_MS = 300_000
|
|
43
|
+
|
|
44
|
+
/** Cap on captured ffmpeg/ffprobe output before spilling. */
|
|
45
|
+
const MAX_OUTPUT_BYTES = 1024 * 1024
|
|
46
|
+
const MAX_SPILL_BYTES = 32 * 1024 * 1024
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Encode a complete frame sequence into an H.264 MP4.
|
|
50
|
+
*
|
|
51
|
+
* The input pattern is `<framesDirectory>/frame_%04d.png` with `-start_number`
|
|
52
|
+
* set to the first frame the job rendered. Frames are named by ABSOLUTE frame
|
|
53
|
+
* number (frame_0030.png is project frame 30), so `-start_number` is what maps
|
|
54
|
+
* the pattern onto the sequence; without it every delivery that does not begin at
|
|
55
|
+
* frame 1 would encode nothing.
|
|
56
|
+
*
|
|
57
|
+
* @param {object} input
|
|
58
|
+
* @param {import('@deepseek-ai/cordis').Context} input.ctx
|
|
59
|
+
* @param {string} input.ffmpegPath
|
|
60
|
+
* @param {string} input.framesDirectory
|
|
61
|
+
* @param {number} input.firstFrame
|
|
62
|
+
* @param {number} input.frameCount
|
|
63
|
+
* @param {number} input.fps
|
|
64
|
+
* @param {string} input.outputPath
|
|
65
|
+
* @param {string} [input.filePrefix]
|
|
66
|
+
* @param {number} [input.filePadding]
|
|
67
|
+
* @param {string} [input.crf]
|
|
68
|
+
* @returns {Promise<{outputPath: string, bytes: number, argv: string[], durationMs: number, stderr: string}>}
|
|
69
|
+
*/
|
|
70
|
+
export async function encodeFrameSequence(input) {
|
|
71
|
+
const executable = await resolveTool(input.ctx, input.ffmpegPath, 'ffmpeg', BlenderErrorCode.ENCODER_NOT_FOUND)
|
|
72
|
+
const prefix = input.filePrefix ?? 'frame_'
|
|
73
|
+
const padding = input.filePadding ?? 4
|
|
74
|
+
// ffmpeg's pattern syntax is `%0Nd`; it is derived from the job's own naming so
|
|
75
|
+
// a job that renders with different padding still encodes correctly.
|
|
76
|
+
const pattern = join(input.framesDirectory, `${prefix}%0${padding}d.png`)
|
|
77
|
+
|
|
78
|
+
const argv = [
|
|
79
|
+
executable,
|
|
80
|
+
'-hide_banner',
|
|
81
|
+
'-loglevel', 'error',
|
|
82
|
+
'-y',
|
|
83
|
+
// `-framerate` and `-start_number` are INPUT options for the image2 demuxer, so
|
|
84
|
+
// they belong before `-i`. Frames are named by ABSOLUTE frame number
|
|
85
|
+
// (frame_0030.png is project frame 30), which is what `-start_number` maps onto
|
|
86
|
+
// the pattern; without it a delivery that does not begin at frame 1 encodes
|
|
87
|
+
// nothing.
|
|
88
|
+
'-framerate', String(input.fps),
|
|
89
|
+
'-start_number', String(input.firstFrame),
|
|
90
|
+
'-i', pattern,
|
|
91
|
+
'-an',
|
|
92
|
+
'-c:v', 'libx264',
|
|
93
|
+
'-preset', input.preset ?? 'medium',
|
|
94
|
+
'-crf', String(input.crf ?? 18),
|
|
95
|
+
// yuv420p is the chroma format every player and every browser accepts;
|
|
96
|
+
// yuv444p produces a file that looks right in ffprobe and plays black in
|
|
97
|
+
// QuickTime.
|
|
98
|
+
'-pix_fmt', 'yuv420p',
|
|
99
|
+
// `-frames:v` is an OUTPUT option and must follow `-i`. Measured the hard way:
|
|
100
|
+
// placed before the input, ffmpeg 8.0.1 refuses the whole command with
|
|
101
|
+
// "Option frames:v ... cannot be applied to input url ... Move this option
|
|
102
|
+
// before the file it belongs to", and the delivery fails with ENCODE_FAILED.
|
|
103
|
+
// It bounds the encode by the exact frame count rather than by "whatever
|
|
104
|
+
// matches", so a stray frame from another attempt in the same directory cannot
|
|
105
|
+
// be appended and make the video longer than the manifest claims.
|
|
106
|
+
'-frames:v', String(input.frameCount),
|
|
107
|
+
'-movflags', '+faststart',
|
|
108
|
+
input.outputPath,
|
|
109
|
+
]
|
|
110
|
+
|
|
111
|
+
const startedAt = Date.now()
|
|
112
|
+
const run = await runTool(input.ctx, {
|
|
113
|
+
argv,
|
|
114
|
+
cwd: input.framesDirectory,
|
|
115
|
+
timeoutMs: input.timeoutMs ?? ENCODE_TIMEOUT_MS,
|
|
116
|
+
})
|
|
117
|
+
const durationMs = Date.now() - startedAt
|
|
118
|
+
|
|
119
|
+
if (run.exitCode !== 0 && !existsSync(input.outputPath)) {
|
|
120
|
+
throw new BlenderError(
|
|
121
|
+
BlenderErrorCode.ENCODE_FAILED,
|
|
122
|
+
`ffmpeg failed to encode ${input.frameCount} frame(s) from ${pattern}: ${run.stderr.slice(-2000) || `exit ${run.exitCode}`}`,
|
|
123
|
+
{ detail: { exitCode: run.exitCode, argv, stderr: run.stderr.slice(-4000) } },
|
|
124
|
+
)
|
|
125
|
+
}
|
|
126
|
+
if (!existsSync(input.outputPath)) {
|
|
127
|
+
throw new BlenderError(
|
|
128
|
+
BlenderErrorCode.ENCODE_FAILED,
|
|
129
|
+
`ffmpeg reported exit ${run.exitCode} but wrote no file at ${input.outputPath}.`,
|
|
130
|
+
{ detail: { argv, stderr: run.stderr.slice(-4000) } },
|
|
131
|
+
)
|
|
132
|
+
}
|
|
133
|
+
const bytes = statSync(input.outputPath).size
|
|
134
|
+
if (bytes === 0) {
|
|
135
|
+
throw new BlenderError(
|
|
136
|
+
BlenderErrorCode.ENCODE_FAILED,
|
|
137
|
+
`ffmpeg wrote an empty file at ${input.outputPath}.`,
|
|
138
|
+
{ detail: { argv } },
|
|
139
|
+
)
|
|
140
|
+
}
|
|
141
|
+
return { outputPath: input.outputPath, bytes, argv, durationMs, stderr: run.stderr }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Read a video's real properties with ffprobe.
|
|
146
|
+
*
|
|
147
|
+
* @param {object} input
|
|
148
|
+
* @param {import('@deepseek-ai/cordis').Context} input.ctx
|
|
149
|
+
* @param {string} input.ffprobePath
|
|
150
|
+
* @param {string} input.path
|
|
151
|
+
* @returns {Promise<{durationSeconds: number|null, fps: number|null, width: number|null,
|
|
152
|
+
* height: number|null, nbFrames: number|null, codec: string|null, raw: object}>}
|
|
153
|
+
*/
|
|
154
|
+
export async function probeVideo(input) {
|
|
155
|
+
const executable = await resolveTool(input.ctx, input.ffprobePath, 'ffprobe', BlenderErrorCode.PROBE_FAILED)
|
|
156
|
+
const argv = [
|
|
157
|
+
executable,
|
|
158
|
+
'-hide_banner',
|
|
159
|
+
'-loglevel', 'error',
|
|
160
|
+
'-count_frames',
|
|
161
|
+
'-select_streams', 'v:0',
|
|
162
|
+
'-show_entries', 'stream=width,height,r_frame_rate,avg_frame_rate,codec_name,nb_frames,nb_read_frames,duration',
|
|
163
|
+
'-show_entries', 'format=duration',
|
|
164
|
+
'-print_format', 'json',
|
|
165
|
+
input.path,
|
|
166
|
+
]
|
|
167
|
+
const run = await runTool(input.ctx, {
|
|
168
|
+
argv,
|
|
169
|
+
cwd: input.cwd ?? undefined,
|
|
170
|
+
timeoutMs: input.timeoutMs ?? PROBE_TIMEOUT_MS,
|
|
171
|
+
})
|
|
172
|
+
if (run.exitCode !== 0) {
|
|
173
|
+
throw new BlenderError(
|
|
174
|
+
BlenderErrorCode.PROBE_FAILED,
|
|
175
|
+
`ffprobe could not read ${input.path}: ${run.stderr.slice(-2000) || `exit ${run.exitCode}`}`,
|
|
176
|
+
{ detail: { argv, stderr: run.stderr.slice(-4000) } },
|
|
177
|
+
)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
let parsed
|
|
181
|
+
try {
|
|
182
|
+
parsed = JSON.parse(run.stdout)
|
|
183
|
+
} catch (cause) {
|
|
184
|
+
throw new BlenderError(
|
|
185
|
+
BlenderErrorCode.PROBE_FAILED,
|
|
186
|
+
`ffprobe produced output that is not JSON for ${input.path}.`,
|
|
187
|
+
{ cause, detail: { stdout: run.stdout.slice(0, 2000) } },
|
|
188
|
+
)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const stream = Array.isArray(parsed.streams) ? parsed.streams[0] : undefined
|
|
192
|
+
if (stream === undefined) {
|
|
193
|
+
throw new BlenderError(
|
|
194
|
+
BlenderErrorCode.PROBE_FAILED,
|
|
195
|
+
`ffprobe found no video stream in ${input.path}.`,
|
|
196
|
+
{ detail: { raw: parsed } },
|
|
197
|
+
)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// `nb_read_frames` is present because of `-count_frames` and is the measurement;
|
|
201
|
+
// `nb_frames` is the container's own claim and is often absent. The measurement
|
|
202
|
+
// wins when both exist — the whole point is not to take the container's word.
|
|
203
|
+
const readFrames = integerOrNull(stream.nb_read_frames)
|
|
204
|
+
const claimedFrames = integerOrNull(stream.nb_frames)
|
|
205
|
+
const codec = typeof stream.codec_name === 'string' ? stream.codec_name : null
|
|
206
|
+
|
|
207
|
+
return {
|
|
208
|
+
durationSeconds: numberOrNull(parsed.format?.duration) ?? numberOrNull(stream.duration),
|
|
209
|
+
fps: parseRational(stream.avg_frame_rate) ?? parseRational(stream.r_frame_rate),
|
|
210
|
+
width: integerOrNull(stream.width),
|
|
211
|
+
height: integerOrNull(stream.height),
|
|
212
|
+
nbFrames: readFrames ?? claimedFrames,
|
|
213
|
+
nbReadFrames: readFrames,
|
|
214
|
+
nbClaimedFrames: claimedFrames,
|
|
215
|
+
codec,
|
|
216
|
+
path: input.path,
|
|
217
|
+
raw: { stream, format: parsed.format ?? null },
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Resolve an external tool, preferring an absolute configured path.
|
|
223
|
+
*
|
|
224
|
+
* @param {import('@deepseek-ai/cordis').Context} ctx
|
|
225
|
+
* @param {string} configured
|
|
226
|
+
* @param {string} label
|
|
227
|
+
* @param {string} code
|
|
228
|
+
* @returns {Promise<string>}
|
|
229
|
+
*/
|
|
230
|
+
async function resolveTool(ctx, configured, label, code) {
|
|
231
|
+
const subprocess = ctx.get('subprocess')
|
|
232
|
+
if (subprocess === undefined) {
|
|
233
|
+
throw new BlenderError(
|
|
234
|
+
BlenderErrorCode.RUNTIME_UNAVAILABLE,
|
|
235
|
+
`Encoding a delivery needs the \`subprocess\` service, which is not composed in this process, ` +
|
|
236
|
+
`so ${label} cannot be started.`,
|
|
237
|
+
)
|
|
238
|
+
}
|
|
239
|
+
try {
|
|
240
|
+
return await subprocess.resolveExecutable(configured, { PATH: process.env.PATH ?? '' })
|
|
241
|
+
} catch (cause) {
|
|
242
|
+
throw new BlenderError(
|
|
243
|
+
code,
|
|
244
|
+
`${label} could not be resolved from "${configured}". A delivery render needs it on PATH ` +
|
|
245
|
+
`(macOS: \`brew install ffmpeg\`) or an absolute path in the DeepBlend configuration.`,
|
|
246
|
+
{ cause },
|
|
247
|
+
)
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Run one subprocess to completion through `ctx.subprocess` (SPEC §9.2).
|
|
253
|
+
*
|
|
254
|
+
* The argv array is passed verbatim: no shell is involved, so nothing in a path
|
|
255
|
+
* can become a command.
|
|
256
|
+
*/
|
|
257
|
+
async function runTool(ctx, spec) {
|
|
258
|
+
const subprocess = ctx.get('subprocess')
|
|
259
|
+
const controller = new AbortController()
|
|
260
|
+
const timer = setTimeout(() => controller.abort(), spec.timeoutMs ?? ENCODE_TIMEOUT_MS)
|
|
261
|
+
let handle
|
|
262
|
+
try {
|
|
263
|
+
handle = subprocess.spawn({
|
|
264
|
+
argv: spec.argv,
|
|
265
|
+
cwd: spec.cwd ?? process.cwd(),
|
|
266
|
+
stdio: {
|
|
267
|
+
stdin: 'ignore',
|
|
268
|
+
stdout: { maxBytes: MAX_OUTPUT_BYTES, spill: { maxBytes: MAX_SPILL_BYTES } },
|
|
269
|
+
stderr: { maxBytes: MAX_OUTPUT_BYTES, spill: { maxBytes: MAX_SPILL_BYTES } },
|
|
270
|
+
},
|
|
271
|
+
graceMs: TERMINATE_GRACE_MS,
|
|
272
|
+
signal: controller.signal,
|
|
273
|
+
env: {
|
|
274
|
+
PATH: process.env.PATH ?? '',
|
|
275
|
+
HOME: process.env.HOME ?? '',
|
|
276
|
+
TMPDIR: process.env.TMPDIR ?? '',
|
|
277
|
+
},
|
|
278
|
+
})
|
|
279
|
+
} catch (cause) {
|
|
280
|
+
// A spawn that throws must not leave its deadline armed: the timer would keep
|
|
281
|
+
// the event loop alive for the full timeout after the caller has already seen
|
|
282
|
+
// the failure.
|
|
283
|
+
clearTimeout(timer)
|
|
284
|
+
throw cause
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
let outcome = null
|
|
288
|
+
let failure = null
|
|
289
|
+
try {
|
|
290
|
+
outcome = await handle.done
|
|
291
|
+
} catch (cause) {
|
|
292
|
+
failure = cause
|
|
293
|
+
} finally {
|
|
294
|
+
// Disarmed only once the process has actually exited. Clearing it right after
|
|
295
|
+
// `spawn` returned would cancel the deadline before the work started, so a
|
|
296
|
+
// hung ffmpeg would wait forever instead of being killed.
|
|
297
|
+
clearTimeout(timer)
|
|
298
|
+
}
|
|
299
|
+
const read = (reader) => {
|
|
300
|
+
if (reader === undefined || reader === null) return ''
|
|
301
|
+
try {
|
|
302
|
+
return reader.readFrom(0).text ?? ''
|
|
303
|
+
} catch {
|
|
304
|
+
return ''
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
return {
|
|
308
|
+
exitCode: outcome?.exitCode ?? null,
|
|
309
|
+
signal: outcome?.signal ?? null,
|
|
310
|
+
stdout: read(handle.collected?.stdout),
|
|
311
|
+
stderr: read(handle.collected?.stderr) || (failure !== null ? String(failure) : ''),
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function numberOrNull(value) {
|
|
316
|
+
const parsed = typeof value === 'string' ? Number.parseFloat(value) : value
|
|
317
|
+
return typeof parsed === 'number' && Number.isFinite(parsed) ? parsed : null
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function integerOrNull(value) {
|
|
321
|
+
const parsed = typeof value === 'string' ? Number.parseInt(value, 10) : value
|
|
322
|
+
return typeof parsed === 'number' && Number.isFinite(parsed) ? parsed : null
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** `"30/1"` -> 30, `"30000/1001"` -> 29.970029… */
|
|
326
|
+
function parseRational(value) {
|
|
327
|
+
if (typeof value !== 'string' || value.length === 0) return null
|
|
328
|
+
const parts = value.split('/')
|
|
329
|
+
const numerator = Number.parseFloat(parts[0])
|
|
330
|
+
const denominator = parts.length > 1 ? Number.parseFloat(parts[1]) : 1
|
|
331
|
+
if (!Number.isFinite(numerator) || !Number.isFinite(denominator) || denominator === 0) return null
|
|
332
|
+
return numerator / denominator
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Where a job's encoded video is written before it is published.
|
|
337
|
+
* @param {string} jobDirectory
|
|
338
|
+
* @param {string} jobId
|
|
339
|
+
*/
|
|
340
|
+
export function encodedPath(jobDirectory, jobId) {
|
|
341
|
+
return join(jobDirectory, 'encoded', `${jobId}.mp4`)
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** The expected frame file for one frame of a job. */
|
|
345
|
+
export { frameFileName }
|
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@deepblend/dsh-blender-host",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "DeepBlend Host service: the blenderStudio business facade over the BlenderRuntime seam.",
|
|
6
|
+
"main": "lib/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"default": "./lib/index.js"
|
|
10
|
+
},
|
|
11
|
+
"./paths": {
|
|
12
|
+
"default": "./lib/paths.js"
|
|
13
|
+
},
|
|
14
|
+
"./project-store": {
|
|
15
|
+
"default": "./lib/project-store.js"
|
|
16
|
+
},
|
|
17
|
+
"./revision-transaction": {
|
|
18
|
+
"default": "./lib/revision-transaction.js"
|
|
19
|
+
},
|
|
20
|
+
"./package.json": "./package.json"
|
|
21
|
+
},
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"@deepblend/dsh-blender-contracts": "0.1.0"
|
|
24
|
+
},
|
|
25
|
+
"license": "MIT",
|
|
26
|
+
"repository": {
|
|
27
|
+
"type": "git",
|
|
28
|
+
"url": "git+https://github.com/pearjelly/deep-blend.git"
|
|
29
|
+
},
|
|
30
|
+
"peerDependencies": {
|
|
31
|
+
"@deepseek-ai/cordis": ">=4.0.0 <5",
|
|
32
|
+
"@deepseek-ai/schemastery": ">=3.18.0 <4"
|
|
33
|
+
}
|
|
34
|
+
}
|