@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.
- 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,49 @@
|
|
|
1
|
+
/** Short, bounded publication leases for emitted artifacts on a local filesystem. */
|
|
2
|
+
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
3
|
+
import { setTimeout } from 'node:timers/promises'
|
|
4
|
+
import { BlenderError, BlenderErrorCode } from '@deepblend/dsh-blender-contracts'
|
|
5
|
+
import { assertProjectWriter, withProjectWriter } from './project-writer.js'
|
|
6
|
+
|
|
7
|
+
const ownership = new AsyncLocalStorage()
|
|
8
|
+
|
|
9
|
+
export async function withArtifactWriter(directory, workspaceRoot, action, options = {}) {
|
|
10
|
+
const { signal, timeoutMs = 10_000 } = options
|
|
11
|
+
const checkCancelled = () => {
|
|
12
|
+
if (signal?.aborted) throw new BlenderError(BlenderErrorCode.ABORTED, 'Artifact publication was cancelled.')
|
|
13
|
+
}
|
|
14
|
+
checkCancelled()
|
|
15
|
+
// Only calls in the owning async chain may join its lease. A second Host in
|
|
16
|
+
// this process must wait just like a second OS process; a global flag is unsafe.
|
|
17
|
+
if (ownership.getStore()?.has(directory)) {
|
|
18
|
+
assertProjectWriter(directory)
|
|
19
|
+
return action()
|
|
20
|
+
}
|
|
21
|
+
const deadline = performance.now() + timeoutMs
|
|
22
|
+
for (;;) {
|
|
23
|
+
checkCancelled()
|
|
24
|
+
let entered = false
|
|
25
|
+
try {
|
|
26
|
+
// The existing lease primitive also covers dead-owner recovery and release.
|
|
27
|
+
// The revision directory is a different key from the project decision lock.
|
|
28
|
+
return await withProjectWriter(directory, workspaceRoot, () => {
|
|
29
|
+
entered = true
|
|
30
|
+
const parents = new Set(ownership.getStore() ?? [])
|
|
31
|
+
parents.add(directory)
|
|
32
|
+
return ownership.run(parents, action)
|
|
33
|
+
})
|
|
34
|
+
} catch (cause) {
|
|
35
|
+
// Retrying an action could duplicate its file changes. Retry acquisition only.
|
|
36
|
+
if (entered || cause.code !== BlenderErrorCode.REVISION_CONFLICT) throw cause
|
|
37
|
+
if (performance.now() >= deadline) {
|
|
38
|
+
throw new BlenderError(BlenderErrorCode.REVISION_CONFLICT,
|
|
39
|
+
'Artifact publication could not acquire its revision lease within the wait limit. Retry after the current publisher finishes.',
|
|
40
|
+
{ detail: { ...cause.detail, timeoutMs } })
|
|
41
|
+
}
|
|
42
|
+
try { await setTimeout(Math.min(10, Math.max(1, deadline - performance.now())), undefined, { signal }) }
|
|
43
|
+
catch (cause) {
|
|
44
|
+
checkCancelled()
|
|
45
|
+
throw cause
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
/** Immutable model resource snapshots. File URIs are resolved only inside a supplied local root. */
|
|
2
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
3
|
+
import { createReadStream, closeSync, existsSync, openSync, readSync, fstatSync, linkSync, mkdirSync, renameSync, statSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { dirname, join, posix, relative, resolve, sep } from 'node:path';
|
|
5
|
+
import { BlenderError, BlenderErrorCode } from '@deepblend/dsh-blender-contracts';
|
|
6
|
+
import { streamAsset } from './asset-io.js';
|
|
7
|
+
import { fileSha256, removeTree, resolveInside } from './paths.js';
|
|
8
|
+
import { objLibraries, mtlImages } from './obj-resources.js';
|
|
9
|
+
export const BUNDLE_LOCK = '.deepblend-lock.json';
|
|
10
|
+
export const BUNDLE_LIMITS = Object.freeze({ files: 256, lockBytes: 1024 * 1024, jsonBytes: 16 * 1024 * 1024, chunks: 1024 });
|
|
11
|
+
const digest = bytes => createHash('sha256').update(bytes).digest('hex');
|
|
12
|
+
const reject = (message, code = BlenderErrorCode.ASSET_REQUEST_INVALID) => { throw new BlenderError(code, message); };
|
|
13
|
+
export function bundlePath(path) {
|
|
14
|
+
if (typeof path !== 'string' || !path || path.length > 1024 || path.includes('\\') || /[\0-\x1f]/.test(path)
|
|
15
|
+
|| path.startsWith('/') || path.split('/').some(p => !p || p === '.' || p === '..' || /[:<>"|?*]/.test(p)))
|
|
16
|
+
reject('Asset bundle members need contained, portable relative paths.');
|
|
17
|
+
return path;
|
|
18
|
+
}
|
|
19
|
+
export function gltfResources(document, entrypoint, binBytes = null) {
|
|
20
|
+
if (!document || Array.isArray(document) || document.asset?.version !== '2.0')
|
|
21
|
+
reject('The glTF JSON must declare version 2.0.', BlenderErrorCode.ASSET_CONTENT_MISMATCH);
|
|
22
|
+
const paths = new Set();
|
|
23
|
+
for (const key of ['buffers', 'images']) {
|
|
24
|
+
if (document[key] !== undefined && !Array.isArray(document[key]))
|
|
25
|
+
reject(`glTF ${key} must be an array.`);
|
|
26
|
+
for (const [index, item] of (document[key] ?? []).entries()) {
|
|
27
|
+
if (!item || typeof item !== 'object' || Array.isArray(item))
|
|
28
|
+
reject(`Invalid glTF ${key} entry.`);
|
|
29
|
+
const uri = item.uri;
|
|
30
|
+
if (uri === undefined) {
|
|
31
|
+
if (key === 'buffers' && (index !== 0 || binBytes === null || !Number.isSafeInteger(item.byteLength)
|
|
32
|
+
|| item.byteLength < 0 || item.byteLength > binBytes || binBytes - item.byteLength > 3))
|
|
33
|
+
reject('An embedded buffer requires the first GLB buffer and a matching BIN chunk.');
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
if (typeof uri !== 'string' || !uri)
|
|
37
|
+
reject('glTF resource URIs must be nonempty strings.');
|
|
38
|
+
if (uri.startsWith('data:')) {
|
|
39
|
+
if (!/^data:[^,]*;base64,[A-Za-z0-9+/]*={0,2}$/.test(uri) || uri.split(',')[1].length % 4)
|
|
40
|
+
reject('glTF embedded resources need valid base64 data URIs.');
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
if (uri.startsWith('/') || uri.includes('\\') || /[?#]/.test(uri) || /^[a-z][a-z0-9+.-]*:/i.test(uri))
|
|
44
|
+
reject('glTF resources must use relative file URIs or embedded data. No dependency URLs are fetched.');
|
|
45
|
+
let decoded;
|
|
46
|
+
try {
|
|
47
|
+
decoded = decodeURIComponent(uri);
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
reject('A glTF resource URI has invalid percent encoding.');
|
|
51
|
+
}
|
|
52
|
+
if (decoded.startsWith('/') || decoded.includes('\\') || /[\0-\x1f]/.test(decoded))
|
|
53
|
+
reject('Invalid glTF resource path.');
|
|
54
|
+
const path = bundlePath(posix.normalize(posix.join(posix.dirname(entrypoint), decoded)));
|
|
55
|
+
if (path === BUNDLE_LOCK)
|
|
56
|
+
reject('A glTF resource collides with the reserved bundle lock.');
|
|
57
|
+
paths.add(path);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
// Core buffers/images cover the importer-facing resource graph in this profile.
|
|
61
|
+
// Refuse additional external URI fields in extensions rather than leave them unlocked.
|
|
62
|
+
const queue = [{ value: document, extension: false }];
|
|
63
|
+
for (let index = 0; index < queue.length; index++) {
|
|
64
|
+
if (index > 500000)
|
|
65
|
+
reject('The glTF document exceeds the resource inspection limit.', BlenderErrorCode.ASSET_TOO_LARGE);
|
|
66
|
+
const { value, extension } = queue[index];
|
|
67
|
+
if (!value || typeof value !== 'object')
|
|
68
|
+
continue;
|
|
69
|
+
if (extension && typeof value.uri === 'string' && !/^data:/i.test(value.uri))
|
|
70
|
+
reject('External URI resources inside glTF extensions need a supported dependency profile.');
|
|
71
|
+
for (const [key, child] of Object.entries(value))
|
|
72
|
+
if (child && typeof child === 'object')
|
|
73
|
+
queue.push({ value: child, extension: extension || key === 'extensions' });
|
|
74
|
+
}
|
|
75
|
+
if (paths.size + 1 > BUNDLE_LIMITS.files)
|
|
76
|
+
reject('The glTF bundle exceeds the resource file limit.', BlenderErrorCode.ASSET_TOO_LARGE);
|
|
77
|
+
const folded = new Set();
|
|
78
|
+
for (const path of [entrypoint, ...paths]) {
|
|
79
|
+
const key = path.toLowerCase();
|
|
80
|
+
if (folded.has(key) && path !== entrypoint)
|
|
81
|
+
reject('glTF resource paths collide on case-insensitive filesystems.');
|
|
82
|
+
folded.add(key);
|
|
83
|
+
}
|
|
84
|
+
return [...paths].sort();
|
|
85
|
+
}
|
|
86
|
+
function readBounded(file, limit) {
|
|
87
|
+
const fd = openSync(file, 'r'), chunks = [];
|
|
88
|
+
let bytes = 0;
|
|
89
|
+
try {
|
|
90
|
+
const buffer = Buffer.alloc(64 * 1024);
|
|
91
|
+
for (;;) {
|
|
92
|
+
const size = readSync(fd, buffer, 0, buffer.length, null);
|
|
93
|
+
if (!size)
|
|
94
|
+
break;
|
|
95
|
+
bytes += size;
|
|
96
|
+
if (bytes > limit)
|
|
97
|
+
reject('Asset bundle metadata exceeds its byte limit.', BlenderErrorCode.ASSET_TOO_LARGE);
|
|
98
|
+
chunks.push(Buffer.from(buffer.subarray(0, size)));
|
|
99
|
+
}
|
|
100
|
+
return Buffer.concat(chunks, bytes);
|
|
101
|
+
}
|
|
102
|
+
finally {
|
|
103
|
+
closeSync(fd);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
function parseJson(bytes) { return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes)); }
|
|
107
|
+
function objReference(entrypoint, name) {
|
|
108
|
+
if (!name || name.startsWith('/') || name.includes('\\') || /^[a-z][a-z0-9+.-]*:/i.test(name))
|
|
109
|
+
reject('OBJ resources require portable relative file paths. No dependency URLs are fetched.');
|
|
110
|
+
const path = bundlePath(posix.normalize(posix.join(posix.dirname(entrypoint), name)));
|
|
111
|
+
if (path === BUNDLE_LOCK) reject('An OBJ resource collides with the reserved bundle lock.');
|
|
112
|
+
return path;
|
|
113
|
+
}
|
|
114
|
+
/** Includes Blender's same-basename MTL lookup as well as explicit libraries. */
|
|
115
|
+
export function objResources(root, entrypoint, { mainFile, signal, missingCode = BlenderErrorCode.ASSET_MISSING, implicit = true } = {}) {
|
|
116
|
+
const main = mainFile ?? resolveInside(root, entrypoint, 'OBJ entrypoint');
|
|
117
|
+
const libraries = new Set(objLibraries(main, { signal }).map(name => objReference(entrypoint, name)));
|
|
118
|
+
const fallback = entrypoint.replace(/\.[^/.]+$/, '.mtl');
|
|
119
|
+
if (implicit && existsSync(resolveInside(root, fallback, 'OBJ implicit library'))) libraries.add(fallback);
|
|
120
|
+
const paths = new Set(libraries), folded = new Map([[entrypoint.toLowerCase(), entrypoint]]);
|
|
121
|
+
const check = path => {
|
|
122
|
+
const previous = folded.get(path.toLowerCase());
|
|
123
|
+
if (previous && previous !== path) reject('OBJ resource paths collide on case-insensitive filesystems.');
|
|
124
|
+
folded.set(path.toLowerCase(), path);
|
|
125
|
+
if (new Set([entrypoint, ...paths]).size > BUNDLE_LIMITS.files) reject('The OBJ bundle exceeds the resource file limit.', BlenderErrorCode.ASSET_TOO_LARGE);
|
|
126
|
+
};
|
|
127
|
+
for (const library of libraries) {
|
|
128
|
+
signal?.throwIfAborted(); check(library);
|
|
129
|
+
const file = resolveInside(root, library, 'OBJ material library');
|
|
130
|
+
if (!existsSync(file) || !statSync(file).isFile()) reject(`The OBJ material library ${library} is missing.`, missingCode);
|
|
131
|
+
for (const name of mtlImages(file, { signal })) { const path = objReference(library, name); paths.add(path); check(path); }
|
|
132
|
+
}
|
|
133
|
+
for (const path of paths) check(path);
|
|
134
|
+
return [...paths].sort();
|
|
135
|
+
}
|
|
136
|
+
/** Read only JSON and chunk headers. Large GLB binary payloads stay on disk. */
|
|
137
|
+
export function readGltfDocument(file, { format = 'gltf', signal } = {}) {
|
|
138
|
+
try {
|
|
139
|
+
signal?.throwIfAborted();
|
|
140
|
+
if (format === 'gltf')
|
|
141
|
+
return { document: parseJson(readBounded(file, BUNDLE_LIMITS.jsonBytes)), binBytes: null };
|
|
142
|
+
if (format !== 'glb')
|
|
143
|
+
reject('Unsupported asset bundle format.');
|
|
144
|
+
const fd = openSync(file, 'r');
|
|
145
|
+
try {
|
|
146
|
+
const size = fstatSync(fd).size;
|
|
147
|
+
const read = (offset, length) => {
|
|
148
|
+
const bytes = Buffer.alloc(length);
|
|
149
|
+
let received = 0;
|
|
150
|
+
while (received < length) {
|
|
151
|
+
signal?.throwIfAborted();
|
|
152
|
+
const n = readSync(fd, bytes, received, length - received, offset + received);
|
|
153
|
+
if (!n)
|
|
154
|
+
reject('The GLB is truncated.', BlenderErrorCode.ASSET_CONTENT_MISMATCH);
|
|
155
|
+
received += n;
|
|
156
|
+
}
|
|
157
|
+
return bytes;
|
|
158
|
+
};
|
|
159
|
+
if (size < 20)
|
|
160
|
+
reject('The GLB header is truncated.', BlenderErrorCode.ASSET_CONTENT_MISMATCH);
|
|
161
|
+
const header = read(0, 12);
|
|
162
|
+
if (header.toString('ascii', 0, 4) !== 'glTF' || header.readUInt32LE(4) !== 2 || header.readUInt32LE(8) !== size)
|
|
163
|
+
reject('The GLB must be a complete version 2 container.', BlenderErrorCode.ASSET_CONTENT_MISMATCH);
|
|
164
|
+
let offset = 12, document = null, binBytes = null, chunks = 0, jsonSeen = false;
|
|
165
|
+
while (offset < size) {
|
|
166
|
+
signal?.throwIfAborted();
|
|
167
|
+
if (++chunks > BUNDLE_LIMITS.chunks)
|
|
168
|
+
reject('The GLB exceeds the chunk inspection limit.', BlenderErrorCode.ASSET_TOO_LARGE);
|
|
169
|
+
if (offset + 8 > size)
|
|
170
|
+
reject('The GLB chunk header is truncated.', BlenderErrorCode.ASSET_CONTENT_MISMATCH);
|
|
171
|
+
const chunk = read(offset, 8), length = chunk.readUInt32LE(0), kind = chunk.readUInt32LE(4);
|
|
172
|
+
if (length % 4 || offset + 8 + length > size)
|
|
173
|
+
reject('The GLB chunk length is invalid.', BlenderErrorCode.ASSET_CONTENT_MISMATCH);
|
|
174
|
+
if (chunks === 1 && kind !== 0x4e4f534a)
|
|
175
|
+
reject('The first GLB chunk must contain JSON.', BlenderErrorCode.ASSET_CONTENT_MISMATCH);
|
|
176
|
+
if (kind === 0x4e4f534a) {
|
|
177
|
+
if (jsonSeen)
|
|
178
|
+
reject('The GLB contains duplicate JSON chunks.', BlenderErrorCode.ASSET_CONTENT_MISMATCH);
|
|
179
|
+
if (length > BUNDLE_LIMITS.jsonBytes)
|
|
180
|
+
reject('The GLB JSON exceeds 16 MiB.', BlenderErrorCode.ASSET_TOO_LARGE);
|
|
181
|
+
jsonSeen = true;
|
|
182
|
+
document = parseJson(read(offset + 8, length));
|
|
183
|
+
}
|
|
184
|
+
else if (kind === 0x004e4942) {
|
|
185
|
+
if (chunks !== 2 || binBytes !== null)
|
|
186
|
+
reject('The GLB BIN must be its second chunk.', BlenderErrorCode.ASSET_CONTENT_MISMATCH);
|
|
187
|
+
binBytes = length;
|
|
188
|
+
}
|
|
189
|
+
offset += 8 + length;
|
|
190
|
+
}
|
|
191
|
+
if (!document || Array.isArray(document) || document.asset?.version !== '2.0')
|
|
192
|
+
reject('The GLB JSON must declare glTF 2.0.', BlenderErrorCode.ASSET_CONTENT_MISMATCH);
|
|
193
|
+
return { document, binBytes };
|
|
194
|
+
}
|
|
195
|
+
finally {
|
|
196
|
+
closeSync(fd);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
catch (error) {
|
|
200
|
+
signal?.throwIfAborted();
|
|
201
|
+
if (error instanceof BlenderError)
|
|
202
|
+
throw error;
|
|
203
|
+
reject('The glTF metadata cannot be parsed.', BlenderErrorCode.ASSET_CONTENT_MISMATCH);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
export function verifyAssetBundle(projectRoot, asset) {
|
|
207
|
+
const match = /^assets\/bundles\/([a-f0-9]{64})\/(.+)$/.exec(asset.path ?? '');
|
|
208
|
+
if (!match)
|
|
209
|
+
return null;
|
|
210
|
+
const root = resolveInside(projectRoot, `assets/bundles/${match[1]}`, 'asset bundle'), lock = resolveInside(root, BUNDLE_LOCK, 'asset bundle lock');
|
|
211
|
+
if (!existsSync(lock))
|
|
212
|
+
reject('The asset bundle lock is missing.', BlenderErrorCode.ASSET_MISSING);
|
|
213
|
+
const bytes = readBounded(lock, BUNDLE_LIMITS.lockBytes);
|
|
214
|
+
if (digest(bytes) !== match[1])
|
|
215
|
+
reject('The asset bundle lock changed.', BlenderErrorCode.ASSET_HASH_MISMATCH);
|
|
216
|
+
let manifest;
|
|
217
|
+
try {
|
|
218
|
+
manifest = parseJson(bytes);
|
|
219
|
+
}
|
|
220
|
+
catch {
|
|
221
|
+
reject('The asset bundle lock cannot be parsed.');
|
|
222
|
+
}
|
|
223
|
+
if (manifest?.schemaVersion !== 'deepblend.asset-bundle/v1' || manifest.entrypoint !== match[2]
|
|
224
|
+
|| !Array.isArray(manifest.files) || !manifest.files.length || manifest.files.length > BUNDLE_LIMITS.files)
|
|
225
|
+
reject('The asset bundle lock has an invalid shape or entrypoint.');
|
|
226
|
+
bundlePath(manifest.entrypoint);
|
|
227
|
+
const format = manifest.format ?? 'gltf';
|
|
228
|
+
if (!['gltf', 'glb', 'obj'].includes(format) || (asset.type && asset.type !== format))
|
|
229
|
+
reject('The asset bundle format does not match its declaration.');
|
|
230
|
+
const members = new Map(), folded = new Set();
|
|
231
|
+
let total = 0;
|
|
232
|
+
for (const member of manifest.files) {
|
|
233
|
+
if (!member || typeof member !== 'object' || Array.isArray(member))
|
|
234
|
+
reject('Invalid asset bundle member.');
|
|
235
|
+
const path = bundlePath(member.path);
|
|
236
|
+
if (path === BUNDLE_LOCK || members.has(path) || folded.has(path.toLowerCase()) || !/^[a-f0-9]{64}$/.test(member.sha256 ?? '') || !Number.isSafeInteger(member.bytes) || member.bytes < 0)
|
|
237
|
+
reject('The asset bundle lock contains invalid or duplicate members.');
|
|
238
|
+
const file = resolveInside(root, path, 'asset bundle member');
|
|
239
|
+
if (!existsSync(file))
|
|
240
|
+
reject(`The asset bundle member ${path} is missing.`, BlenderErrorCode.ASSET_MISSING);
|
|
241
|
+
if (!statSync(file).isFile() || statSync(file).size !== member.bytes || fileSha256(file) !== member.sha256)
|
|
242
|
+
reject(`The asset bundle member ${path} changed.`, BlenderErrorCode.ASSET_HASH_MISMATCH);
|
|
243
|
+
members.set(path, member);
|
|
244
|
+
folded.add(path.toLowerCase());
|
|
245
|
+
total += member.bytes;
|
|
246
|
+
}
|
|
247
|
+
if (!Number.isSafeInteger(total) || total !== manifest.totalBytes || !members.has(manifest.entrypoint)
|
|
248
|
+
|| (asset.sha256 && asset.sha256 !== members.get(manifest.entrypoint).sha256))
|
|
249
|
+
reject('The asset bundle identity does not agree with its declaration.', BlenderErrorCode.ASSET_HASH_MISMATCH);
|
|
250
|
+
const content = format === 'obj' ? null : readGltfDocument(resolveInside(root, manifest.entrypoint, 'glTF entrypoint'), { format });
|
|
251
|
+
const resources = format === 'obj' ? objResources(root, manifest.entrypoint) : gltfResources(content.document, manifest.entrypoint, content.binBytes);
|
|
252
|
+
for (const path of resources)
|
|
253
|
+
if (!members.has(path))
|
|
254
|
+
reject(`The glTF resource ${path} is not locked.`);
|
|
255
|
+
return manifest;
|
|
256
|
+
}
|
|
257
|
+
export function verifyUnbundledObjAsset(projectRoot, asset) {
|
|
258
|
+
if (asset.type !== 'obj' || /^assets\/bundles\/[a-f0-9]{64}\//.test(asset.path ?? '')) return;
|
|
259
|
+
const file = resolveInside(projectRoot, asset.path, 'OBJ asset');
|
|
260
|
+
const fallback = asset.path.replace(/\.[^/.]+$/, '.mtl');
|
|
261
|
+
if (objLibraries(file).length || existsSync(resolveInside(projectRoot, fallback, 'OBJ implicit library')))
|
|
262
|
+
reject('This OBJ has unlocked material resources. Reimport the complete local source with a containing sourceRoot.');
|
|
263
|
+
}
|
|
264
|
+
/** Legacy files can rebuild only when the main file contains all core resources. */
|
|
265
|
+
export function verifyUnbundledGltfAsset(projectRoot, asset) {
|
|
266
|
+
if (!['gltf', 'glb'].includes(asset.type) || /^assets\/bundles\/[a-f0-9]{64}\//.test(asset.path ?? ''))
|
|
267
|
+
return;
|
|
268
|
+
const file = resolveInside(projectRoot, asset.path, 'glTF asset');
|
|
269
|
+
const content = readGltfDocument(file, { format: asset.type });
|
|
270
|
+
if (gltfResources(content.document, posix.basename(asset.path), content.binBytes).length)
|
|
271
|
+
reject('This glTF/GLB has unlocked external resources. Reimport its local source with a containing sourceRoot to create a resource snapshot.');
|
|
272
|
+
}
|
|
273
|
+
/** Dependency identity is encoded in the managed path, independently of main-file SHA. */
|
|
274
|
+
export function assetPreviewVersion(asset) {
|
|
275
|
+
return /^assets\/bundles\/([a-f0-9]{64})\//.exec(asset.path ?? '')?.[1] ?? asset.sha256;
|
|
276
|
+
}
|
|
277
|
+
/** Copy and reverify a complete immutable snapshot in the disposable preview root. */
|
|
278
|
+
export async function stageAssetBundle(projectRoot, directory, asset, { maxBytes, signal } = {}) {
|
|
279
|
+
signal?.throwIfAborted();
|
|
280
|
+
const manifest = verifyAssetBundle(projectRoot, asset);
|
|
281
|
+
if (!manifest) return false;
|
|
282
|
+
const prefix = `assets/bundles/${assetPreviewVersion(asset)}`;
|
|
283
|
+
let total = 0;
|
|
284
|
+
for (const path of [...manifest.files.map(member => member.path), BUNDLE_LOCK]) {
|
|
285
|
+
signal?.throwIfAborted();
|
|
286
|
+
const source = resolveInside(projectRoot, `${prefix}/${path}`, 'preview bundle source');
|
|
287
|
+
const target = resolveInside(directory, `${prefix}/${path}`, 'preview bundle copy');
|
|
288
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
289
|
+
const copied = await streamAsset(createReadStream(source), target, { maxBytes: maxBytes - total, signal, label: path });
|
|
290
|
+
total += copied.bytes;
|
|
291
|
+
const expected = manifest.files.find(member => member.path === path);
|
|
292
|
+
if (expected && (expected.bytes !== copied.bytes || expected.sha256 !== copied.sha256))
|
|
293
|
+
reject('The asset bundle changed while preparing its preview.', BlenderErrorCode.ASSET_HASH_MISMATCH);
|
|
294
|
+
}
|
|
295
|
+
verifyAssetBundle(directory, asset);
|
|
296
|
+
return true;
|
|
297
|
+
}
|
|
298
|
+
export async function prepareModelBundle({ projectRoot, sourcePath, name, sourceRoot, local, maxBytes, signal, type = 'gltf', stagingRoot }) {
|
|
299
|
+
if (sourceRoot !== undefined && (!local || typeof sourceRoot !== 'string' || !sourceRoot))
|
|
300
|
+
reject('sourceRoot requires a local model source directory.');
|
|
301
|
+
const sourceBase = resolve(sourceRoot ?? dirname(sourcePath)), entrypoint = bundlePath(local ? relative(sourceBase, resolve(sourcePath)).split(sep).join('/') : name);
|
|
302
|
+
resolveInside(sourceBase, sourcePath, 'model source');
|
|
303
|
+
const parent = resolveInside(projectRoot, 'assets/bundles', 'asset bundle directory');
|
|
304
|
+
mkdirSync(parent, { recursive: true });
|
|
305
|
+
// Browser upload sessions supply a Host-owned staging root on the same
|
|
306
|
+
// project filesystem. Their partial files stay inside that session.
|
|
307
|
+
const staging = stagingRoot ? resolveInside(stagingRoot, `.bundle-${randomUUID()}`, 'bundle preparation') : join(parent, `.incoming-${randomUUID()}`);
|
|
308
|
+
mkdirSync(staging);
|
|
309
|
+
try {
|
|
310
|
+
const files = [];
|
|
311
|
+
let totalBytes = 0;
|
|
312
|
+
const copy = async (path, sourceOverride) => {
|
|
313
|
+
signal?.throwIfAborted();
|
|
314
|
+
const source = resolveInside(sourceBase, sourceOverride ?? path, 'model resource'), target = resolveInside(staging, path, 'staged model resource');
|
|
315
|
+
if (!existsSync(source) || !statSync(source).isFile())
|
|
316
|
+
reject(`The local model resource ${path} is missing.`, BlenderErrorCode.ASSET_SOURCE_NOT_FOUND);
|
|
317
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
318
|
+
const result = await streamAsset(createReadStream(source), target, { maxBytes: maxBytes - totalBytes, signal, label: path });
|
|
319
|
+
totalBytes += result.bytes;
|
|
320
|
+
files.push({ path, ...result });
|
|
321
|
+
};
|
|
322
|
+
await copy(entrypoint, local ? undefined : sourcePath);
|
|
323
|
+
const content = type === 'obj' ? null : readGltfDocument(join(staging, entrypoint), { format: type, signal });
|
|
324
|
+
if (type === 'obj' && !local && objLibraries(join(staging, entrypoint), { signal }).length)
|
|
325
|
+
reject('Remote OBJ material dependencies are not fetched. Import a complete local resource directory.');
|
|
326
|
+
const resources = type === 'obj'
|
|
327
|
+
? objResources(sourceBase, entrypoint, { mainFile: join(staging, entrypoint), signal, implicit: local, missingCode: BlenderErrorCode.ASSET_SOURCE_NOT_FOUND })
|
|
328
|
+
: gltfResources(content.document, entrypoint, content.binBytes);
|
|
329
|
+
if (!local && resources.length)
|
|
330
|
+
reject('Remote glTF dependencies are not fetched. Import a local resource directory or a self-contained file.');
|
|
331
|
+
for (const path of resources)
|
|
332
|
+
if (path !== entrypoint)
|
|
333
|
+
await copy(path);
|
|
334
|
+
if (type === 'obj') {
|
|
335
|
+
const copied = new Set(files.map(file => file.path));
|
|
336
|
+
for (const path of objResources(staging, entrypoint, { signal }))
|
|
337
|
+
if (!copied.has(path)) reject('An OBJ material reference changed during import.', BlenderErrorCode.ASSET_HASH_MISMATCH);
|
|
338
|
+
}
|
|
339
|
+
if (type === 'glb' && resources.length === 0) {
|
|
340
|
+
const main = files[0], relativePath = `assets/raw/${main.sha256}.glb`;
|
|
341
|
+
return { staging, relativePath, sourceRoot: local ? sourceBase : null, bytes: main.bytes, sha256: main.sha256, bundle: null, publish() {
|
|
342
|
+
const destination = resolveInside(projectRoot, relativePath, 'GLB destination');
|
|
343
|
+
mkdirSync(dirname(destination), { recursive: true });
|
|
344
|
+
try {
|
|
345
|
+
linkSync(join(staging, entrypoint), destination);
|
|
346
|
+
}
|
|
347
|
+
catch (error) {
|
|
348
|
+
if (error.code !== 'EEXIST')
|
|
349
|
+
throw error;
|
|
350
|
+
if (fileSha256(destination) !== main.sha256)
|
|
351
|
+
reject('The stored GLB changed.', BlenderErrorCode.ASSET_HASH_MISMATCH);
|
|
352
|
+
}
|
|
353
|
+
return destination;
|
|
354
|
+
} };
|
|
355
|
+
}
|
|
356
|
+
files.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
|
|
357
|
+
const manifest = { schemaVersion: 'deepblend.asset-bundle/v1', ...(type !== 'gltf' ? { format: type } : {}), entrypoint, files, totalBytes }, lockBytes = Buffer.from(JSON.stringify(manifest) + '\n');
|
|
358
|
+
if (totalBytes + lockBytes.length > maxBytes)
|
|
359
|
+
reject('The model bundle and lock exceed assetMaxBytes.', BlenderErrorCode.ASSET_TOO_LARGE);
|
|
360
|
+
if (lockBytes.length > BUNDLE_LIMITS.lockBytes)
|
|
361
|
+
reject('The asset bundle lock exceeds its byte limit.', BlenderErrorCode.ASSET_TOO_LARGE);
|
|
362
|
+
writeFileSync(join(staging, BUNDLE_LOCK), lockBytes, { flag: 'wx' });
|
|
363
|
+
const bundleSha256 = digest(lockBytes), relativePath = `assets/bundles/${bundleSha256}/${entrypoint}`, main = files.find(file => file.path === entrypoint);
|
|
364
|
+
return { staging, relativePath, sourceRoot: local ? sourceBase : null, bytes: main.bytes, sha256: main.sha256, bundle: { sha256: bundleSha256, ...manifest }, publish() {
|
|
365
|
+
const destination = resolveInside(projectRoot, `assets/bundles/${bundleSha256}`, 'asset bundle destination');
|
|
366
|
+
try {
|
|
367
|
+
renameSync(staging, destination);
|
|
368
|
+
}
|
|
369
|
+
catch (error) {
|
|
370
|
+
if (!['EEXIST', 'ENOTEMPTY'].includes(error.code))
|
|
371
|
+
throw error;
|
|
372
|
+
verifyAssetBundle(projectRoot, { path: relativePath, sha256: main.sha256, type });
|
|
373
|
+
}
|
|
374
|
+
return destination;
|
|
375
|
+
} };
|
|
376
|
+
}
|
|
377
|
+
catch (error) {
|
|
378
|
+
removeTree(staging);
|
|
379
|
+
throw error;
|
|
380
|
+
}
|
|
381
|
+
}
|
package/lib/asset-io.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** Bounded asset streaming. The digest covers exactly the bytes written. */
|
|
2
|
+
import { createHash } from 'node:crypto'
|
|
3
|
+
import { createWriteStream } from 'node:fs'
|
|
4
|
+
import { Transform } from 'node:stream'
|
|
5
|
+
import { pipeline } from 'node:stream/promises'
|
|
6
|
+
import { BlenderError, BlenderErrorCode } from '@deepblend/dsh-blender-contracts'
|
|
7
|
+
|
|
8
|
+
export async function streamAsset(source, target, { maxBytes, signal, label = 'asset', onBytes }) {
|
|
9
|
+
const hash = createHash('sha256')
|
|
10
|
+
let bytes = 0
|
|
11
|
+
const meter = new Transform({
|
|
12
|
+
transform(chunk, encoding, callback) {
|
|
13
|
+
bytes += chunk.length
|
|
14
|
+
try { onBytes?.(bytes) } catch (error) { callback(error); return }
|
|
15
|
+
if (bytes > maxBytes) return callback(new BlenderError(BlenderErrorCode.ASSET_TOO_LARGE,
|
|
16
|
+
`${label} exceeds the configured assetMaxBytes of ${maxBytes}; the transfer was stopped.`,
|
|
17
|
+
{ detail: { received: bytes, maxBytes } }))
|
|
18
|
+
hash.update(chunk)
|
|
19
|
+
callback(null, chunk)
|
|
20
|
+
},
|
|
21
|
+
})
|
|
22
|
+
await pipeline(source, meter, createWriteStream(target, { flags: 'wx' }), { signal })
|
|
23
|
+
return { bytes, sha256: hash.digest('hex') }
|
|
24
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/** Bounded local asset inspection. Original bytes are never rewritten. */
|
|
2
|
+
import { createHash } from 'node:crypto'
|
|
3
|
+
import { createReadStream } from 'node:fs'
|
|
4
|
+
import { open } from 'node:fs/promises'
|
|
5
|
+
import { extname } from 'node:path'
|
|
6
|
+
import { readGltfDocument, gltfResources } from './asset-bundle.js'
|
|
7
|
+
import { BlenderError, BlenderErrorCode, assetContentVerdict } from '@deepblend/dsh-blender-contracts'
|
|
8
|
+
|
|
9
|
+
export const ASSET_LIBRARY_LIMITS = Object.freeze({
|
|
10
|
+
maxImagePixels: 64 * 1024 * 1024,
|
|
11
|
+
maxImageEdge: 8192,
|
|
12
|
+
maxGlbJsonBytes: 16 * 1024 * 1024,
|
|
13
|
+
previewWidth: 512,
|
|
14
|
+
previewHeight: 384,
|
|
15
|
+
previewSamples: 16,
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
const MIME_BY_TYPE = Object.freeze({
|
|
19
|
+
glb: ['model/gltf-binary'], png: ['image/png'], jpg: ['image/jpeg'], jpeg: ['image/jpeg'],
|
|
20
|
+
hdr: ['image/vnd.radiance', 'image/x-hdr'], exr: ['image/x-exr', 'image/exr'],
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
function reject(message, code = BlenderErrorCode.ASSET_CONTENT_MISMATCH) {
|
|
24
|
+
throw new BlenderError(code, message)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function uploadAssetType(name, mediaType) {
|
|
28
|
+
const type = extname(name).slice(1).toLowerCase()
|
|
29
|
+
if (!Object.hasOwn(MIME_BY_TYPE, type)) reject('Choose a GLB, PNG, JPEG, HDR or EXR file.', BlenderErrorCode.ASSET_REQUEST_INVALID)
|
|
30
|
+
if (mediaType !== undefined && typeof mediaType !== 'string') reject('The upload media type must be text.', BlenderErrorCode.ASSET_REQUEST_INVALID)
|
|
31
|
+
const mime = (mediaType ?? '').split(';')[0].trim().toLowerCase()
|
|
32
|
+
if (mime && mime !== 'application/octet-stream' && !MIME_BY_TYPE[type].includes(mime)) {
|
|
33
|
+
reject('The file extension and upload media type do not agree.')
|
|
34
|
+
}
|
|
35
|
+
return type
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Inspect bounded GLB metadata; the upload profile requires all core resources embedded. */
|
|
39
|
+
export async function inspectSelfContainedGlb(path, { signal } = {}) {
|
|
40
|
+
const { document, binBytes } = readGltfDocument(path, { format: 'glb', signal })
|
|
41
|
+
if (gltfResources(document, 'upload.glb', binBytes).length) {
|
|
42
|
+
reject('Upload a self-contained GLB. External buffers and image files are not copied or fetched.', BlenderErrorCode.ASSET_REQUEST_INVALID)
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
animations: Array.isArray(document.animations) ? document.animations.length : 0,
|
|
46
|
+
cameras: Array.isArray(document.cameras) ? document.cameras.length : 0,
|
|
47
|
+
lights: Array.isArray(document.extensions?.KHR_lights_punctual?.lights) ? document.extensions.KHR_lights_punctual.lights.length : 0,
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function checkAssetUpload(path, type, { signal } = {}) {
|
|
52
|
+
signal?.throwIfAborted()
|
|
53
|
+
const handle = await open(path, 'r')
|
|
54
|
+
try {
|
|
55
|
+
const head = Buffer.alloc(512)
|
|
56
|
+
const { bytesRead } = await handle.read(head, 0, head.length, 0)
|
|
57
|
+
if (assetContentVerdict(head.subarray(0, bytesRead), type) === 'contradicts') {
|
|
58
|
+
reject('The uploaded bytes contradict the file extension.')
|
|
59
|
+
}
|
|
60
|
+
} finally { await handle.close() }
|
|
61
|
+
return type === 'glb' ? inspectSelfContainedGlb(path, { signal }) : null
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Hash incrementally, including while checking large staged model files. */
|
|
65
|
+
export async function hashAssetFile(path, { maxBytes, signal } = {}) {
|
|
66
|
+
const hash = createHash('sha256')
|
|
67
|
+
let bytes = 0
|
|
68
|
+
for await (const chunk of createReadStream(path, { signal })) {
|
|
69
|
+
bytes += chunk.length
|
|
70
|
+
if (maxBytes !== undefined && bytes > maxBytes) reject('The asset exceeds the configured byte limit.', BlenderErrorCode.ASSET_TOO_LARGE)
|
|
71
|
+
hash.update(chunk)
|
|
72
|
+
}
|
|
73
|
+
signal?.throwIfAborted()
|
|
74
|
+
return { bytes, sha256: hash.digest('hex') }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Decode the complete stored image, then make a bounded PNG derivative. */
|
|
78
|
+
export async function previewRasterAsset(path, type, outputPath, { signal } = {}) {
|
|
79
|
+
signal?.throwIfAborted()
|
|
80
|
+
const { default: sharp } = await import('sharp')
|
|
81
|
+
const inputOptions = { failOn: 'warning', limitInputPixels: ASSET_LIBRARY_LIMITS.maxImagePixels, sequentialRead: true }
|
|
82
|
+
const source = sharp(path, inputOptions)
|
|
83
|
+
// Sharp is both a Promise API and a Duplex. destroy(error) can reject the
|
|
84
|
+
// operation AND emit a stream error; the latter needs its own listener.
|
|
85
|
+
// Keep it for the object's lifetime because destroy emits asynchronously.
|
|
86
|
+
source.on('error', () => {})
|
|
87
|
+
const abort = () => source.destroy(signal.reason instanceof Error ? signal.reason : new Error('Asset preview cancelled.'))
|
|
88
|
+
signal?.addEventListener('abort', abort, { once: true })
|
|
89
|
+
try {
|
|
90
|
+
const metadata = await source.metadata()
|
|
91
|
+
const expected = type === 'png' ? 'png' : 'jpeg'
|
|
92
|
+
if (metadata.format !== expected) reject('The image decoder found a different format from the declared asset type.')
|
|
93
|
+
if (!Number.isInteger(metadata.width) || !Number.isInteger(metadata.height)
|
|
94
|
+
|| metadata.width < 1 || metadata.height < 1 || metadata.width > ASSET_LIBRARY_LIMITS.maxImageEdge
|
|
95
|
+
|| metadata.height > ASSET_LIBRARY_LIMITS.maxImageEdge || metadata.width * metadata.height > ASSET_LIBRARY_LIMITS.maxImagePixels) {
|
|
96
|
+
reject('The image exceeds the asset library pixel or edge limit.', BlenderErrorCode.ASSET_TOO_LARGE)
|
|
97
|
+
}
|
|
98
|
+
if ((metadata.pages ?? 1) !== 1) reject('Asset library images must contain a single still image.', BlenderErrorCode.ASSET_REQUEST_INVALID)
|
|
99
|
+
signal?.throwIfAborted()
|
|
100
|
+
const result = await source.resize({ width: ASSET_LIBRARY_LIMITS.previewWidth, height: ASSET_LIBRARY_LIMITS.previewHeight,
|
|
101
|
+
fit: 'inside', withoutEnlargement: true }).png().toFile(outputPath)
|
|
102
|
+
signal?.throwIfAborted()
|
|
103
|
+
return {
|
|
104
|
+
image: { width: metadata.width, height: metadata.height, channels: metadata.channels,
|
|
105
|
+
format: expected, orientation: metadata.orientation ?? 1, colorSpace: metadata.space },
|
|
106
|
+
preview: { width: result.width, height: result.height, mime: 'image/png', toneMapped: false },
|
|
107
|
+
}
|
|
108
|
+
} catch (cause) {
|
|
109
|
+
signal?.throwIfAborted()
|
|
110
|
+
if (cause instanceof BlenderError) throw cause
|
|
111
|
+
throw new BlenderError(BlenderErrorCode.ASSET_CONTENT_MISMATCH, 'The image could not be completely decoded.', { cause })
|
|
112
|
+
} finally { signal?.removeEventListener('abort', abort) }
|
|
113
|
+
}
|