@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.
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,139 @@
1
+ /** Fixed asset preview scenes, compiled in batch mode outside project revisions. */
2
+ import { copyFileSync, existsSync, writeFileSync } from 'node:fs'
3
+ import { join } from 'node:path'
4
+ import { randomUUID } from 'node:crypto'
5
+ import { BlenderError, BlenderErrorCode, compileSceneSpec, validateSceneSpec, specHash } from '@deepblend/dsh-blender-contracts'
6
+ import { defaultSceneSpec } from './revision-transaction.js'
7
+ import { ASSET_LIBRARY_LIMITS } from './asset-library.js'
8
+ import { readGltfDocument, verifyAssetBundle, verifyUnbundledGltfAsset, verifyUnbundledObjAsset } from './asset-bundle.js'
9
+
10
+ export const ASSET_PREVIEW_TEMPLATE = 'deepblend.asset-preview/v1'
11
+
12
+ function invalid(message, detail) {
13
+ throw new BlenderError(BlenderErrorCode.ASSET_REQUEST_INVALID, message, { detail })
14
+ }
15
+
16
+ function pointLight(id, location, target, energy, size) {
17
+ const d = target.map((value, index) => value - location[index])
18
+ return { id, type: 'area', energy, size, color: [1, 1, 1], transform: { location,
19
+ rotationEuler: [Math.atan2(Math.hypot(d[0], d[1]), -d[2]), 0, Math.atan2(-d[0], d[1])] } }
20
+ }
21
+
22
+ export function assetPreviewScene(asset, { bounds, environment = false, samples = ASSET_LIBRARY_LIMITS.previewSamples } = {}) {
23
+ const spec = defaultSceneSpec({ projectId: 'asset-preview', title: 'Isolated asset preview' })
24
+ spec.project.aspectRatio = '4:3'
25
+ spec.project.activeCamera = 'camera-main'
26
+ spec.assets = [structuredClone(asset)]
27
+ spec.entities = environment ? [
28
+ { id: 'grey-sphere', type: 'generator', generator: { shape: 'uv_sphere', radius: 0.45, segments: 48, ringCount: 24 },
29
+ materialId: 'default-surface', transform: { location: [-0.5, 0, 0.45] } },
30
+ { id: 'metal-sphere', type: 'generator', generator: { shape: 'uv_sphere', radius: 0.45, segments: 48, ringCount: 24 },
31
+ materialId: 'metal-surface', transform: { location: [0.5, 0, 0.45] } },
32
+ ] : [{ id: 'asset-subject', type: 'asset-instance', assetId: asset.id }]
33
+ if (environment) spec.materials.push({ id: 'metal-surface', shader: 'principled',
34
+ parameters: { baseColor: [0.7, 0.7, 0.7, 1], metallic: 1, roughness: 0.12 } })
35
+ const extent = bounds ?? (environment ? { min: [-1, -0.5, 0], max: [1, 0.5, 1] } : { min: [-1, -1, -1], max: [1, 1, 1] })
36
+ const target = extent.min.map((value, i) => (value + extent.max[i]) / 2)
37
+ const radius = Math.max(0.0001, Math.hypot(...extent.min.map((value, i) => extent.max[i] - value)) / 2)
38
+ const camera = spec.cameras[0]
39
+ delete camera.targetEntityId
40
+ camera.targetPoint = target
41
+ camera.clipping = [Math.max(0.00001, radius / 1000), Math.max(1, radius * 40)]
42
+ camera.transform.location = target.map((value, i) => value + [2.6, -3.6, 2.2][i] * radius)
43
+ spec.world = environment ? { color: [1, 1, 1, 1], strength: 1, environment: { assetId: asset.id, rotation: 0 } }
44
+ : { color: [0.18, 0.18, 0.18, 1], strength: 0.35 }
45
+ spec.lights = environment ? [] : [
46
+ pointLight('key-light', target.map((v, i) => v + [2, -3, 4][i] * radius), target, 350 * radius ** 2, 3 * radius),
47
+ pointLight('fill-light', target.map((v, i) => v + [-3, -1, 2][i] * radius), target, 180 * radius ** 2, 4 * radius),
48
+ ]
49
+ const profile = { engine: 'cycles', resolution: [ASSET_LIBRARY_LIMITS.previewWidth, ASSET_LIBRARY_LIMITS.previewHeight],
50
+ samples, maxSamplesBudget: samples, filmTransparent: false, colorManagement: { viewTransform: 'AgX', exposure: 0 } }
51
+ spec.renderProfiles = { preview: profile, final: structuredClone(profile) }
52
+ return spec
53
+ }
54
+
55
+ function modelInspection(report, glb) {
56
+ const objects = (report.objects ?? []).filter(object => object.deepblendId === 'asset-subject')
57
+ const surfaces = objects.filter(object => object.type === 'MESH' && object.renderVisible === true)
58
+ if (!surfaces.length) invalid('The model has no visible mesh with measured bounds for an isolated preview.')
59
+ if (surfaces.some(object => !object.worldBounds)) invalid('A renderable part has no evaluated world bounds; the preview cannot frame the complete asset.')
60
+ const bounds = { min: [Infinity, Infinity, Infinity], max: [-Infinity, -Infinity, -Infinity] }
61
+ for (const object of surfaces) for (let axis = 0; axis < 3; axis++) {
62
+ const min = object.worldBounds.min?.[axis], max = object.worldBounds.max?.[axis]
63
+ if (!Number.isFinite(min) || !Number.isFinite(max) || min > max) invalid('The imported model returned invalid world bounds.')
64
+ bounds.min[axis] = Math.min(bounds.min[axis], min); bounds.max[axis] = Math.max(bounds.max[axis], max)
65
+ }
66
+ if (bounds.max.every((value, index) => value === bounds.min[index])) invalid('The model has zero measured size.')
67
+ return { kind: 'model', bounds, dimensions: bounds.max.map((value, index) => value - bounds.min[index]),
68
+ parts: objects.filter(object => typeof object.partId === 'string').map(object => ({
69
+ partId: object.partId, parentPartId: object.parentPartId ?? null, name: object.name, type: object.type,
70
+ worldBounds: object.worldBounds ?? null, boundsFrame: object.boundsFrame,
71
+ renderVisible: object.renderVisible, uvMaps: object.uvMaps ?? [], evaluatedUvMaps: object.evaluatedUvMaps ?? null,
72
+ ...(object.boundsUnavailable ? { boundsUnavailable: object.boundsUnavailable } : {}),
73
+ ...(object.evaluatedUvMapsUnavailable ? { evaluatedUvMapsUnavailable: object.evaluatedUvMapsUnavailable } : {}),
74
+ sourceMaterialSlots: object.sourceMaterialSlots ?? [], materialSlots: object.materialSlots ?? [],
75
+ assetSha256: null, selectorVersion: 1,
76
+ })), warnings: glb.animations ? ['This model contains animation. The isolated preview shows frame 1 only.'] : [] }
77
+ }
78
+
79
+ export async function renderAssetPreview({ runtime, asset, directory, maxMeshPolygons, maxPreviewSamples, signal }) {
80
+ const environment = ['hdr', 'exr'].includes(asset.type)
81
+ if (!environment && !['gltf', 'glb', 'obj'].includes(asset.type)) invalid('This preview supports glTF/GLB/OBJ models and HDR/EXR environment lighting.')
82
+ let glb = null
83
+ if (!environment) {
84
+ signal?.throwIfAborted()
85
+ verifyAssetBundle(directory, asset)
86
+ verifyUnbundledGltfAsset(directory, asset)
87
+ verifyUnbundledObjAsset(directory, asset)
88
+ const { document } = asset.type === 'obj' ? { document: {} } : readGltfDocument(join(directory, asset.path), { format: asset.type, signal })
89
+ glb = { animations: document.animations?.length ?? 0, cameras: document.cameras?.length ?? 0,
90
+ lights: document.extensions?.KHR_lights_punctual?.lights?.length ?? 0 }
91
+ }
92
+ if (glb && (glb.cameras || glb.lights)) invalid(
93
+ 'This model contains cameras or lights. Export a model-only file for the current asset library preview; its contents will not be silently removed.')
94
+ const samples = Math.max(1, Math.min(ASSET_LIBRARY_LIMITS.previewSamples, Math.floor(maxPreviewSamples)))
95
+ const checkpointPath = join(directory, 'preview.blend'), sceneSpecPath = join(directory, 'scene-spec.json')
96
+ const compile = async scene => {
97
+ signal?.throwIfAborted()
98
+ const checked = validateSceneSpec(scene)
99
+ if (!checked.ok) throw new BlenderError(BlenderErrorCode.SCENE_SPEC_INVALID,
100
+ 'The isolated preview scene is invalid.', { detail: { errors: checked.errors } })
101
+ const compiled = compileSceneSpec(scene).spec
102
+ writeFileSync(sceneSpecPath, JSON.stringify(compiled))
103
+ const result = await runtime.compileScene({ sceneSpecPath, profile: 'preview', projectRoot: directory,
104
+ jobId: `asset-compile-${randomUUID()}`, session: false, signal,
105
+ onWorkingDirectory: info => { copyFileSync(join(info.directory, 'result.blend'), checkpointPath) } })
106
+ if (result.report?.validation?.ok !== true) throw new BlenderError(BlenderErrorCode.SCENE_VALIDATION_FAILED,
107
+ 'The isolated asset failed Blender validation.', { detail: { validation: result.report?.validation ?? null } })
108
+ const polygons = result.report?.sceneFingerprint?.totalPolygons
109
+ if (!Number.isFinite(polygons) || polygons > maxMeshPolygons) throw new BlenderError(BlenderErrorCode.SCENE_TOO_HEAVY,
110
+ 'The isolated asset exceeds the mesh budget or has no measured polygon count.', { detail: { polygons, maxMeshPolygons } })
111
+ if (!existsSync(checkpointPath)) throw new BlenderError(BlenderErrorCode.REVISION_CHECKPOINT_MISSING, 'The asset preview produced no checkpoint.')
112
+ return { report: result.report, sceneHash: specHash(compiled) }
113
+ }
114
+ let scene = assetPreviewScene(asset, { environment, samples })
115
+ let compiled = await compile(scene), inspection
116
+ if (environment) {
117
+ const image = compiled.report.world?.environment?.image
118
+ if (!image || !Number.isInteger(image.width) || !Number.isInteger(image.height)) invalid('Blender returned no measured environment image dimensions.')
119
+ if (image.width > ASSET_LIBRARY_LIMITS.maxImageEdge || image.height > ASSET_LIBRARY_LIMITS.maxImageEdge
120
+ || image.width * image.height > ASSET_LIBRARY_LIMITS.maxImagePixels) {
121
+ throw new BlenderError(BlenderErrorCode.ASSET_TOO_LARGE, 'The environment image exceeds the library image budget.')
122
+ }
123
+ inspection = { kind: 'environment', image, warnings: ['This is a tone-mapped lighting preview on two spheres, not a full image thumbnail.'] }
124
+ } else {
125
+ inspection = modelInspection(compiled.report, glb)
126
+ for (const part of inspection.parts) part.assetSha256 = asset.sha256
127
+ scene = assetPreviewScene(asset, { bounds: inspection.bounds, samples })
128
+ compiled = await compile(scene)
129
+ }
130
+ signal?.throwIfAborted()
131
+ const outputPath = join(directory, 'preview.png')
132
+ const result = await runtime.renderPreview({ checkpointPath, outputPath, cameraId: 'camera-main', frame: 1,
133
+ width: ASSET_LIBRARY_LIMITS.previewWidth, height: ASSET_LIBRARY_LIMITS.previewHeight,
134
+ samples, engine: 'cycles', session: false, signal, jobId: `asset-render-${randomUUID()}` })
135
+ signal?.throwIfAborted()
136
+ return { inspection, sourceSceneSha256: compiled.sceneHash, template: ASSET_PREVIEW_TEMPLATE,
137
+ preview: { path: outputPath, width: result.report?.width, height: result.report?.height, mime: 'image/png',
138
+ renderConfig: result.report?.renderConfig ?? null, toneMapped: true } }
139
+ }
@@ -0,0 +1,328 @@
1
+ /** Browser bundle transfers own private files; only existing ingestion publishes assets. */
2
+ import { randomUUID } from 'node:crypto'
3
+ import { lstatSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from 'node:fs'
4
+ import { hostname } from 'node:os'
5
+ import { dirname, join } from 'node:path'
6
+ import { BlenderError, BlenderErrorCode } from '@deepblend/dsh-blender-contracts'
7
+ import { BUNDLE_LIMITS, BUNDLE_LOCK, bundlePath } from './asset-bundle.js'
8
+ import { streamAsset } from './asset-io.js'
9
+ import { readJsonSafe, requireSafeSegment, resolveInside } from './paths.js'
10
+
11
+ export const ASSET_UPLOAD_LIMITS = Object.freeze({ maxFiles: BUNDLE_LIMITS.files, lifetimeMs: 15 * 60 * 1000, maxActive: 1 })
12
+ const TERMINAL = new Set(['completed', 'cancelled', 'expired', 'failed'])
13
+ const invalid = (message, reason, code = BlenderErrorCode.ASSET_REQUEST_INVALID) => new BlenderError(code, message, { detail: { reason } })
14
+ const folded = value => value.normalize('NFC').toLowerCase()
15
+
16
+ /** Validate the whole manifest before creating directories or accepting any bytes. */
17
+ export function validateUploadManifest(request, maxBytes) {
18
+ if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0) throw invalid('The Host upload budget is unavailable.', 'upload-budget')
19
+ if (!Array.isArray(request?.files) || !request.files.length || request.files.length > ASSET_UPLOAD_LIMITS.maxFiles) {
20
+ throw invalid(`Select between 1 and ${ASSET_UPLOAD_LIMITS.maxFiles} files.`, 'file-count')
21
+ }
22
+ const paths = new Map(), directories = new Map()
23
+ let totalBytes = 0
24
+ const files = request.files.map((file, index) => {
25
+ const path = bundlePath(file?.path), segments = path.split('/')
26
+ if (segments.some(segment => /[. ]$/.test(segment) || /[\x7f]/.test(segment)
27
+ || /^(?:con|prn|aux|nul|com[1-9¹²³]|lpt[1-9¹²³])(?:\.|$)/i.test(segment)
28
+ || folded(segment) === BUNDLE_LOCK)) throw invalid('A selected path uses a reserved or nonportable name.', 'reserved-path')
29
+ if (!Number.isSafeInteger(file.bytes) || file.bytes < 0) throw invalid('Every selected file needs its actual nonnegative byte size.', 'file-size')
30
+ totalBytes += file.bytes
31
+ if (!Number.isSafeInteger(totalBytes) || totalBytes > maxBytes) throw invalid('The selected files exceed the configured total upload budget.', 'total-bytes', BlenderErrorCode.ASSET_TOO_LARGE)
32
+ const key = folded(path)
33
+ if (paths.has(key)) throw invalid(`Selected paths collide: ${paths.get(key)} and ${path}.`, 'duplicate-path')
34
+ paths.set(key, path)
35
+ for (let i = 1; i < segments.length; i++) {
36
+ const directory = segments.slice(0, i).join('/'), directoryKey = folded(directory)
37
+ if (directories.has(directoryKey) && directories.get(directoryKey) !== directory) {
38
+ throw invalid('Selected directory names collide after Unicode/case normalization.', 'duplicate-directory')
39
+ }
40
+ directories.set(directoryKey, directory)
41
+ }
42
+ return { id: `file-${index}`, path, bytes: file.bytes }
43
+ })
44
+ if ([...paths.keys()].some(key => directories.has(key))) throw invalid('A selected path is both a file and a directory.', 'file-directory-collision')
45
+ const entrypoint = bundlePath(request?.entrypoint)
46
+ const entry = files.find(file => file.path === entrypoint)
47
+ if (!entry || !entry.bytes || !/\.(gltf|glb|obj)$/i.test(entrypoint)) throw invalid('Choose one nonempty glTF, GLB or OBJ entrypoint from the selected files.', 'entrypoint')
48
+ if (request.license !== undefined && (typeof request.license !== 'string' || request.license.length > 200)) {
49
+ throw invalid('The optional licence source must be text of at most 200 characters.', 'license')
50
+ }
51
+ return { entrypoint, files, totalBytes, license: request.license?.trim() || null }
52
+ }
53
+
54
+ function ownerGone(owner, name) {
55
+ if (owner?.schemaVersion !== 'deepblend.upload-owner/v1' || owner.token !== name || owner.hostname !== hostname()
56
+ || !Number.isSafeInteger(owner.pid) || owner.pid <= 0) return false
57
+ try { process.kill(owner.pid, 0); return false } catch (error) { return error.code === 'ESRCH' }
58
+ }
59
+
60
+ // A rollback may remove only the directory this attempt actually created.
61
+ // An existing path (mkdir failed) or a replaced directory is never adopted.
62
+ function sameDirectory(directory, created) {
63
+ if (!created) return false
64
+ try {
65
+ const current = lstatSync(directory)
66
+ return current.isDirectory() && current.dev === created.dev && current.ino === created.ino
67
+ } catch (error) { if (error.code === 'ENOENT') return false; throw error }
68
+ }
69
+
70
+ /** State is per Host. Completed receipts are recovered from the asset ledger, including after restart. */
71
+ export class AssetUploadSessions {
72
+ constructor({ root, maxBytes, readProject, ingest, published, now = Date.now, lifetimeMs = ASSET_UPLOAD_LIMITS.lifetimeMs }) {
73
+ Object.assign(this, { root, maxBytes, readProject, ingest, published, now, lifetimeMs })
74
+ this.sessions = new Map()
75
+ this.owner = null
76
+ this.closed = false
77
+ }
78
+
79
+ _project(projectId) {
80
+ requireSafeSegment(projectId, 'project id')
81
+ this.readProject(projectId)
82
+ }
83
+
84
+ _identity(request) {
85
+ this._project(request?.projectId)
86
+ if (!/^upload-[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(request?.uploadId ?? '')) {
87
+ throw invalid('Unknown upload session. Select the files again.', 'unknown-session')
88
+ }
89
+ }
90
+
91
+ _ownerDirectory() {
92
+ if (this.owner) return join(this.root, this.owner.token)
93
+ // The base is Host-controlled and outside project artifacts. Never clean an
94
+ // owner from another machine, a live/reused PID, or malformed ownership.
95
+ resolveInside(dirname(this.root), this.root, 'upload session root')
96
+ mkdirSync(this.root, { recursive: true })
97
+ const claim = join(this.root, '.claim')
98
+ try { mkdirSync(claim) } catch (error) {
99
+ if (error.code !== 'EEXIST') throw error
100
+ throw invalid('Another Host is acquiring upload ownership, or the ownership guard needs inspection.', 'upload-host-busy')
101
+ }
102
+ try {
103
+ for (const entry of readdirSync(this.root, { withFileTypes: true })) {
104
+ if (!entry.isDirectory() || entry.name === '.claim') continue
105
+ const directory = resolveInside(this.root, entry.name, 'upload owner')
106
+ const owner = readJsonSafe(join(directory, 'owner.json'))
107
+ if (ownerGone(owner, entry.name)) rmSync(directory, { recursive: true, force: true })
108
+ else throw invalid('Another or unverified Host owns model upload storage. Finish its upload before retrying.', 'upload-host-busy')
109
+ }
110
+ const owner = { schemaVersion: 'deepblend.upload-owner/v1', token: randomUUID(), hostname: hostname(), pid: process.pid }
111
+ const directory = join(this.root, owner.token)
112
+ mkdirSync(directory)
113
+ const created = lstatSync(directory), ownerText = JSON.stringify(owner)
114
+ try { writeFileSync(join(directory, 'owner.json'), ownerText, { flag: 'wx', mode: 0o600 }) }
115
+ catch (error) {
116
+ // ENOSPC can leave a partial owner file. Only our exact serialization
117
+ // or its prefix belongs to this failed attempt; foreign content stays.
118
+ let contents = null
119
+ try { contents = readFileSync(join(directory, 'owner.json'), 'utf8') }
120
+ catch (readError) { if (readError.code === 'ENOENT') contents = '' }
121
+ if (contents !== null && ownerText.startsWith(contents) && sameDirectory(directory, created)
122
+ && readdirSync(directory).every(name => name === 'owner.json')) rmSync(directory, { recursive: true, force: true })
123
+ throw error
124
+ }
125
+ this.owner = owner
126
+ return directory
127
+ } finally { rmSync(claim, { recursive: true, force: true }) }
128
+ }
129
+
130
+ _view(session) {
131
+ return { projectId: session.projectId, uploadId: session.uploadId, status: session.status,
132
+ entrypoint: session.entrypoint, expiresAt: new Date(session.deadline).toISOString(),
133
+ totalBytes: session.totalBytes, receivedBytes: session.receivedBytes,
134
+ inFlightBytes: session.inFlightBytes, transferredBytes: session.transferredBytes,
135
+ files: session.files.map(file => ({ ...file })), limits: { maxBytes: this.maxBytes, ...ASSET_UPLOAD_LIMITS },
136
+ ...(session.receipt ? { receipt: session.receipt } : {}), ...(session.error ? { error: session.error } : {}) }
137
+ }
138
+
139
+ _releaseEmptyOwner() {
140
+ if (!this.owner || [...this.sessions.values()].some(item => !item.cleaned)) return
141
+ const directory = join(this.root, this.owner.token)
142
+ if (readJsonSafe(join(directory, 'owner.json'))?.token !== this.owner.token) { this.owner = null; return }
143
+ // Do not let parent cleanup swallow an unregistered or foreign child.
144
+ if (readdirSync(directory).some(name => name !== 'owner.json')) { this.owner = null; return }
145
+ rmSync(directory, { recursive: true, force: true })
146
+ this.owner = null
147
+ }
148
+
149
+ _clean(session) {
150
+ clearTimeout(session.timer)
151
+ rmSync(session.directory, { recursive: true, force: true })
152
+ session.cleaned = true
153
+ session.inFlightBytes = 0
154
+ this._releaseEmptyOwner()
155
+ }
156
+
157
+ _published(session) {
158
+ const receipt = this.published(session.projectId, session.uploadId)
159
+ if (receipt) {
160
+ session.status = 'completed'; session.receipt = receipt
161
+ this._clean(session)
162
+ }
163
+ return receipt
164
+ }
165
+
166
+ async _session(request) {
167
+ this._identity(request)
168
+ const session = this.sessions.get(request.uploadId)
169
+ if (!session || session.projectId !== request.projectId) throw invalid('Unknown upload session. Select the files again.', 'unknown-session')
170
+ if (!TERMINAL.has(session.status) && this.now() >= session.deadline) await this._terminate(session, 'expired')
171
+ return session
172
+ }
173
+
174
+ async create(request) {
175
+ this._project(request?.projectId)
176
+ const manifest = validateUploadManifest(request, this.maxBytes)
177
+ if (this.closed) throw invalid('Upload service has stopped.', 'service-stopped')
178
+ for (const session of this.sessions.values()) {
179
+ if (!TERMINAL.has(session.status) && this.now() >= session.deadline) await this._terminate(session, 'expired')
180
+ if (TERMINAL.has(session.status)) {
181
+ if (session.operation) await session.operation.catch(() => {})
182
+ if (!session.cleaned) this._clean(session)
183
+ }
184
+ }
185
+ if (this.closed) throw invalid('Upload service has stopped.', 'service-stopped')
186
+ if ([...this.sessions.values()].some(session => !TERMINAL.has(session.status))) throw invalid('Another model upload is active. Finish or cancel it first.', 'upload-busy')
187
+ for (const [id, session] of this.sessions) {
188
+ if (this.sessions.size < 32) break
189
+ if (TERMINAL.has(session.status)) this.sessions.delete(id)
190
+ }
191
+ request.signal?.throwIfAborted()
192
+ const uploadId = `upload-${randomUUID()}`, directory = join(this._ownerDirectory(), uploadId)
193
+ let created = null
194
+ try {
195
+ mkdirSync(directory); created = lstatSync(directory)
196
+ mkdirSync(join(directory, 'members')); mkdirSync(join(directory, 'partial'))
197
+ } catch (error) {
198
+ if (readJsonSafe(join(dirname(directory), 'owner.json'))?.token === this.owner?.token && sameDirectory(directory, created)) {
199
+ rmSync(directory, { recursive: true, force: true })
200
+ }
201
+ this._releaseEmptyOwner()
202
+ throw error
203
+ }
204
+ const session = { ...manifest, projectId: request.projectId, uploadId, assetId: `asset-${randomUUID()}`,
205
+ directory, deadline: this.now() + this.lifetimeMs, status: 'receiving', files: manifest.files.map(file => ({ ...file, received: false })),
206
+ receivedBytes: 0, transferredBytes: 0, inFlightBytes: 0, controller: new AbortController(), operation: null, operationKind: null }
207
+ session.timer = setTimeout(() => { this._terminate(session, 'expired').catch(() => {}) }, this.lifetimeMs)
208
+ session.timer.unref?.()
209
+ this.sessions.set(uploadId, session)
210
+ return this._view(session)
211
+ }
212
+
213
+ async get(request) {
214
+ this._identity(request)
215
+ const receipt = this.published(request.projectId, request.uploadId)
216
+ if (receipt) return { projectId: request.projectId, uploadId: request.uploadId, status: 'completed', receipt }
217
+ return this._view(await this._session(request))
218
+ }
219
+
220
+ async put(request) {
221
+ const session = await this._session(request)
222
+ const file = session.files.find(file => file.id === request.fileId)
223
+ if (!file) throw invalid('Unknown upload member.', 'unknown-file')
224
+ if (session.status !== 'receiving' || session.operation) throw invalid('This upload cannot receive another file now.', 'upload-busy')
225
+ if (!request.stream || typeof request.stream[Symbol.asyncIterator] !== 'function') throw invalid('Upload the member as raw bytes.', 'byte-stream')
226
+ const signal = request.signal ? AbortSignal.any([session.controller.signal, request.signal]) : session.controller.signal
227
+ signal.throwIfAborted()
228
+ const partial = join(session.directory, 'partial', randomUUID())
229
+ session.operationKind = 'file'
230
+ session.operation = Promise.resolve().then(async () => {
231
+ try {
232
+ const result = await streamAsset(request.stream, partial, {
233
+ maxBytes: Math.min(file.bytes, this.maxBytes - session.receivedBytes + (file.received ? file.bytes : 0)), signal, label: file.path,
234
+ onBytes: bytes => {
235
+ session.transferredBytes += bytes - session.inFlightBytes
236
+ session.inFlightBytes = bytes
237
+ if (this.now() >= session.deadline) {
238
+ session.status = 'expired'
239
+ session.controller.abort(invalid('Upload expired; select the files again.', 'expired'))
240
+ }
241
+ },
242
+ })
243
+ signal.throwIfAborted()
244
+ if (result.bytes !== file.bytes) throw invalid(`The received length for ${file.path} differs from the selected file size.`, 'length-mismatch', BlenderErrorCode.ASSET_CONTENT_MISMATCH)
245
+ if (file.received) {
246
+ if (file.sha256 !== result.sha256) throw invalid('A retried upload member has different bytes; it was not overwritten.', 'retry-mismatch', BlenderErrorCode.ASSET_HASH_MISMATCH)
247
+ } else {
248
+ const target = resolveInside(join(session.directory, 'members'), file.path, 'upload member')
249
+ mkdirSync(dirname(target), { recursive: true })
250
+ renameSync(partial, target)
251
+ Object.assign(file, { received: true, sha256: result.sha256 })
252
+ session.receivedBytes += result.bytes
253
+ }
254
+ session.inFlightBytes = 0
255
+ return this._view(session)
256
+ } catch (error) {
257
+ if (!TERMINAL.has(session.status)) session.status = signal.aborted ? 'cancelled' : 'failed'
258
+ session.error = { code: error.code ?? BlenderErrorCode.ASSET_REQUEST_INVALID, message: error.message }
259
+ this._clean(session)
260
+ throw error
261
+ } finally {
262
+ rmSync(partial, { force: true }); session.inFlightBytes = 0; session.operation = null; session.operationKind = null
263
+ }
264
+ })
265
+ return session.operation
266
+ }
267
+
268
+ async complete(request) {
269
+ this._identity(request)
270
+ const published = this.published(request.projectId, request.uploadId)
271
+ if (published) return { projectId: request.projectId, uploadId: request.uploadId, status: 'completed', receipt: published }
272
+ const session = await this._session(request)
273
+ if (session.operationKind === 'complete') return session.operation
274
+ if (session.status !== 'receiving' || session.operation) throw invalid('This upload cannot be completed now.', 'upload-busy')
275
+ if (session.files.some(file => !file.received)) throw invalid('Upload every selected file before completing the bundle.', 'incomplete')
276
+ const signal = request.signal ? AbortSignal.any([session.controller.signal, request.signal]) : session.controller.signal
277
+ signal.throwIfAborted()
278
+ session.status = 'completing'; session.operationKind = 'complete'
279
+ session.operation = Promise.resolve().then(async () => {
280
+ try {
281
+ await this.ingest({ projectId: session.projectId, assetId: session.assetId,
282
+ sourceRoot: join(session.directory, 'members'), sourcePath: join(session.directory, 'members', session.entrypoint),
283
+ type: session.entrypoint.split('.').at(-1).toLowerCase(), license: session.license, signal },
284
+ { kind: 'upload', name: session.entrypoint, uploadId: session.uploadId,
285
+ files: session.files.map(({ path, bytes, sha256 }) => ({ path, bytes, sha256 })) })
286
+ if (!this._published(session)) throw invalid('The completed upload is missing its asset receipt.', 'missing-receipt')
287
+ return this._view(session)
288
+ } catch (error) {
289
+ // A committed ledger entry survives a dropped/failed response. Never
290
+ // delete published content or re-import it merely to obtain a receipt.
291
+ if (this._published(session)) return this._view(session)
292
+ if (!TERMINAL.has(session.status)) session.status = signal.aborted ? 'cancelled' : 'failed'
293
+ session.error = { code: error.code ?? BlenderErrorCode.ASSET_REQUEST_INVALID, message: error.message }
294
+ this._clean(session)
295
+ throw error
296
+ } finally { session.operation = null; session.operationKind = null }
297
+ })
298
+ return session.operation
299
+ }
300
+
301
+ async _terminate(session, status) {
302
+ if (session.status === 'completed') return this._view(session)
303
+ if (!TERMINAL.has(session.status)) session.status = status
304
+ session.controller.abort(invalid(status === 'expired' ? 'Upload expired; select the files again.' : 'Upload cancelled.', status))
305
+ await session.operation?.catch(() => {})
306
+ if (!this._published(session)) this._clean(session)
307
+ return this._view(session)
308
+ }
309
+
310
+ async cancel(request) {
311
+ this._identity(request)
312
+ const receipt = this.published(request.projectId, request.uploadId)
313
+ if (receipt) return { projectId: request.projectId, uploadId: request.uploadId, status: 'completed', receipt }
314
+ // An unfinished session can disappear after restart or terminal eviction.
315
+ // Report only that this Host cannot continue it; do not inspect or delete
316
+ // unknown storage, or claim that another Host's work was cancelled.
317
+ if (!this.sessions.has(request.uploadId)) return {
318
+ projectId: request.projectId, uploadId: request.uploadId, status: 'unavailable', reason: 'unknown-session',
319
+ }
320
+ return this._terminate(await this._session(request), 'cancelled')
321
+ }
322
+
323
+ async dispose() {
324
+ this.closed = true
325
+ await Promise.all([...this.sessions.values()].map(session => this._terminate(session, 'cancelled')))
326
+ this._releaseEmptyOwner()
327
+ }
328
+ }
@@ -103,7 +103,12 @@ export function buildDeliveryManifest(input) {
103
103
 
104
104
  render: {
105
105
  profileName: record.profileName ?? null,
106
- config: record.renderConfig ?? null,
106
+ // The old job config remains the default for a later resume. Only known,
107
+ // uniform frame settings can describe the entire delivered sequence.
108
+ config: input.provenance?.coverage === 'complete' && input.provenance?.configuration === 'uniform'
109
+ ? input.provenance.groups[0].renderConfig : null,
110
+ defaultConfig: record.renderConfig ?? null,
111
+ provenance: input.provenance ?? null,
107
112
  cameraId: record.cameraId ?? null,
108
113
  meanMsPerFrame: record.meanMsPerFrame ?? null,
109
114
  renderDurationMs: record.renderDurationMs ?? null,