@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,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
|
+
}
|
package/lib/delivery-manifest.js
CHANGED
|
@@ -103,7 +103,12 @@ export function buildDeliveryManifest(input) {
|
|
|
103
103
|
|
|
104
104
|
render: {
|
|
105
105
|
profileName: record.profileName ?? null,
|
|
106
|
-
config
|
|
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,
|