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