@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.
- package/lib/artifact-writer.js +49 -0
- package/lib/asset-bundle.js +381 -0
- package/lib/asset-io.js +24 -0
- package/lib/asset-library.js +113 -0
- package/lib/asset-preview.js +139 -0
- package/lib/asset-uploads.js +328 -0
- package/lib/delivery-manifest.js +6 -1
- package/lib/diagnostic-preview.js +264 -0
- package/lib/frame-provenance.js +230 -0
- package/lib/index.js +1283 -544
- package/lib/obj-resources.js +89 -0
- package/lib/paths.js +12 -1
- package/lib/project-store.js +40 -24
- package/lib/project-writer.js +96 -0
- package/lib/recipe-catalog.js +128 -0
- package/lib/reference-image.js +186 -0
- package/lib/render-job-store.js +2 -2
- package/lib/revision-transaction.js +106 -43
- package/lib/video-encoder.js +71 -13
- package/package.json +8 -3
- package/recipes/glass-ceramic/LICENSE +21 -0
- package/recipes/glass-ceramic/preview.png +0 -0
- package/recipes/glass-ceramic/recipe.json +94 -0
- package/recipes/glass-ceramic/scene-spec.json +1407 -0
- package/recipes/glazed-cup/LICENSE +21 -0
- package/recipes/glazed-cup/preview.png +0 -0
- package/recipes/glazed-cup/recipe.json +93 -0
- package/recipes/glazed-cup/scene-spec.json +248 -0
- package/recipes/metal-lamp/LICENSE +21 -0
- package/recipes/metal-lamp/preview.png +0 -0
- package/recipes/metal-lamp/recipe.json +118 -0
- package/recipes/metal-lamp/scene-spec.json +2530 -0
- package/recipes/modular-speaker/LICENSE +21 -0
- package/recipes/modular-speaker/preview.png +0 -0
- package/recipes/modular-speaker/recipe.json +95 -0
- 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
|
-
*
|
|
127
|
-
*
|
|
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
|
|
152
|
-
*
|
|
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.
|
|
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
|
-
//
|
|
366
|
-
//
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
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.
|
|
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.
|
|
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
|
|
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
|
-
|
|
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 ??
|
|
902
|
-
samples: report.renderConfig?.samples ??
|
|
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: '
|
|
1114
|
+
colorManagement: { viewTransform: 'AgX' },
|
|
1052
1115
|
maxSamplesBudget: 256,
|
|
1053
1116
|
},
|
|
1054
1117
|
final: {
|
package/lib/video-encoder.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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
|
-
//
|
|
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.
|
|
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.
|
|
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.
|
|
Binary file
|
|
@@ -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
|
+
}
|