@deepblend/dsh-blender-host 0.2.20 → 0.3.1

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