@deepblend/dsh-blender-host 0.2.20 → 0.3.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.
Files changed (36) hide show
  1. package/lib/artifact-writer.js +49 -0
  2. package/lib/asset-bundle.js +381 -0
  3. package/lib/asset-io.js +24 -0
  4. package/lib/asset-library.js +113 -0
  5. package/lib/asset-preview.js +139 -0
  6. package/lib/asset-uploads.js +328 -0
  7. package/lib/delivery-manifest.js +6 -1
  8. package/lib/diagnostic-preview.js +264 -0
  9. package/lib/frame-provenance.js +230 -0
  10. package/lib/index.js +1283 -544
  11. package/lib/obj-resources.js +89 -0
  12. package/lib/paths.js +12 -1
  13. package/lib/project-store.js +40 -24
  14. package/lib/project-writer.js +96 -0
  15. package/lib/recipe-catalog.js +128 -0
  16. package/lib/reference-image.js +186 -0
  17. package/lib/render-job-store.js +2 -2
  18. package/lib/revision-transaction.js +106 -43
  19. package/lib/video-encoder.js +71 -13
  20. package/package.json +8 -3
  21. package/recipes/glass-ceramic/LICENSE +21 -0
  22. package/recipes/glass-ceramic/preview.png +0 -0
  23. package/recipes/glass-ceramic/recipe.json +94 -0
  24. package/recipes/glass-ceramic/scene-spec.json +1407 -0
  25. package/recipes/glazed-cup/LICENSE +21 -0
  26. package/recipes/glazed-cup/preview.png +0 -0
  27. package/recipes/glazed-cup/recipe.json +93 -0
  28. package/recipes/glazed-cup/scene-spec.json +248 -0
  29. package/recipes/metal-lamp/LICENSE +21 -0
  30. package/recipes/metal-lamp/preview.png +0 -0
  31. package/recipes/metal-lamp/recipe.json +118 -0
  32. package/recipes/metal-lamp/scene-spec.json +2530 -0
  33. package/recipes/modular-speaker/LICENSE +21 -0
  34. package/recipes/modular-speaker/preview.png +0 -0
  35. package/recipes/modular-speaker/recipe.json +95 -0
  36. package/recipes/modular-speaker/scene-spec.json +6268 -0
@@ -42,6 +42,7 @@
42
42
  */
43
43
 
44
44
  import { existsSync, mkdirSync, readdirSync, renameSync, statSync } from 'node:fs'
45
+ import { randomUUID } from 'node:crypto'
45
46
  import { join } from 'node:path'
46
47
 
47
48
  import {
@@ -64,12 +65,14 @@ import {
64
65
  validateSceneSpec,
65
66
  warning,
66
67
  } from '@deepblend/dsh-blender-contracts'
68
+ import { verifyAssetBundle, verifyUnbundledGltfAsset, verifyUnbundledObjAsset } from './asset-bundle.js'
67
69
  import { GENESIS_REVISION, parseRevisionId } from './project-store.js'
68
70
  import {
69
71
  fileSha256,
70
72
  fileSize,
71
73
  isFile,
72
74
  publishDirectory,
75
+ resolveInside,
73
76
  removeTree,
74
77
  writeFileAtomic,
75
78
  writeJsonAtomic,
@@ -123,9 +126,8 @@ export function resolveIdempotencyKey(patch) {
123
126
  /**
124
127
  * The revision transaction runner.
125
128
  *
126
- * One instance per `blenderStudio` service; it holds no state of its own beyond
127
- * the stores and the runtime it was built with, so a second project cannot leak
128
- * into the first.
129
+ * Each mutation holds the store's project writer lease until the revision,
130
+ * project pointer and idempotency record have all been written.
129
131
  */
130
132
  export class RevisionTransaction {
131
133
  /**
@@ -148,14 +150,17 @@ export class RevisionTransaction {
148
150
  /**
149
151
  * Remove abandoned staging directories.
150
152
  *
151
- * Called before each transaction rather than on a timer: a staging directory
152
- * can only be abandoned by a process that died, and the next transaction is
153
- * exactly when we know we are alive and it is stale.
153
+ * Called only while owning the project writer lease. Once a dead writer has
154
+ * been recovered, its unpublished candidates can safely be discarded.
154
155
  *
155
156
  * @param {string} projectId
156
157
  * @param {string} [keep] - a staging directory to preserve (this transaction's).
157
158
  */
158
159
  sweepStaging(projectId, keep) {
160
+ return this.store.withProjectWrite(projectId, () => this._sweepStaging(projectId, keep))
161
+ }
162
+
163
+ _sweepStaging(projectId, keep) {
159
164
  const staging = join(this.store.projectDirectory(projectId), 'staging')
160
165
  if (!existsSync(staging)) return
161
166
  for (const entry of readdirSafe(staging)) {
@@ -184,6 +189,11 @@ export class RevisionTransaction {
184
189
  * @returns {Promise<{ projectId: string, record: object, revision: object, job: object, warnings: object[] }>}
185
190
  */
186
191
  async createProject(input) {
192
+ const projectId = input.projectId ?? this.store.allocateProjectId(String(input.title ?? '').trim())
193
+ return this.store.withProjectWrite(projectId, () => this._createProject({ ...input, projectId }))
194
+ }
195
+
196
+ async _createProject(input) {
187
197
  const title = String(input.title ?? '').trim()
188
198
  if (title.length === 0) {
189
199
  throw new BlenderError(BlenderErrorCode.PROJECT_ID_INVALID, 'A project needs a non-empty title.')
@@ -241,7 +251,7 @@ export class RevisionTransaction {
241
251
  projectsRoot: this.store.projectsRoot,
242
252
  })
243
253
 
244
- const outcome = await this.commit({
254
+ const outcome = await this._commit({
245
255
  projectId,
246
256
  baseRevision: GENESIS_REVISION,
247
257
  operations: [],
@@ -249,6 +259,7 @@ export class RevisionTransaction {
249
259
  specHashBefore: null,
250
260
  specHashAfter: specHash(compiled.spec),
251
261
  kind: 'project_create',
262
+ recipeLock: input.recipeLock ?? null,
252
263
  summary: `Initialise project "${title}"`,
253
264
  actor: input.actor ?? null,
254
265
  stage: 'BRIEF',
@@ -280,6 +291,10 @@ export class RevisionTransaction {
280
291
  )
281
292
  }
282
293
 
294
+ return this.store.withProjectWrite(patch.projectId, () => this._applyScenePatch(patch, options))
295
+ }
296
+
297
+ async _applyScenePatch(patch, options) {
283
298
  const projectId = patch.projectId
284
299
  const record = this.store.readRecord(projectId)
285
300
  const { key, derived } = resolveIdempotencyKey(patch)
@@ -362,34 +377,23 @@ export class RevisionTransaction {
362
377
  )
363
378
  }
364
379
 
365
- // ---- a declared asset hash must be the file's hash ---------------------
366
- //
367
- // `asset.add` carries a `sha256` the patch APPLIER cannot check: it is a pure function over documents with no
368
- // filesystem, which is the right shape for it. But the hash it stores is provenance a later reader trusts —
369
- // the delivery manifest, a human auditing where the bytes came from — and `blender_asset_ingest` COMPUTES the
370
- // hash of what it wrote, so the two copies can disagree with nothing comparing them. MEASURED before this:
371
- // nothing did, on either side, and the Python never reads `assets[].sha256` at all.
372
- //
373
- // Checked here, in the transaction, because this is the layer that has both the document and the store. A
374
- // path with no file is left alone: declaring an asset before its bytes exist is a different question, and the
375
- // compiler refuses that one loudly.
376
- for (const operation of patch.operations ?? []) {
377
- if (operation?.op !== 'asset.add') continue
378
- const declared = operation.asset?.sha256
379
- const assetPath = operation.asset?.path
380
- if (typeof declared !== 'string' || typeof assetPath !== 'string') continue
381
- const absolute = join(this.store.projectDirectory(projectId), assetPath)
382
- if (!existsSync(absolute)) continue
383
- const actual = fileSha256(absolute)
384
- if (actual !== declared) {
385
- throw new BlenderError(
386
- BlenderErrorCode.ASSET_HASH_MISMATCH,
387
- `asset "${operation.asset.id}" declares sha256 ${declared}, but ${assetPath} is ${actual}. Nothing was ` +
388
- 'committed. The declaration is provenance a later reader trusts, so it has to be the file\u2019s own ' +
389
- 'hash — blender_asset_ingest reports the right one for what it wrote.',
390
- { detail: { projectId, assetId: operation.asset.id, path: assetPath, declared, actual } },
391
- )
380
+ // Verify every declared dependency on every patch, including unchanged assets.
381
+ // A later material/camera edit must not silently compile changed source bytes.
382
+ for (const asset of nextSpec.assets ?? []) {
383
+ if (typeof asset.path !== 'string') continue
384
+ verifyAssetBundle(this.store.projectDirectory(projectId), asset)
385
+ const pathHash = /^assets\/raw\/([a-f0-9]{64})\.[a-z0-9]+$/.exec(asset.path)?.[1]
386
+ const declared = pathHash ?? asset.sha256
387
+ const absolute = resolveInside(this.store.projectDirectory(projectId), asset.path, 'scene asset')
388
+ if (!existsSync(absolute)) continue // Missing declarations are diagnosed when compiled.
389
+ const actual = typeof declared === 'string' ? fileSha256(absolute) : null
390
+ if (typeof declared === 'string' && (actual !== declared || (asset.sha256 && asset.sha256 !== declared))) {
391
+ throw new BlenderError(BlenderErrorCode.ASSET_HASH_MISMATCH,
392
+ `asset "${asset.id}" declares sha256 ${asset.sha256 ?? declared}, but ${asset.path} is ${actual}. Nothing was committed.`,
393
+ { detail: { projectId, assetId: asset.id, path: asset.path, declared: asset.sha256 ?? declared, pathHash, actual } })
392
394
  }
395
+ verifyUnbundledGltfAsset(this.store.projectDirectory(projectId), asset)
396
+ verifyUnbundledObjAsset(this.store.projectDirectory(projectId), asset)
393
397
  }
394
398
 
395
399
  // ---- resolve the RESULT before anything reads it -----------------------
@@ -431,7 +435,7 @@ export class RevisionTransaction {
431
435
  }
432
436
 
433
437
  const summary = patch.note ?? describeOperations(operationRecords)
434
- const outcome = await this.commit({
438
+ const outcome = await this._commit({
435
439
  projectId,
436
440
  baseRevision: patch.baseRevision,
437
441
  operations: patch.operations,
@@ -480,16 +484,35 @@ export class RevisionTransaction {
480
484
  * @returns {Promise<{ revision: object, job: object, warnings: object[], sceneSummary: object }>}
481
485
  */
482
486
  async commit(plan) {
487
+ return this.store.withProjectWrite(plan.projectId, () => this._commit(plan))
488
+ }
489
+
490
+ async _commit(plan) {
483
491
  const { projectId } = plan
484
492
  const record = this.store.readRecord(projectId)
493
+ if (plan.baseRevision !== record.currentRevision) {
494
+ throw new BlenderError(BlenderErrorCode.REVISION_CONFLICT,
495
+ `The commit was computed against ${plan.baseRevision}, but "${projectId}" is at ${record.currentRevision}.`,
496
+ { detail: { projectId, baseRevision: plan.baseRevision, currentRevision: record.currentRevision } })
497
+ }
485
498
  const revision = this.store.nextRevisionId(projectId)
486
499
  const revisionNumber = /** @type {number} */ (parseRevisionId(revision))
487
500
 
488
- this.sweepStaging(projectId)
489
- const staging = join(this.store.projectDirectory(projectId), 'staging', revision)
490
- removeTree(staging)
501
+ this._sweepStaging(projectId)
502
+ const staging = join(this.store.projectDirectory(projectId), 'staging', `${revision}-${randomUUID()}`)
491
503
  mkdirSync(staging, { recursive: true })
492
504
 
505
+ try {
506
+ return await this._commitStaged({ plan, record, revision, revisionNumber, staging })
507
+ } finally {
508
+ // Publication moved this directory; every unsuccessful path removes only
509
+ // this transaction's private staging, never another candidate's files.
510
+ removeTree(staging)
511
+ }
512
+ }
513
+
514
+ async _commitStaged({ plan, record, revision, revisionNumber, staging }) {
515
+ const { projectId } = plan
493
516
  const jobId = plan.jobId ?? this.store.allocateJobId(projectId, plan.kind === 'project_create' ? 'compile_scene' : 'apply_scene_patch')
494
517
  const startedAt = new Date().toISOString()
495
518
  const startedMs = Date.now()
@@ -725,6 +748,7 @@ export class RevisionTransaction {
725
748
  actor: plan.actor ?? null,
726
749
  stage: plan.stage ?? null,
727
750
  note: plan.note ?? null,
751
+ ...(plan.recipeLock ? { recipe: { id: plan.recipeLock.id, version: plan.recipeLock.version, packageDigest: plan.recipeLock.packageDigest, lockPath: 'recipe-lock.json' } } : {}),
728
752
  sceneSpecVersion: plan.spec.schemaVersion,
729
753
  counts: sceneSummary.counts,
730
754
  subjectBounds: sceneSummary.subjectBounds,
@@ -743,6 +767,12 @@ export class RevisionTransaction {
743
767
  },
744
768
  renderConfig: compileReport?.renderConfig ?? null,
745
769
  sceneFingerprint: compileReport?.sceneFingerprint ?? null,
770
+ assetParts: (compileReport?.objects ?? [])
771
+ .filter(object => object.type === 'MESH' && typeof object.partId === 'string' && object.partId.length > 0)
772
+ .map(({ deepblendId, partId, parentPartId, sourceMaterialSlots, materialSlots }) => ({
773
+ entityId: deepblendId, partId, parentPartId: parentPartId ?? null,
774
+ sourceMaterialSlots: sourceMaterialSlots ?? [], materialSlots: materialSlots ?? [],
775
+ })),
746
776
  checkpoint: isFile(join(staging, 'scene.blend')) ? `revisions/${revision}/scene.blend` : null,
747
777
  previews: previews.map(entry => entry.artifact),
748
778
  jobId,
@@ -794,6 +824,8 @@ export class RevisionTransaction {
794
824
  })
795
825
  }
796
826
 
827
+ if (plan.recipeLock) writeJsonAtomic(join(staging, 'recipe-lock.json'), plan.recipeLock)
828
+
797
829
  writeJsonAtomic(join(staging, 'validation.json'), {
798
830
  schemaVersion: 'deepblend.validation/v1',
799
831
  revision,
@@ -807,6 +839,25 @@ export class RevisionTransaction {
807
839
  writeJsonAtomic(join(staging, 'revision-manifest.json'), manifest)
808
840
 
809
841
  // ---- publish -----------------------------------------------------------
842
+ // The lease coordinates all supported writers; this second check also
843
+ // catches an external pointer/record edit while Blender was running. Compare
844
+ // the whole record so a restore away and back cannot hide behind the same id.
845
+ try {
846
+ this.store.assertProjectWrite(projectId)
847
+ const current = this.store.readRecord(projectId)
848
+ if (JSON.stringify(current) !== JSON.stringify(record)) {
849
+ throw new BlenderError(BlenderErrorCode.REVISION_CONFLICT,
850
+ `Project "${projectId}" changed while ${revision} was compiling; current revision is ${current.currentRevision}. ` +
851
+ 'The candidate was not published. Read the scene again before retrying.',
852
+ { detail: { projectId, baseRevision: record.currentRevision, currentRevision: current.currentRevision } })
853
+ }
854
+ } catch (cause) {
855
+ removeTree(staging)
856
+ const failure = toCanonicalFailure(cause, { isBlenderError: value => value instanceof BlenderError, fallbackCode: 'REVISION_CONFLICT' })
857
+ const job = writeJob({ status: 'failed', revision: null, errorCode: failure.code, message: failure.message })
858
+ this.recordFailedAttempt(projectId, { revision, baseRevision: plan.baseRevision, job, failure, plan })
859
+ throw cause
860
+ }
810
861
  const finalPath = this.store.revisionDirectory(projectId, revision)
811
862
  publishDirectory(staging, finalPath)
812
863
 
@@ -863,6 +914,11 @@ export class RevisionTransaction {
863
914
  const filename = `${cameraId}.png`
864
915
  const outputPath = join(previewsDir, filename)
865
916
 
917
+ const ceiling = Math.min(profile.maxSamplesBudget ?? this.config.maxPreviewSamples ?? 512, this.config.maxPreviewSamples ?? 512)
918
+ const samples = Math.min(profile.samples ?? ceiling, ceiling)
919
+ if (samples < profile.samples) input.warnings?.push(warning(BlenderWarningCode.RENDER_SAMPLES_REDUCED,
920
+ `preview samples reduced from ${profile.samples} to ${samples} by the configured budget`,
921
+ { requested: profile.samples, used: samples }))
866
922
  const run = await this.runtime.renderPreview({
867
923
  checkpointPath: input.checkpointPath,
868
924
  outputPath,
@@ -870,7 +926,7 @@ export class RevisionTransaction {
870
926
  engine: engineInfo.blenderEngine === null ? undefined : profile.engine,
871
927
  width: profile.resolution?.[0],
872
928
  height: profile.resolution?.[1],
873
- samples: profile.samples,
929
+ samples,
874
930
  jobId: input.jobId,
875
931
  signal: input.signal,
876
932
  })
@@ -894,12 +950,19 @@ export class RevisionTransaction {
894
950
  artifact: {
895
951
  kind: 'preview',
896
952
  path: `revisions/${revision}/previews/${filename}`,
897
- cameraId,
953
+ sourceRevision: revision,
954
+ sourceDigest: sceneSpecDigest(spec),
955
+ at: new Date().toISOString(),
956
+ cameraId: report.cameraId ?? null,
898
957
  frame: report.frame ?? null,
899
958
  width: report.width ?? null,
900
959
  height: report.height ?? null,
901
- engine: report.engine ?? profile.engine,
902
- samples: report.renderConfig?.samples ?? profile.samples ?? null,
960
+ engine: report.engine ?? null,
961
+ samples: report.renderConfig?.samples ?? null,
962
+ // Measured renderer settings, not the requested SceneSpec profile. Older
963
+ // providers may omit this; a comparison must then treat it as unknown.
964
+ renderConfig: report.renderConfig ? structuredClone(report.renderConfig) : null,
965
+ cameraFacts: report.cameraFacts ? structuredClone(report.cameraFacts) : null,
903
966
  bytes,
904
967
  sha256: fileSha256(outputPath),
905
968
  mime: 'image/png',
@@ -1048,7 +1111,7 @@ export function defaultSceneSpec({ projectId, title, goal }) {
1048
1111
  resolution: [640, 360],
1049
1112
  samples: 48,
1050
1113
  filmTransparent: false,
1051
- colorManagement: { viewTransform: 'Standard' },
1114
+ colorManagement: { viewTransform: 'AgX' },
1052
1115
  maxSamplesBudget: 256,
1053
1116
  },
1054
1117
  final: {
@@ -65,10 +65,11 @@ const MAX_SPILL_BYTES = 32 * 1024 * 1024
65
65
  * @param {string} [input.filePrefix]
66
66
  * @param {number} [input.filePadding]
67
67
  * @param {string} [input.crf]
68
+ * @param {AbortSignal} [input.signal]
68
69
  * @returns {Promise<{outputPath: string, bytes: number, argv: string[], durationMs: number, stderr: string}>}
69
70
  */
70
71
  export async function encodeFrameSequence(input) {
71
- const executable = await resolveTool(input.ctx, input.ffmpegPath, 'ffmpeg', BlenderErrorCode.ENCODER_NOT_FOUND)
72
+ const executable = await resolveTool(input.ctx, input.ffmpegPath, 'ffmpeg', BlenderErrorCode.ENCODER_NOT_FOUND, input.signal)
72
73
  const prefix = input.filePrefix ?? 'frame_'
73
74
  const padding = input.filePadding ?? 4
74
75
  // ffmpeg's pattern syntax is `%0Nd`; it is derived from the job's own naming so
@@ -113,10 +114,15 @@ export async function encodeFrameSequence(input) {
113
114
  argv,
114
115
  cwd: input.framesDirectory,
115
116
  timeoutMs: input.timeoutMs ?? ENCODE_TIMEOUT_MS,
117
+ signal: input.signal,
118
+ label: 'ffmpeg',
119
+ failureCode: BlenderErrorCode.ENCODE_FAILED,
116
120
  })
117
121
  const durationMs = Date.now() - startedAt
118
122
 
119
- if (run.exitCode !== 0 && !existsSync(input.outputPath)) {
123
+ // This path may hold a previous attempt. Only a confirmed successful command
124
+ // can supply a new encode; nonzero or unknown outcomes cannot reuse old bytes.
125
+ if (run.exitCode !== 0) {
120
126
  throw new BlenderError(
121
127
  BlenderErrorCode.ENCODE_FAILED,
122
128
  `ffmpeg failed to encode ${input.frameCount} frame(s) from ${pattern}: ${run.stderr.slice(-2000) || `exit ${run.exitCode}`}`,
@@ -148,11 +154,12 @@ export async function encodeFrameSequence(input) {
148
154
  * @param {import('@deepseek-ai/cordis').Context} input.ctx
149
155
  * @param {string} input.ffprobePath
150
156
  * @param {string} input.path
157
+ * @param {AbortSignal} [input.signal]
151
158
  * @returns {Promise<{durationSeconds: number|null, fps: number|null, width: number|null,
152
159
  * height: number|null, nbFrames: number|null, codec: string|null, raw: object}>}
153
160
  */
154
161
  export async function probeVideo(input) {
155
- const executable = await resolveTool(input.ctx, input.ffprobePath, 'ffprobe', BlenderErrorCode.PROBE_FAILED)
162
+ const executable = await resolveTool(input.ctx, input.ffprobePath, 'ffprobe', BlenderErrorCode.PROBE_FAILED, input.signal)
156
163
  const argv = [
157
164
  executable,
158
165
  '-hide_banner',
@@ -168,6 +175,9 @@ export async function probeVideo(input) {
168
175
  argv,
169
176
  cwd: input.cwd ?? undefined,
170
177
  timeoutMs: input.timeoutMs ?? PROBE_TIMEOUT_MS,
178
+ signal: input.signal,
179
+ label: 'ffprobe',
180
+ failureCode: BlenderErrorCode.PROBE_FAILED,
171
181
  })
172
182
  if (run.exitCode !== 0) {
173
183
  throw new BlenderError(
@@ -225,9 +235,11 @@ export async function probeVideo(input) {
225
235
  * @param {string} configured
226
236
  * @param {string} label
227
237
  * @param {string} code
238
+ * @param {AbortSignal} [signal]
228
239
  * @returns {Promise<string>}
229
240
  */
230
- async function resolveTool(ctx, configured, label, code) {
241
+ async function resolveTool(ctx, configured, label, code, signal) {
242
+ throwIfCancelled(signal, label)
231
243
  const subprocess = ctx.get('subprocess')
232
244
  if (subprocess === undefined) {
233
245
  throw new BlenderError(
@@ -237,8 +249,11 @@ async function resolveTool(ctx, configured, label, code) {
237
249
  )
238
250
  }
239
251
  try {
240
- return await subprocess.resolveExecutable(configured, { PATH: process.env.PATH ?? '' })
252
+ const executable = await subprocess.resolveExecutable(configured, { PATH: process.env.PATH ?? '' }, signal)
253
+ throwIfCancelled(signal, label)
254
+ return executable
241
255
  } catch (cause) {
256
+ throwIfCancelled(signal, label)
242
257
  throw new BlenderError(
243
258
  code,
244
259
  `${label} could not be resolved from "${configured}". A delivery render needs it on PATH ` +
@@ -248,6 +263,12 @@ async function resolveTool(ctx, configured, label, code) {
248
263
  }
249
264
  }
250
265
 
266
+ function throwIfCancelled(signal, label) {
267
+ if (signal?.aborted) {
268
+ throw new BlenderError(BlenderErrorCode.ABORTED, `${label} was cancelled by the caller.`, { cause: signal.reason })
269
+ }
270
+ }
271
+
251
272
  /**
252
273
  * Run one subprocess to completion through `ctx.subprocess` (SPEC §9.2).
253
274
  *
@@ -255,9 +276,22 @@ async function resolveTool(ctx, configured, label, code) {
255
276
  * can become a command.
256
277
  */
257
278
  async function runTool(ctx, spec) {
279
+ throwIfCancelled(spec.signal, spec.label)
258
280
  const subprocess = ctx.get('subprocess')
259
281
  const controller = new AbortController()
260
- const timer = setTimeout(() => controller.abort(), spec.timeoutMs ?? ENCODE_TIMEOUT_MS)
282
+ const timeoutMs = spec.timeoutMs ?? ENCODE_TIMEOUT_MS
283
+ const timeoutReason = new Error(`${spec.label} exceeded its ${timeoutMs} ms deadline.`)
284
+ const signal = spec.signal ? AbortSignal.any([spec.signal, controller.signal]) : controller.signal
285
+ const timer = setTimeout(() => controller.abort(timeoutReason), timeoutMs)
286
+ const throwIfStopped = (detail = {}) => {
287
+ if (!signal.aborted) return
288
+ const timedOut = signal.reason === timeoutReason
289
+ throw new BlenderError(
290
+ timedOut ? BlenderErrorCode.TIMEOUT : BlenderErrorCode.ABORTED,
291
+ timedOut ? timeoutReason.message : `${spec.label} was cancelled by the caller.`,
292
+ { cause: signal.reason, detail },
293
+ )
294
+ }
261
295
  let handle
262
296
  try {
263
297
  handle = subprocess.spawn({
@@ -269,7 +303,7 @@ async function runTool(ctx, spec) {
269
303
  stderr: { maxBytes: MAX_OUTPUT_BYTES, spill: { maxBytes: MAX_SPILL_BYTES } },
270
304
  },
271
305
  graceMs: TERMINATE_GRACE_MS,
272
- signal: controller.signal,
306
+ signal,
273
307
  env: {
274
308
  PATH: process.env.PATH ?? '',
275
309
  HOME: process.env.HOME ?? '',
@@ -281,21 +315,45 @@ async function runTool(ctx, spec) {
281
315
  // the event loop alive for the full timeout after the caller has already seen
282
316
  // the failure.
283
317
  clearTimeout(timer)
318
+ throwIfStopped()
284
319
  throw cause
285
320
  }
286
321
 
287
322
  let outcome = null
288
323
  let failure = null
324
+ let rangeFailure = null
325
+ let processGone = null
289
326
  try {
290
- outcome = await handle.done
291
- } catch (cause) {
292
- failure = cause
327
+ // Reap the direct command before observing the managed range. An early
328
+ // range query can race a provider's scope establishment. A rejected command
329
+ // still needs range cleanup, and the same deadline covers both observations.
330
+ const [direct] = await Promise.allSettled([handle.done])
331
+ const [range] = await Promise.allSettled([
332
+ typeof handle.waitForExit === 'function'
333
+ ? Promise.resolve().then(() => handle.waitForExit())
334
+ : Promise.resolve(null),
335
+ ])
336
+ if (direct.status === 'fulfilled') outcome = direct.value
337
+ else failure = direct.reason
338
+ if (range.status === 'fulfilled') {
339
+ processGone = range.value
340
+ if (processGone === false) rangeFailure = new Error('the managed process range has not exited')
341
+ } else {
342
+ processGone = false
343
+ rangeFailure = range.reason
344
+ }
293
345
  } 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.
346
+ // A surviving descendant still needs the deadline after `done` settles.
297
347
  clearTimeout(timer)
298
348
  }
349
+ // Classify cancellation before looking at an existing MP4 or successful probe
350
+ // JSON. Neither is evidence that this invocation completed without cancellation.
351
+ throwIfStopped({ processGone, ...(rangeFailure === null ? {} : { cleanupError: String(rangeFailure) }) })
352
+ if (rangeFailure !== null) {
353
+ throw new BlenderError(spec.failureCode, `${spec.label} process cleanup could not be confirmed.`, {
354
+ cause: rangeFailure, detail: { processGone: false },
355
+ })
356
+ }
299
357
  const read = (reader) => {
300
358
  if (reader === undefined || reader === null) return ''
301
359
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deepblend/dsh-blender-host",
3
- "version": "0.2.20",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "description": "DeepBlend Host service: the blenderStudio business facade over the BlenderRuntime seam.",
6
6
  "main": "lib/index.js",
@@ -20,7 +20,8 @@
20
20
  "./package.json": "./package.json"
21
21
  },
22
22
  "dependencies": {
23
- "@deepblend/dsh-blender-contracts": "0.2.20"
23
+ "@deepblend/dsh-blender-contracts": "0.3.0",
24
+ "sharp": "0.35.5"
24
25
  },
25
26
  "license": "MIT",
26
27
  "repository": {
@@ -30,5 +31,9 @@
30
31
  "peerDependencies": {
31
32
  "@deepseek-ai/cordis": ">=4.0.0 <5",
32
33
  "@deepseek-ai/schemastery": ">=3.18.0 <4"
33
- }
34
+ },
35
+ "files": [
36
+ "lib",
37
+ "recipes"
38
+ ]
34
39
  }
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 DeepBlend Studio contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,94 @@
1
+ {
2
+ "schemaVersion": "deepblend.recipe/v1",
3
+ "id": "deepblend.glass-ceramic",
4
+ "version": "1.0.0",
5
+ "title": "玻璃空瓶与青釉托盘",
6
+ "description": "带厚底、内壁和圆润瓶肩的透明空瓶,搭配独立瓶盖与有脚圈的青釉托盘。只调整青釉颜色、粗糙度和摄影曝光,玻璃厚壁保持不变。",
7
+ "author": {
8
+ "name": "DeepBlend contributors",
9
+ "url": "https://github.com/pearjelly/deep-blend"
10
+ },
11
+ "license": "MIT",
12
+ "source": {
13
+ "url": "https://github.com/pearjelly/deep-blend",
14
+ "note": "Original glass-ceramic product benchmark. The bundled SceneSpec and rendered preview are pinned by their raw content hashes; source URL is attribution, not an installation endpoint."
15
+ },
16
+ "compatibility": {
17
+ "sceneSchemaVersion": "deepblend.scene/v1",
18
+ "capabilities": [
19
+ "animation.transform",
20
+ "geometry.lathe",
21
+ "geometry.primitive",
22
+ "material.glass",
23
+ "material.principled",
24
+ "material.procedural"
25
+ ]
26
+ },
27
+ "input": {
28
+ "path": "scene-spec.json",
29
+ "sha256": "c9c6d7b6aaecfef62db28d862d632df4b591448f05dcad6b58b39cc66b97fac9"
30
+ },
31
+ "preview": {
32
+ "path": "preview.png",
33
+ "sha256": "fecf9682091b4878fdbe72632d7d2600f9f7fba8d718849195e8094f36093c8b",
34
+ "mediaType": "image/png",
35
+ "alt": "玻璃空瓶与青釉托盘默认参数的实际Blender渲染"
36
+ },
37
+ "parameters": [
38
+ {
39
+ "id": "main-color",
40
+ "title": "陶瓷釉色",
41
+ "description": "同步调整陶瓷托盘和瓶盖饰片的釉色。",
42
+ "type": "color",
43
+ "default": [
44
+ 0.18,
45
+ 0.3,
46
+ 0.265
47
+ ],
48
+ "bindings": [
49
+ {
50
+ "kind": "material",
51
+ "materialId": "celadon-glaze",
52
+ "property": "baseColor"
53
+ }
54
+ ]
55
+ },
56
+ {
57
+ "id": "surface-roughness",
58
+ "title": "主表面粗糙度",
59
+ "description": "调整陶瓷釉面的光泽;数值越小,表面越亮。",
60
+ "type": "number",
61
+ "default": 0.2,
62
+ "minimum": 0.12,
63
+ "maximum": 0.42,
64
+ "bindings": [
65
+ {
66
+ "kind": "material",
67
+ "materialId": "celadon-glaze",
68
+ "property": "roughness"
69
+ }
70
+ ]
71
+ },
72
+ {
73
+ "id": "exposure",
74
+ "title": "摄影曝光",
75
+ "description": "调整整体画面的明暗,预览和成品同步生效。",
76
+ "type": "number",
77
+ "default": 0,
78
+ "minimum": -1,
79
+ "maximum": 1,
80
+ "bindings": [
81
+ {
82
+ "kind": "render-profile",
83
+ "profile": "preview",
84
+ "property": "exposure"
85
+ },
86
+ {
87
+ "kind": "render-profile",
88
+ "profile": "final",
89
+ "property": "exposure"
90
+ }
91
+ ]
92
+ }
93
+ ]
94
+ }