@deepblend/dsh-blender-host 0.2.19 → 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
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
/** Isolated inspection renders. Successful tasks are published with one directory rename. */
|
|
2
|
+
import { createReadStream, copyFileSync, existsSync, mkdirSync, readdirSync, renameSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { dirname, join } from 'node:path'
|
|
4
|
+
import { randomUUID } from 'node:crypto'
|
|
5
|
+
import { BlenderError, BlenderErrorCode, BLENDER_ENGINE_BY_KEY, JOB_RECORD_VERSION, compileSceneSpec, sceneSpecDigest,
|
|
6
|
+
sha256, specHash, validateSceneSpec, toCanonicalJobRecord } from '@deepblend/dsh-blender-contracts'
|
|
7
|
+
import { streamAsset } from './asset-io.js'
|
|
8
|
+
import { stageAssetBundle, assetPreviewVersion, readGltfDocument, verifyAssetBundle, verifyUnbundledGltfAsset, verifyUnbundledObjAsset } from './asset-bundle.js'
|
|
9
|
+
import { fileSha256, fileSize, isFile, readJsonSafe, removeTree, resolveInside, writeJsonAtomic } from './paths.js'
|
|
10
|
+
|
|
11
|
+
export const DIAGNOSTIC_VERSION = 'deepblend.diagnostic/v1'
|
|
12
|
+
export const CLAY_TRANSFORM = Object.freeze({ id: 'opaque-clay', version: 1,
|
|
13
|
+
scope: 'non-environment', baseColor: Object.freeze([.35, .35, .35, 1]), roughness: .62 })
|
|
14
|
+
export const DIAGNOSTIC_LIMITS = Object.freeze({ maxViews: 8, maxEdge: 2048, maxPngBytes: 32 * 1024 * 1024 })
|
|
15
|
+
|
|
16
|
+
function refuse(message, code = BlenderErrorCode.RENDER_RANGE_INVALID) {
|
|
17
|
+
throw new BlenderError(code, message)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Preserve geometry, animation and lighting declarations; never change the input document. */
|
|
21
|
+
export function diagnosticSpec(source, mode) {
|
|
22
|
+
if (!['beauty', 'clay'].includes(mode)) refuse('Inspection mode must be beauty or clay.')
|
|
23
|
+
if (source.entities.some(entity => ['rigidBody', 'cloth', 'softBody', 'fluid'].some(key => entity[key] != null))) {
|
|
24
|
+
refuse('Inspection cannot rebuild cached physics faithfully. Use the existing checkpoint preview for this scene.', BlenderErrorCode.UNSUPPORTED_ACTION)
|
|
25
|
+
}
|
|
26
|
+
const spec = structuredClone(source)
|
|
27
|
+
if (mode === 'clay') {
|
|
28
|
+
const ids = new Set((spec.materials ?? []).map(material => material.id))
|
|
29
|
+
let id = 'inspection-clay', suffix = 1
|
|
30
|
+
while (ids.has(id)) id = `inspection-clay-${suffix++}`
|
|
31
|
+
spec.materials ??= []
|
|
32
|
+
spec.materials.push({ id, shader: 'principled', parameters: {
|
|
33
|
+
baseColor: [...CLAY_TRANSFORM.baseColor], roughness: CLAY_TRANSFORM.roughness,
|
|
34
|
+
} })
|
|
35
|
+
for (const entity of spec.entities) if (entity.type !== 'empty' && !entity.tags?.includes('environment')) {
|
|
36
|
+
entity.materialId = id
|
|
37
|
+
delete entity.materialBindings
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return compileSceneSpec(spec).spec
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function inspectionPlan(spec, request, maxSamples) {
|
|
44
|
+
const profile = spec.renderProfiles?.preview
|
|
45
|
+
if (!profile) refuse('This revision has no preview render profile.', BlenderErrorCode.RENDER_PROFILE_MISSING)
|
|
46
|
+
const views = request.views
|
|
47
|
+
if (!Array.isArray(views) || views.length < 1 || views.length > DIAGNOSTIC_LIMITS.maxViews) {
|
|
48
|
+
refuse(`An inspection needs 1–${DIAGNOSTIC_LIMITS.maxViews} explicit camera/frame views.`)
|
|
49
|
+
}
|
|
50
|
+
const ids = new Set(), cameras = new Set(spec.cameras.map(camera => camera.id))
|
|
51
|
+
const plan = views.map(view => {
|
|
52
|
+
if (!view || !/^[a-zA-Z][a-zA-Z0-9_-]{0,63}$/.test(view.id ?? '') || ids.has(view.id)) refuse('Inspection view IDs must be unique safe names.')
|
|
53
|
+
ids.add(view.id)
|
|
54
|
+
if (!cameras.has(view.cameraId)) refuse(`Unknown inspection camera: ${view.cameraId}.`)
|
|
55
|
+
if (!Number.isInteger(view.frame) || view.frame < spec.project.frameStart || view.frame > spec.project.frameEnd) {
|
|
56
|
+
refuse(`Inspection frames must be integers from ${spec.project.frameStart} to ${spec.project.frameEnd}.`)
|
|
57
|
+
}
|
|
58
|
+
return { id: view.id, cameraId: view.cameraId, frame: view.frame }
|
|
59
|
+
})
|
|
60
|
+
const width = request.width ?? profile.resolution?.[0], height = request.height ?? profile.resolution?.[1]
|
|
61
|
+
if (![width, height].every(value => Number.isInteger(value) && value > 0 && value <= DIAGNOSTIC_LIMITS.maxEdge)) {
|
|
62
|
+
refuse(`Inspection resolution must be between 1 and ${DIAGNOSTIC_LIMITS.maxEdge} pixels per edge.`, BlenderErrorCode.RENDER_BUDGET_EXCEEDED)
|
|
63
|
+
}
|
|
64
|
+
const engine = request.engine ?? profile.engine
|
|
65
|
+
if (!['cycles', 'eevee'].includes(engine)) refuse('Material and clay inspections require a Cycles or EEVEE preview profile.', BlenderErrorCode.UNSUPPORTED_ACTION)
|
|
66
|
+
const samples = request.samples ?? profile.samples ?? 16
|
|
67
|
+
if (!Number.isInteger(samples) || samples < 1) refuse('Inspection samples must be a positive integer.')
|
|
68
|
+
const effectiveSamples = Math.min(samples, maxSamples, profile.maxSamplesBudget ?? maxSamples)
|
|
69
|
+
const warnings = samples > effectiveSamples ? [{ code: 'RENDER_SAMPLES_REDUCED',
|
|
70
|
+
message: `Inspection samples reduced from ${samples} to ${effectiveSamples}.` }] : []
|
|
71
|
+
return { plan, width, height, samples: effectiveSamples, engine, warnings }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function finiteArray(values, size) { return Array.isArray(values) && values.length === size && values.every(Number.isFinite) }
|
|
75
|
+
function matrixFacts(value) { return Array.isArray(value) && value.length === 4 && value.every(row => finiteArray(row, 4)) }
|
|
76
|
+
function dofFacts(value) {
|
|
77
|
+
return value && typeof value.enabled === 'boolean'
|
|
78
|
+
&& ['focusDistance', 'apertureFstop', 'apertureBlades', 'apertureRotation', 'apertureRatio'].every(key => Number.isFinite(value[key]))
|
|
79
|
+
&& value.focusDistance >= 0 && value.apertureFstop > 0 && value.apertureRatio > 0
|
|
80
|
+
&& Number.isInteger(value.apertureBlades) && value.apertureBlades >= 0
|
|
81
|
+
&& (value.focusSubtarget === null || typeof value.focusSubtarget === 'string')
|
|
82
|
+
&& (value.focusObject === null || typeof value.focusObject?.name === 'string'
|
|
83
|
+
&& (value.focusObject.entityId === null || typeof value.focusObject.entityId === 'string') && matrixFacts(value.focusObject.matrixWorld))
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Missing measurements must never be filled with requested settings. */
|
|
87
|
+
function verifyFacts(entry, expected, settings) {
|
|
88
|
+
const facts = entry?.cameraFacts, measured = entry?.renderConfig
|
|
89
|
+
if (entry?.viewId !== expected.id || entry.cameraId !== expected.cameraId || entry.frame !== expected.frame
|
|
90
|
+
|| entry.width !== settings.width || entry.height !== settings.height
|
|
91
|
+
|| !measured || measured.engine !== entry.engine || entry.engine !== BLENDER_ENGINE_BY_KEY[settings.engine] || !finiteArray(measured.resolution, 2)
|
|
92
|
+
|| measured.resolution[0] !== entry.width || measured.resolution[1] !== entry.height
|
|
93
|
+
|| !Number.isInteger(measured.samples) || measured.samples < 1 || measured.samples > settings.samples
|
|
94
|
+
|| measured.resolutionPercentage !== 100 || typeof measured.filmTransparent !== 'boolean'
|
|
95
|
+
|| !Number.isFinite(measured.exposure) || typeof measured.viewTransform !== 'string' || typeof measured.look !== 'string'
|
|
96
|
+
|| !Number.isFinite(measured.fps) || measured.fps <= 0 || !Number.isInteger(measured.frameStart) || !Number.isInteger(measured.frameEnd)
|
|
97
|
+
|| measured.frameStart > entry.frame || measured.frameEnd < entry.frame
|
|
98
|
+
|| !facts || facts.frame !== entry.frame || !matrixFacts(facts.matrixWorld) || !['PERSP', 'ORTHO', 'PANO'].includes(facts.type)
|
|
99
|
+
|| !['lens', 'orthoScale', 'sensorWidth', 'sensorHeight'].every(key => Number.isFinite(facts[key]) && facts[key] > 0)
|
|
100
|
+
|| !['AUTO', 'HORIZONTAL', 'VERTICAL'].includes(facts.sensorFit)
|
|
101
|
+
|| !finiteArray(facts.shift, 2) || !finiteArray(facts.clip, 2) || facts.clip[0] <= 0 || facts.clip[1] <= facts.clip[0] || !dofFacts(facts.dof)) {
|
|
102
|
+
refuse(`Inspection view ${expected.id} has missing or inconsistent measured camera/render facts.`, BlenderErrorCode.RENDER_NO_OUTPUT)
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function listDiagnostics(store, projectId, revision) {
|
|
107
|
+
const directory = resolveInside(store.projectDirectory(projectId), join(store.revisionDirectory(projectId, revision), 'diagnostics'), 'diagnostics')
|
|
108
|
+
if (!existsSync(directory)) return []
|
|
109
|
+
return readdirSync(directory, { withFileTypes: true }).filter(entry => entry.isDirectory() && !entry.name.startsWith('.'))
|
|
110
|
+
.flatMap(entry => {
|
|
111
|
+
const path = resolveInside(directory, join(entry.name, 'result.json'), 'diagnostic result')
|
|
112
|
+
const result = readJsonSafe(path)
|
|
113
|
+
return result?.schemaVersion === DIAGNOSTIC_VERSION && result.sourceRevision === revision && Array.isArray(result.artifacts)
|
|
114
|
+
? result.artifacts : []
|
|
115
|
+
}).sort((left, right) => String(right.at).localeCompare(String(left.at)))
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export async function renderDiagnostic(host, request) {
|
|
119
|
+
const { store, runtime, config } = host
|
|
120
|
+
const projectId = request.projectId
|
|
121
|
+
if (typeof request.revision !== 'string') refuse('Inspection requires an explicit source revision.')
|
|
122
|
+
store.readRecord(projectId)
|
|
123
|
+
const revision = request.revision, spec = store.readRevisionSpec(projectId, revision)
|
|
124
|
+
const scene = diagnosticSpec(spec, request.mode)
|
|
125
|
+
const checked = validateSceneSpec(scene)
|
|
126
|
+
if (!checked.ok) throw new BlenderError(BlenderErrorCode.SCENE_SPEC_INVALID, checked.summary, { detail: checked.errors })
|
|
127
|
+
const settings = inspectionPlan(spec, request, config.maxPreviewSamples), warnings = [...settings.warnings]
|
|
128
|
+
const project = store.projectDirectory(projectId), revisionDirectory = store.revisionDirectory(projectId, revision)
|
|
129
|
+
// A counter alone can collide across Host instances observing the same store.
|
|
130
|
+
const jobId = `${store.allocateJobId(projectId, 'diagnostic')}-${randomUUID()}`, signal = request.signal
|
|
131
|
+
const scratch = resolveInside(project, `staging/.diagnostic-${jobId}`, 'inspection staging')
|
|
132
|
+
const compileRoot = join(scratch, 'source'), output = join(scratch, 'output')
|
|
133
|
+
const relative = `revisions/${revision}/diagnostics/${jobId}`
|
|
134
|
+
const destination = resolveInside(project, relative, 'inspection publication')
|
|
135
|
+
const startedMs = Date.now(), startedAt = new Date().toISOString()
|
|
136
|
+
const baseJob = { schemaVersion: JOB_RECORD_VERSION, jobId, projectId, action: 'render_views', revision,
|
|
137
|
+
startedAt, idempotencyKey: null, baseRevision: revision, warnings }
|
|
138
|
+
let published = false, complete = false
|
|
139
|
+
const sourceSpecSha256 = fileSha256(join(revisionDirectory, 'scene-spec.json'))
|
|
140
|
+
const sourceCheckpoint = join(revisionDirectory, 'scene.blend')
|
|
141
|
+
const sourceCheckpointSha256 = isFile(sourceCheckpoint) ? fileSha256(sourceCheckpoint) : null
|
|
142
|
+
store.writeJob(projectId, { ...baseJob, status: 'running', errorCode: null, message: null,
|
|
143
|
+
finishedAt: null, durationMs: null, artifacts: [] })
|
|
144
|
+
try {
|
|
145
|
+
signal?.throwIfAborted()
|
|
146
|
+
mkdirSync(compileRoot, { recursive: true }); mkdirSync(output)
|
|
147
|
+
const sourceAssets = []
|
|
148
|
+
const assetPaths = new Map()
|
|
149
|
+
const stagedBundles = new Set()
|
|
150
|
+
for (const asset of spec.assets ?? []) {
|
|
151
|
+
if (typeof asset.path !== 'string' || !asset.path.startsWith('assets/')) {
|
|
152
|
+
refuse('Inspection requires project-local assets under assets/.', BlenderErrorCode.ASSET_REQUEST_INVALID)
|
|
153
|
+
}
|
|
154
|
+
if (!['gltf', 'glb', 'obj', 'png', 'jpg', 'jpeg', 'hdr', 'exr', 'stl', 'ply'].includes(asset.type)) {
|
|
155
|
+
refuse(`Inspection cannot isolate dependencies of ${asset.type} assets. Use a self-contained GLB or the existing checkpoint preview.`, BlenderErrorCode.UNSUPPORTED_ACTION)
|
|
156
|
+
}
|
|
157
|
+
if (!/^[a-f0-9]{64}$/.test(asset.sha256 ?? '')) refuse(`Asset ${asset.id} has no exact SHA-256.`, BlenderErrorCode.ASSET_HASH_MISMATCH)
|
|
158
|
+
const original = resolveInside(project, asset.path, 'inspection source asset')
|
|
159
|
+
const staged = resolveInside(join(compileRoot, 'assets'), join(compileRoot, asset.path), 'inspection copied asset')
|
|
160
|
+
if (!isFile(original)) refuse(`Asset ${asset.id} is missing.`, BlenderErrorCode.ASSET_SOURCE_NOT_FOUND)
|
|
161
|
+
let copied = assetPaths.get(staged)
|
|
162
|
+
if (!copied) {
|
|
163
|
+
const version = assetPreviewVersion(asset)
|
|
164
|
+
let bundled = stagedBundles.has(version)
|
|
165
|
+
if (!bundled) bundled = await stageAssetBundle(project, compileRoot, asset, { maxBytes: config.assetMaxBytes, signal })
|
|
166
|
+
if (bundled) {
|
|
167
|
+
stagedBundles.add(version)
|
|
168
|
+
copied = { sha256: fileSha256(staged), bytes: fileSize(staged) }
|
|
169
|
+
} else {
|
|
170
|
+
mkdirSync(dirname(staged), { recursive: true })
|
|
171
|
+
copied = await streamAsset(createReadStream(original), staged, { maxBytes: config.assetMaxBytes, signal, label: asset.id })
|
|
172
|
+
}
|
|
173
|
+
assetPaths.set(staged, copied)
|
|
174
|
+
}
|
|
175
|
+
if (copied.sha256 !== asset.sha256) refuse(`Asset ${asset.id} no longer matches this revision.`, BlenderErrorCode.ASSET_HASH_MISMATCH)
|
|
176
|
+
verifyAssetBundle(compileRoot, asset)
|
|
177
|
+
// Aliases can share bytes while declaring different types. Copying once
|
|
178
|
+
// must not let an earlier image alias bypass a later GLB dependency check.
|
|
179
|
+
if (['gltf', 'glb', 'obj'].includes(asset.type)) {
|
|
180
|
+
verifyUnbundledGltfAsset(compileRoot, asset)
|
|
181
|
+
verifyUnbundledObjAsset(compileRoot, asset)
|
|
182
|
+
const { document } = asset.type === 'obj' ? { document: {} } : readGltfDocument(staged, { format: asset.type, signal })
|
|
183
|
+
if (document.cameras?.length || document.extensions?.KHR_lights_punctual?.lights?.length) refuse('Inspection requires model-only glTF/GLB assets, without embedded cameras or lights.', BlenderErrorCode.UNSUPPORTED_ACTION)
|
|
184
|
+
}
|
|
185
|
+
sourceAssets.push({ id: asset.id, path: asset.path, sha256: copied.sha256, bytes: copied.bytes })
|
|
186
|
+
}
|
|
187
|
+
const sceneSpecPath = join(compileRoot, 'scene-spec.json'), checkpointPath = join(compileRoot, 'derived.blend')
|
|
188
|
+
writeJsonAtomic(sceneSpecPath, scene)
|
|
189
|
+
const compiled = await runtime.compileScene({ sceneSpecPath, projectRoot: compileRoot, profile: 'preview',
|
|
190
|
+
jobId: `${jobId}-compile`, session: false, signal,
|
|
191
|
+
onWorkingDirectory: info => copyFileSync(join(info.directory, 'result.blend'), checkpointPath) })
|
|
192
|
+
signal?.throwIfAborted()
|
|
193
|
+
if (!isFile(checkpointPath)) refuse('Inspection compilation produced no checkpoint.', BlenderErrorCode.REVISION_CHECKPOINT_MISSING)
|
|
194
|
+
if (compiled.report?.validation?.ok !== true) refuse('The derived inspection scene failed Blender validation.', BlenderErrorCode.SCENE_VALIDATION_FAILED)
|
|
195
|
+
const polygons = compiled.report?.sceneFingerprint?.totalPolygons
|
|
196
|
+
if (!Number.isFinite(polygons) || polygons > config.maxMeshPolygons) refuse('The derived scene exceeds the mesh budget or has no measured mesh count.', BlenderErrorCode.SCENE_TOO_HEAVY)
|
|
197
|
+
const run = await runtime.renderViews({ checkpointPath, views: settings.plan, track: [], parts: [],
|
|
198
|
+
engine: settings.engine, width: settings.width, height: settings.height, samples: settings.samples,
|
|
199
|
+
jobId: `${jobId}-render`, session: false, signal })
|
|
200
|
+
signal?.throwIfAborted()
|
|
201
|
+
for (const result of [compiled, run]) warnings.push(...(result.envelope?.warnings ?? []))
|
|
202
|
+
if (!Array.isArray(run.report?.views) || run.report.views.length !== settings.plan.length) {
|
|
203
|
+
refuse('Inspection returned an incomplete view set.', BlenderErrorCode.RENDER_NO_OUTPUT)
|
|
204
|
+
}
|
|
205
|
+
const transform = request.mode === 'clay' ? CLAY_TRANSFORM : { id: 'rebuild-original', version: 1 }
|
|
206
|
+
const sourceDigest = sceneSpecDigest(spec), sourceSpecHash = specHash(spec)
|
|
207
|
+
const at = new Date().toISOString(), artifacts = []
|
|
208
|
+
const { default: sharp } = await import('sharp')
|
|
209
|
+
for (let index = 0; index < settings.plan.length; index++) {
|
|
210
|
+
const expected = settings.plan[index], entry = run.report.views[index]
|
|
211
|
+
verifyFacts(entry, expected, settings)
|
|
212
|
+
const png = run.pngs?.[expected.id]
|
|
213
|
+
if (!Buffer.isBuffer(png) || png.length < 8 || png.length > DIAGNOSTIC_LIMITS.maxPngBytes) {
|
|
214
|
+
refuse('Inspection returned no bounded PNG bytes.', BlenderErrorCode.RENDER_NO_OUTPUT)
|
|
215
|
+
}
|
|
216
|
+
const options = { failOn: 'warning', limitInputPixels: DIAGNOSTIC_LIMITS.maxEdge ** 2 }
|
|
217
|
+
const metadata = await sharp(png, options).metadata()
|
|
218
|
+
if (metadata.format !== 'png' || metadata.width !== entry.width || metadata.height !== entry.height || (metadata.pages ?? 1) !== 1) {
|
|
219
|
+
refuse('Inspection PNG dimensions disagree with the measured report.', BlenderErrorCode.RENDER_NO_OUTPUT)
|
|
220
|
+
}
|
|
221
|
+
await sharp(png, options).raw().toBuffer()
|
|
222
|
+
signal?.throwIfAborted()
|
|
223
|
+
writeFileSync(join(output, `${expected.id}.png`), png, { flag: 'wx' })
|
|
224
|
+
artifacts.push({ kind: 'diagnostic', mode: request.mode, transformationVersion: transform.version,
|
|
225
|
+
sourceRevision: revision, sourceDigest, sourceSpecHash, viewId: expected.id, cameraId: entry.cameraId,
|
|
226
|
+
frame: entry.frame, width: entry.width, height: entry.height, engine: entry.engine,
|
|
227
|
+
samples: entry.renderConfig.samples, renderConfig: entry.renderConfig, cameraFacts: entry.cameraFacts,
|
|
228
|
+
path: `${relative}/${expected.id}.png`, resultPath: `${relative}/result.json`, bytes: png.length,
|
|
229
|
+
sha256: sha256(png), mime: 'image/png', at })
|
|
230
|
+
}
|
|
231
|
+
const result = { schemaVersion: DIAGNOSTIC_VERSION, projectId, revision, sourceRevision: revision, sourceDigest,
|
|
232
|
+
sourceSpecHash, sourceSpecSha256, sourceCheckpointSha256, sourceAssets,
|
|
233
|
+
mode: request.mode, transform, derivedSpecHash: specHash(scene), derivedCheckpointSha256: fileSha256(checkpointPath),
|
|
234
|
+
execution: { transport: 'batch' }, limits: DIAGNOSTIC_LIMITS,
|
|
235
|
+
limitations: request.mode === 'clay' ? [
|
|
236
|
+
'Non-environment surfaces are opaque grey. Transparency, emission, texture, normal and bump appearance are removed.',
|
|
237
|
+
'Rebuilt from SceneSpec; imported shader displacement may change rendered geometry. This is not original-checkpoint equivalence.',
|
|
238
|
+
'A grey render does not prove thickness, watertightness, self-intersection freedom or artistic quality.',
|
|
239
|
+
] : ['Rebuilt from SceneSpec; this is not a render of the original checkpoint.'],
|
|
240
|
+
artifacts, views: artifacts, at, durationMs: Date.now() - startedMs, warnings }
|
|
241
|
+
writeJsonAtomic(join(output, 'derived-scene-spec.json'), scene)
|
|
242
|
+
writeJsonAtomic(join(output, 'result.json'), result)
|
|
243
|
+
signal?.throwIfAborted()
|
|
244
|
+
store.withProjectWrite(projectId, () => {
|
|
245
|
+
store.readRecord(projectId)
|
|
246
|
+
mkdirSync(dirname(destination), { recursive: true })
|
|
247
|
+
renameSync(output, destination)
|
|
248
|
+
published = true
|
|
249
|
+
})
|
|
250
|
+
const job = store.writeJob(projectId, { ...baseJob, status: 'succeeded', errorCode: null, message: null,
|
|
251
|
+
finishedAt: new Date().toISOString(), durationMs: Date.now() - startedMs, artifacts })
|
|
252
|
+
complete = true
|
|
253
|
+
return { ...result, job: toCanonicalJobRecord(job) }
|
|
254
|
+
} catch (cause) {
|
|
255
|
+
const failure = host._failedRenderError(signal?.aborted
|
|
256
|
+
? new BlenderError(BlenderErrorCode.ABORTED, 'Inspection was cancelled.', { cause }) : cause, jobId)
|
|
257
|
+
store.writeJob(projectId, { ...baseJob, status: 'failed', errorCode: failure.code, message: failure.message,
|
|
258
|
+
finishedAt: new Date().toISOString(), durationMs: Date.now() - startedMs, artifacts: [] })
|
|
259
|
+
throw failure
|
|
260
|
+
} finally {
|
|
261
|
+
removeTree(scratch)
|
|
262
|
+
if (published && !complete) removeTree(destination)
|
|
263
|
+
}
|
|
264
|
+
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/** Actual frame settings, bound to bytes. Only finalization/recovery hashes PNGs;
|
|
2
|
+
* UI polling checks the saved stat signature. The current journal is folded into
|
|
3
|
+
* one bounded source document before the provider may replace that journal. */
|
|
4
|
+
import { closeSync, existsSync, fstatSync, openSync, readFileSync, readSync, statSync } from 'node:fs'
|
|
5
|
+
import { createHash } from 'node:crypto'
|
|
6
|
+
import { join } from 'node:path'
|
|
7
|
+
import { BlenderError, BlenderErrorCode, frameFileName } from '@deepblend/dsh-blender-contracts'
|
|
8
|
+
import { writeJsonAtomic } from './paths.js'
|
|
9
|
+
import { inspectFrameSample, sampleFrame } from './frame-ledger.js'
|
|
10
|
+
|
|
11
|
+
const VERSION = 'deepblend.frame-sources/v1'
|
|
12
|
+
const MAX_BYTES = 16 * 1024 * 1024
|
|
13
|
+
const MAX_JOURNAL_BYTES = 32 * 1024 * 1024
|
|
14
|
+
const MAX_FRAMES = 100_000
|
|
15
|
+
const MAX_CONFIG_BYTES = 8192
|
|
16
|
+
const TOKEN = /^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/i
|
|
17
|
+
const HASH = /^[a-f0-9]{64}$/
|
|
18
|
+
const configFields = ['engine', 'resolution', 'resolutionPercentage', 'samples', 'filmTransparent', 'viewTransform', 'look', 'exposure', 'fps', 'fpsBase', 'frameStart', 'frameEnd']
|
|
19
|
+
const documentPath = directory => join(directory, 'frame-sources.json')
|
|
20
|
+
const empty = record => ({ schemaVersion: VERSION, projectId: record.projectId, jobId: record.jobId, revisionId: record.revisionId, sources: {}, frames: {}, current: null })
|
|
21
|
+
const fail = message => new BlenderError(BlenderErrorCode.RENDER_JOB_STATE_INVALID, message)
|
|
22
|
+
const limit = (reason, message) => new BlenderError(BlenderErrorCode.RENDER_JOB_STATE_INVALID, message, { detail: { frameProvenanceLimit: reason } })
|
|
23
|
+
/** One budget for a getJob or an entire listJobs projection, never per row. */
|
|
24
|
+
export const createFrameProvenanceReadBudget = () => ({ frames: 256, bytes: 256 * 1024, documents: 16, reads: 16 })
|
|
25
|
+
function limitedSummary(record, reason) {
|
|
26
|
+
const count = record.frameEnd - record.frameStart + 1
|
|
27
|
+
return { schemaVersion: VERSION, coverage: 'unknown', configuration: 'unknown', groups: [], sources: [], frames: [],
|
|
28
|
+
unknownFrames: [], missingFrames: [], knownFrameCount: 0, unknownFrameCount: 0, missingFrameCount: 0,
|
|
29
|
+
limited: true, limitation: reason, uncheckedFrameCount: Number.isSafeInteger(count) && count > 0 ? count : 0 }
|
|
30
|
+
}
|
|
31
|
+
const range = record => {
|
|
32
|
+
const count = record.frameEnd - record.frameStart + 1
|
|
33
|
+
if (!Number.isSafeInteger(count) || count < 1) throw fail('Invalid frame provenance range.')
|
|
34
|
+
if (count > MAX_FRAMES) throw limit('frame-limit', `Frame provenance supports 1..${MAX_FRAMES} frames per job.`)
|
|
35
|
+
return Array.from({ length: count }, (_, i) => record.frameStart + i)
|
|
36
|
+
}
|
|
37
|
+
const framePath = (record, directory, frame) => join(record.framesDirectory ?? join(directory, 'frames'), frameFileName(frame, record.filePrefix ?? 'frame_', record.filePadding ?? 4))
|
|
38
|
+
function signature(path) {
|
|
39
|
+
try { const s = statSync(path); return s.isFile() ? { bytes: s.size, mtimeMs: s.mtimeMs, ctimeMs: s.ctimeMs } : null } catch { return null }
|
|
40
|
+
}
|
|
41
|
+
const same = (a, b) => a != null && b != null && a.bytes === b.bytes && a.mtimeMs === b.mtimeMs && a.ctimeMs === b.ctimeMs
|
|
42
|
+
function digest(path) {
|
|
43
|
+
try {
|
|
44
|
+
const before = signature(path)
|
|
45
|
+
// Frame hashing is performed after the renderer has stopped. A changed file
|
|
46
|
+
// is never attributed while this read is in progress.
|
|
47
|
+
const hash = createHash('sha256'), buffer = Buffer.allocUnsafe(256 * 1024)
|
|
48
|
+
const fd = openSync(path, 'r')
|
|
49
|
+
try { let size; while ((size = readSync(fd, buffer, 0, buffer.length, null)) > 0) hash.update(buffer.subarray(0, size)) }
|
|
50
|
+
finally { closeSync(fd) }
|
|
51
|
+
const sha256 = hash.digest('hex')
|
|
52
|
+
const after = signature(path)
|
|
53
|
+
return same(before, after) ? { sha256, stat: after } : null
|
|
54
|
+
} catch { return null }
|
|
55
|
+
}
|
|
56
|
+
function actualConfig(value) {
|
|
57
|
+
if (!value || typeof value !== 'object' || Buffer.byteLength(JSON.stringify(value)) > MAX_CONFIG_BYTES) return null
|
|
58
|
+
if (typeof value.engine !== 'string' || !value.engine || value.engine.length > 128 ||
|
|
59
|
+
!Array.isArray(value.resolution) || value.resolution.length !== 2 || !value.resolution.every(n => Number.isSafeInteger(n) && n > 0) ||
|
|
60
|
+
!(value.samples === null || Number.isSafeInteger(value.samples) && value.samples > 0)) return null
|
|
61
|
+
const config = {}
|
|
62
|
+
for (const key of configFields) {
|
|
63
|
+
if (value[key] === undefined) continue
|
|
64
|
+
if (key !== 'resolution' && value[key] !== null && !['string', 'boolean', 'number'].includes(typeof value[key])) return null
|
|
65
|
+
if (typeof value[key] === 'number' && !Number.isFinite(value[key])) return null
|
|
66
|
+
config[key] = value[key]
|
|
67
|
+
}
|
|
68
|
+
return config
|
|
69
|
+
}
|
|
70
|
+
function readSource(path, budget) {
|
|
71
|
+
if (budget) budget.documents -= 1
|
|
72
|
+
const fd = openSync(path, 'r')
|
|
73
|
+
try {
|
|
74
|
+
const size = fstatSync(fd).size
|
|
75
|
+
const maxBytes = budget ? Math.min(budget.bytes, MAX_BYTES) : MAX_BYTES
|
|
76
|
+
if (size > maxBytes) throw limit(budget ? 'status-budget' : 'source-byte-limit', 'Frame source metadata exceeds its read budget; it has been preserved.')
|
|
77
|
+
// Read at most the budget even if an external file grows after fstat.
|
|
78
|
+
const buffer = Buffer.alloc(size)
|
|
79
|
+
let offset = 0
|
|
80
|
+
while (offset < size) {
|
|
81
|
+
if (budget && budget.reads-- < 1) throw limit('status-budget', 'Frame source read calls exceeded their allowance; the file has been preserved.')
|
|
82
|
+
const n = readSync(fd, buffer, offset, size - offset, offset)
|
|
83
|
+
if (n === 0) break
|
|
84
|
+
offset += n
|
|
85
|
+
if (budget) budget.bytes -= n
|
|
86
|
+
}
|
|
87
|
+
return JSON.parse(buffer.subarray(0, offset).toString('utf8'))
|
|
88
|
+
} finally { closeSync(fd) }
|
|
89
|
+
}
|
|
90
|
+
function load(record, directory, budget) {
|
|
91
|
+
const fresh = empty(record)
|
|
92
|
+
try {
|
|
93
|
+
const path = documentPath(directory)
|
|
94
|
+
const data = readSource(path, budget)
|
|
95
|
+
if (data.schemaVersion !== VERSION || data.projectId !== record.projectId || data.jobId !== record.jobId || data.revisionId !== record.revisionId ||
|
|
96
|
+
!data.sources || !data.frames || typeof data.sources !== 'object' || typeof data.frames !== 'object') return fresh
|
|
97
|
+
const expected = new Set(range(record))
|
|
98
|
+
for (const [id, source] of Object.entries(data.sources)) {
|
|
99
|
+
if (TOKEN.test(id) && source?.id === id && source.revisionId === record.revisionId && actualConfig(source.renderConfig) && Buffer.byteLength(JSON.stringify(source)) <= MAX_CONFIG_BYTES * 3) fresh.sources[id] = source
|
|
100
|
+
}
|
|
101
|
+
for (const [frame, binding] of Object.entries(data.frames)) {
|
|
102
|
+
if (expected.has(Number(frame)) && String(Number(frame)) === frame && fresh.sources[binding?.sourceId] && HASH.test(binding.sha256 ?? '')) fresh.frames[frame] = binding
|
|
103
|
+
}
|
|
104
|
+
const c = data.current
|
|
105
|
+
if (TOKEN.test(c?.id ?? '') && c.revisionId === record.revisionId && Array.isArray(c.plannedFrames) && c.plannedFrames.length <= MAX_FRAMES &&
|
|
106
|
+
c.plannedFrames.every(frame => expected.has(frame)) && Buffer.byteLength(JSON.stringify(c)) <= MAX_BYTES / 2) fresh.current = c
|
|
107
|
+
} catch (cause) {
|
|
108
|
+
if (cause?.detail?.frameProvenanceLimit) throw cause
|
|
109
|
+
// Legacy, missing or corrupt source data remains unknown.
|
|
110
|
+
}
|
|
111
|
+
return fresh
|
|
112
|
+
}
|
|
113
|
+
function save(directory, data) {
|
|
114
|
+
const used = new Set(Object.values(data.frames).map(binding => binding.sourceId))
|
|
115
|
+
for (const id of Object.keys(data.sources)) if (!used.has(id)) delete data.sources[id]
|
|
116
|
+
if (Buffer.byteLength(JSON.stringify(data, null, 2) + '\n') > MAX_BYTES) throw limit('source-byte-limit', `Frame provenance exceeds its ${MAX_BYTES} byte metadata limit.`)
|
|
117
|
+
writeJsonAtomic(documentPath(directory), data)
|
|
118
|
+
}
|
|
119
|
+
function foldJournal(data, record, directory) {
|
|
120
|
+
const current = data.current
|
|
121
|
+
if (!current) return
|
|
122
|
+
const path = join(directory, 'events.jsonl')
|
|
123
|
+
if (!existsSync(path)) return
|
|
124
|
+
// Refuse replacement of a journal that cannot be safely absorbed. Its owner
|
|
125
|
+
// may retry after resolving the problem; do not quietly discard known data.
|
|
126
|
+
if (statSync(path).size > MAX_JOURNAL_BYTES) throw limit('journal-byte-limit', 'The frame journal exceeds the provenance read limit; it has been preserved.')
|
|
127
|
+
const lines = readFileSync(path, 'utf8').split('\n')
|
|
128
|
+
lines.pop() // only fsynced complete JSON lines count
|
|
129
|
+
if (lines.length > MAX_FRAMES * 3 + 32) throw limit('journal-line-limit', 'The frame journal has too many provenance records; it has been preserved.')
|
|
130
|
+
const planned = new Set(current.plannedFrames)
|
|
131
|
+
let config = null, invalid = false
|
|
132
|
+
const claims = new Map()
|
|
133
|
+
for (const line of lines) {
|
|
134
|
+
let event
|
|
135
|
+
try { event = JSON.parse(line) } catch { continue }
|
|
136
|
+
if (event.attemptToken !== current.id) continue
|
|
137
|
+
if (event.type === 'render_config') {
|
|
138
|
+
const next = actualConfig(event.renderConfig)
|
|
139
|
+
if (!next || config && JSON.stringify(next) !== JSON.stringify(config)) invalid = true
|
|
140
|
+
else config = next
|
|
141
|
+
} else if (event.type === 'frame' && config && planned.has(event.frame) && HASH.test(event.sha256 ?? '')) {
|
|
142
|
+
claims.set(event.frame, event.sha256)
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
if (invalid) {
|
|
146
|
+
for (const [frame, binding] of Object.entries(data.frames)) if (binding.sourceId === current.id) delete data.frames[frame]
|
|
147
|
+
return
|
|
148
|
+
}
|
|
149
|
+
if (!config) return
|
|
150
|
+
const { plannedFrames, ...source } = current
|
|
151
|
+
data.sources[current.id] = { ...source, renderConfig: config }
|
|
152
|
+
for (const [frame, sha256] of claims) {
|
|
153
|
+
// inspect() verifies this claimed digest once, after all journal records.
|
|
154
|
+
data.frames[frame] = { sourceId: current.id, sha256, stat: null }
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
function inspect(data, record, directory, verify) {
|
|
158
|
+
const groups = new Map(), bindings = [], unknownFrames = [], missingFrames = []
|
|
159
|
+
for (const frame of range(record)) {
|
|
160
|
+
const path = framePath(record, directory, frame), stat = signature(path)
|
|
161
|
+
if (stat === null) { missingFrames.push(frame); if (verify) delete data.frames[frame]; continue }
|
|
162
|
+
const binding = data.frames[frame], source = data.sources[binding?.sourceId]
|
|
163
|
+
const measured = verify && binding ? digest(path) : null
|
|
164
|
+
const valid = source && (verify ? measured?.sha256 === binding.sha256 && inspectFrameSample(sampleFrame(path), {
|
|
165
|
+
width: record.renderConfig?.resolution?.[0], height: record.renderConfig?.resolution?.[1],
|
|
166
|
+
}).ok : same(stat, binding.stat))
|
|
167
|
+
if (!valid) { unknownFrames.push(frame); if (verify) delete data.frames[frame]; continue }
|
|
168
|
+
if (verify) binding.stat = measured.stat
|
|
169
|
+
bindings.push({ frame, sourceId: binding.sourceId, sha256: binding.sha256 })
|
|
170
|
+
const key = JSON.stringify([source.renderConfig, source.cameraId, source.checkpointSha256])
|
|
171
|
+
if (!groups.has(key)) groups.set(key, { renderConfig: source.renderConfig, frames: [], sourceIds: [] })
|
|
172
|
+
const group = groups.get(key); group.frames.push(frame)
|
|
173
|
+
if (!group.sourceIds.includes(source.id)) group.sourceIds.push(source.id)
|
|
174
|
+
}
|
|
175
|
+
const known = bindings.length, complete = known > 0 && unknownFrames.length === 0 && missingFrames.length === 0
|
|
176
|
+
return {
|
|
177
|
+
schemaVersion: VERSION, coverage: complete ? 'complete' : known ? 'partial' : 'unknown',
|
|
178
|
+
configuration: groups.size > 1 ? 'mixed' : complete ? 'uniform' : 'unknown',
|
|
179
|
+
groups: [...groups.values()], unknownFrames, missingFrames, frames: bindings,
|
|
180
|
+
knownFrameCount: known, unknownFrameCount: unknownFrames.length, missingFrameCount: missingFrames.length,
|
|
181
|
+
sources: [...new Set(bindings.map(b => b.sourceId))].map(id => data.sources[id]),
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
/** Publication may defer attribution at a metadata limit, preserving every source
|
|
185
|
+
* file and journal. Recovery/startup stays strict before replacing any evidence. */
|
|
186
|
+
export function syncFrameProvenance(record, directory, options = {}) {
|
|
187
|
+
try {
|
|
188
|
+
range(record)
|
|
189
|
+
const data = load(record, directory)
|
|
190
|
+
foldJournal(data, record, directory)
|
|
191
|
+
const summary = inspect(data, record, directory, true)
|
|
192
|
+
save(directory, data)
|
|
193
|
+
return summary
|
|
194
|
+
} catch (cause) {
|
|
195
|
+
if (options.allowLimited && cause?.detail?.frameProvenanceLimit) return limitedSummary(record, cause.detail.frameProvenanceLimit)
|
|
196
|
+
throw cause
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
/** No PNG reads/hashes on the status path. A skipped job is explicitly unchecked,
|
|
200
|
+
* not classified as missing/corrupt or attributed from an old saved summary. */
|
|
201
|
+
export function readFrameProvenance(record, directory, budget = createFrameProvenanceReadBudget()) {
|
|
202
|
+
const count = record.frameEnd - record.frameStart + 1
|
|
203
|
+
if (!Number.isSafeInteger(count) || count < 1 || count > budget.frames || budget.documents < 1 || budget.bytes < 1 || budget.reads < 1) {
|
|
204
|
+
return limitedSummary(record, 'status-budget')
|
|
205
|
+
}
|
|
206
|
+
budget.frames -= count
|
|
207
|
+
try { return inspect(load(record, directory, budget), record, directory, false) }
|
|
208
|
+
catch (cause) {
|
|
209
|
+
if (cause?.detail?.frameProvenanceLimit) return limitedSummary(record, 'status-budget')
|
|
210
|
+
throw cause
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
/** Must finish before the provider can clear its previous journal. */
|
|
214
|
+
export function beginFrameAttempt(record, directory, input) {
|
|
215
|
+
syncFrameProvenance(record, directory)
|
|
216
|
+
const data = load(record, directory)
|
|
217
|
+
const expected = new Set(range(record))
|
|
218
|
+
if (!TOKEN.test(input.id) || !input.frames.every(frame => expected.has(frame))) throw fail('Invalid frame provenance attempt identity.')
|
|
219
|
+
if (Buffer.byteLength(JSON.stringify(input.profile)) > MAX_CONFIG_BYTES) throw fail('The frame profile exceeds the provenance metadata limit.')
|
|
220
|
+
const checkpoint = input.checkpointPath && digest(input.checkpointPath)
|
|
221
|
+
data.current = {
|
|
222
|
+
id: input.id, attempt: (record.attempt ?? 0) + 1, revisionId: record.revisionId,
|
|
223
|
+
cameraId: input.cameraId ?? null, checkpointSha256: checkpoint?.sha256 ?? null,
|
|
224
|
+
requestedSamples: input.requestedSamples ?? input.profile.samples ?? null,
|
|
225
|
+
effectiveProfile: input.profile, plannedFrames: input.frames,
|
|
226
|
+
}
|
|
227
|
+
// A replacement is unknown until its new completion event binds its bytes.
|
|
228
|
+
for (const frame of input.frames) delete data.frames[frame]
|
|
229
|
+
save(directory, data)
|
|
230
|
+
}
|