@deepblend/dsh-blender-ui 0.3.3 → 0.3.5
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/client.js +299 -24
- package/lib/index.js +10 -2
- package/package.json +2 -2
package/lib/client.js
CHANGED
|
@@ -458,6 +458,16 @@ window.__ModuleLoader__.load({
|
|
|
458
458
|
'preview.keptPrevious': ';上一张已留作「上一次渲染」,可以直接并排比较',
|
|
459
459
|
'preview.firstSheet': '(这是第一张;再渲染一次就能并排比较前后)',
|
|
460
460
|
'preview.isArtifact': '。预览是产物:替换同一路径上的旧图,不产生新的 revision。',
|
|
461
|
+
'download.png': '保存预览 PNG',
|
|
462
|
+
'download.loading': '正在准备 PNG…',
|
|
463
|
+
'download.help': '保存这张图的原始 PNG,保留显示的尺寸与采样。更高质量的交付请使用渲染任务。',
|
|
464
|
+
'download.ready': '已交给浏览器保存:{filename}',
|
|
465
|
+
'download.source': '这张图缺少完整来源信息,请重新生成预览后再保存。',
|
|
466
|
+
'download.changed': '图片内容与当前显示的来源不一致,请刷新预览后重试。',
|
|
467
|
+
'download.failed': '无法读取预览图片,请刷新后重试。',
|
|
468
|
+
'download.unsupported': '浏览器无法保存图片,请使用支持文件下载的浏览器。',
|
|
469
|
+
'download.timeout': '准备图片超时,未发起保存。请重试。',
|
|
470
|
+
'download.cancelled': '已取消,未发起保存。',
|
|
461
471
|
'jobs.cancelRequested': '取消已请求,但进程仍在',
|
|
462
472
|
'projects.empty': '这个工作区还没有项目。',
|
|
463
473
|
'projects.create': '新建项目',
|
|
@@ -478,6 +488,18 @@ window.__ModuleLoader__.load({
|
|
|
478
488
|
'recipes.license': '作者与许可',
|
|
479
489
|
'recipes.unavailable': '部分配方无法载入;可继续使用下方可用的配方。',
|
|
480
490
|
'projects.creating': '创建中…',
|
|
491
|
+
'projects.incomplete': '尚未完成创建',
|
|
492
|
+
'projects.recovered': '已打开之前创建的 {id},未重复创建或渲染',
|
|
493
|
+
'projects.replyMissing': '未能收到创建结果。输入已保留;重试上次创建会检查同一次请求。',
|
|
494
|
+
'projects.environmentFailed': 'Blender 环境尚未就绪。输入已保留;请先检查安装和路径,修复后再重试。',
|
|
495
|
+
'projects.creationFailed': '未能确认创建结果。输入已保留;可重试上次创建,或刷新列表检查已保存的项目。',
|
|
496
|
+
'projects.retryCreation': '重试上次创建',
|
|
497
|
+
'projects.originalTitle': '上次创建:{title}',
|
|
498
|
+
'projects.refreshList': '刷新项目列表',
|
|
499
|
+
'projects.creationDetails': '详细诊断',
|
|
500
|
+
'projects.environmentGuide': '查看环境检查指南',
|
|
501
|
+
'projects.creationConflict': '上次创建记录需要检查。输入已保留;请刷新列表并查看详细诊断,确认项目状态。',
|
|
502
|
+
'projects.hostNeedsRestart': '当前运行的 Host 尚不支持创建恢复。请更新 DeepBlend 并重启 DSH,输入仍保留在当前页面。',
|
|
481
503
|
'projects.createButton': '创建',
|
|
482
504
|
'scene.currentRevision': '当前 revision',
|
|
483
505
|
'scene.revisionCount': 'revision 数',
|
|
@@ -985,6 +1007,16 @@ window.__ModuleLoader__.load({
|
|
|
985
1007
|
'preview.keptPrevious': '; the previous one is kept as “last render”, so they can be compared side by side',
|
|
986
1008
|
'preview.firstSheet': '(this is the first; render once more to compare before and after)',
|
|
987
1009
|
'preview.isArtifact': '. A preview is an artifact: it replaces the old image at the same path and creates no revision.',
|
|
1010
|
+
'download.png': 'Save preview PNG',
|
|
1011
|
+
'download.loading': 'Preparing PNG…',
|
|
1012
|
+
'download.help': 'Save this image’s original PNG at its displayed resolution and samples. Use render jobs for higher-quality delivery.',
|
|
1013
|
+
'download.ready': 'Sent to your browser to save: {filename}',
|
|
1014
|
+
'download.source': 'This image has incomplete source information. Generate a new preview before saving.',
|
|
1015
|
+
'download.changed': 'The image bytes do not match the displayed source. Refresh the preview and retry.',
|
|
1016
|
+
'download.failed': 'The preview image could not be read. Refresh and retry.',
|
|
1017
|
+
'download.unsupported': 'This browser cannot save the image. Use a browser with file-download support.',
|
|
1018
|
+
'download.timeout': 'Preparing the image timed out. No save was started. Retry.',
|
|
1019
|
+
'download.cancelled': 'Cancelled. No save was started.',
|
|
988
1020
|
'jobs.cancelRequested': 'cancellation requested, but the process is still there',
|
|
989
1021
|
'projects.empty': 'This workspace has no projects yet.',
|
|
990
1022
|
'projects.create': 'New project',
|
|
@@ -1005,6 +1037,18 @@ window.__ModuleLoader__.load({
|
|
|
1005
1037
|
'recipes.license': 'Author and license',
|
|
1006
1038
|
'recipes.unavailable': 'Some recipes could not be loaded. Available recipes are shown below.',
|
|
1007
1039
|
'projects.creating': 'Creating…',
|
|
1040
|
+
'projects.incomplete': 'Creation is not complete',
|
|
1041
|
+
'projects.recovered': 'Opened the previously created {id}; no duplicate creation or render',
|
|
1042
|
+
'projects.replyMissing': 'The creation result could not be received. Your inputs are kept; retry the previous creation to check the same request.',
|
|
1043
|
+
'projects.environmentFailed': 'Blender is not ready. Your inputs are kept; check its installation and path, then retry.',
|
|
1044
|
+
'projects.creationFailed': 'The creation result could not be confirmed. Your inputs are kept; retry the previous creation, or refresh the list to check saved projects.',
|
|
1045
|
+
'projects.retryCreation': 'Retry previous creation',
|
|
1046
|
+
'projects.originalTitle': 'Previous creation: {title}',
|
|
1047
|
+
'projects.refreshList': 'Refresh project list',
|
|
1048
|
+
'projects.creationDetails': 'Diagnostic details',
|
|
1049
|
+
'projects.environmentGuide': 'Open the environment check guide',
|
|
1050
|
+
'projects.creationConflict': 'The previous creation record needs inspection. Your inputs are kept; refresh the list and read the diagnostic details to check its state.',
|
|
1051
|
+
'projects.hostNeedsRestart': 'The running Host does not support creation recovery. Update DeepBlend and restart DSH; your inputs remain on this page.',
|
|
1008
1052
|
'projects.createButton': 'Create',
|
|
1009
1053
|
'scene.currentRevision': 'current revision',
|
|
1010
1054
|
'scene.revisionCount': 'revisions',
|
|
@@ -1247,6 +1291,7 @@ window.__ModuleLoader__.load({
|
|
|
1247
1291
|
const PANEL_LABEL = 'Blender'
|
|
1248
1292
|
/** The host API this half was written against; a mismatch is a deployment state. */
|
|
1249
1293
|
const EXPECTED_HOST_API = 6
|
|
1294
|
+
const CREATION_REQUEST_PROTOCOL = 'deepblend.creation-request/v1'
|
|
1250
1295
|
/** Polling cadence while something is live; M3 writes progress once a second. */
|
|
1251
1296
|
const POLL_LIVE_MS = 1500
|
|
1252
1297
|
/** Polling cadence when nothing is running. */
|
|
@@ -1309,6 +1354,147 @@ window.__ModuleLoader__.load({
|
|
|
1309
1354
|
return `${base}${path}?v=${version}`
|
|
1310
1355
|
}
|
|
1311
1356
|
|
|
1357
|
+
const creationSignature = body => JSON.stringify(body, (_, value) => value && typeof value === 'object' && !Array.isArray(value)
|
|
1358
|
+
? Object.fromEntries(Object.keys(value).sort().map(key => [key, value[key]])) : value)
|
|
1359
|
+
let creationKeySequence = 0
|
|
1360
|
+
function newCreationKey() {
|
|
1361
|
+
const bytes = new Uint32Array(4)
|
|
1362
|
+
if (globalThis.crypto?.getRandomValues) globalThis.crypto.getRandomValues(bytes)
|
|
1363
|
+
else for (let i = 0; i < bytes.length; i++) bytes[i] = Math.floor(Math.random() * 4294967296)
|
|
1364
|
+
return `create-${Date.now().toString(36)}-${(++creationKeySequence).toString(36)}-${[...bytes].map(n => n.toString(16).padStart(8, '0')).join('')}`
|
|
1365
|
+
}
|
|
1366
|
+
function creationErrorMessage(code) {
|
|
1367
|
+
if (code === 'UI_FETCH_FAILED' || /^HTTP_/.test(code)) return t('projects.replyMissing')
|
|
1368
|
+
if (code === 'BLENDER_NOT_FOUND' || code === 'RUNTIME_UNAVAILABLE') return t('projects.environmentFailed')
|
|
1369
|
+
if (code === 'CREATION_REQUEST_CONFLICT') return t('projects.creationConflict')
|
|
1370
|
+
if (code === 'UI_HOST_API_STALE') return t('projects.hostNeedsRestart')
|
|
1371
|
+
return t('projects.creationFailed')
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
const imageDownloadKey = (projectId, artifact) => JSON.stringify([projectId, artifact?.path, artifact?.sha256])
|
|
1375
|
+
function imageDownloadValid(artifact) {
|
|
1376
|
+
return Boolean(artifact && typeof artifact.path === 'string' && /\.png$/i.test(artifact.path)
|
|
1377
|
+
&& /^[a-f0-9]{64}$/i.test(artifact.sha256 || '') && Number.isSafeInteger(artifact.bytes) && artifact.bytes > 0)
|
|
1378
|
+
}
|
|
1379
|
+
function imageDownloadMessage(work) {
|
|
1380
|
+
switch (work.messageKey) {
|
|
1381
|
+
case 'download.ready': return t('download.ready', { filename: work.filename || '' })
|
|
1382
|
+
case 'download.changed': return t('download.changed')
|
|
1383
|
+
case 'download.timeout': return t('download.timeout')
|
|
1384
|
+
case 'download.cancelled': return t('download.cancelled')
|
|
1385
|
+
case 'download.unsupported': return t('download.unsupported')
|
|
1386
|
+
default: return t('download.failed')
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1389
|
+
function imageDownloadName(projectId, artifact) {
|
|
1390
|
+
const label = value => String(value).replace(/[\\/:*?"<>|\u0000-\u001f]/g, '-').replace(/^[. ]+|[. ]+$/g, '').slice(0, 60) || 'image'
|
|
1391
|
+
return `deepblend-${label(projectId)}-${label(artifact.sourceRevision || 'preview')}-${label(artifact.mode || artifact.cameraId || artifact.kind || 'image')}${Number.isSafeInteger(artifact.frame) ? `-f${artifact.frame}` : ''}-${artifact.sha256.slice(0, 12).toLowerCase()}.png`
|
|
1392
|
+
}
|
|
1393
|
+
function savePngFile(bytes, filename) {
|
|
1394
|
+
const doc = typeof document === 'object' ? document : null
|
|
1395
|
+
if (!doc || typeof Blob !== 'function' || typeof URL === 'undefined' || typeof URL.createObjectURL !== 'function') throw Object.assign(new Error(t('download.unsupported')), { messageKey: 'download.unsupported' })
|
|
1396
|
+
const url = URL.createObjectURL(new Blob([bytes], { type: 'image/png' }))
|
|
1397
|
+
const link = doc.createElement('a')
|
|
1398
|
+
link.href = url; link.download = filename; link.hidden = true
|
|
1399
|
+
try { doc.body.appendChild(link); link.click() } finally {
|
|
1400
|
+
link.remove()
|
|
1401
|
+
// The browser needs the Blob until its download has started.
|
|
1402
|
+
setTimeout(() => URL.revokeObjectURL(url), 1000)
|
|
1403
|
+
}
|
|
1404
|
+
}
|
|
1405
|
+
// SHA-256 per FIPS 180-4 §6.2.2. LAN HTTP lacks SubtleCrypto; keep byte
|
|
1406
|
+
// verification available there without adding a framework or network call.
|
|
1407
|
+
// https://csrc.nist.gov/pubs/fips/180-4/upd1/final
|
|
1408
|
+
const SHA256_ROUNDS = [
|
|
1409
|
+
0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5,
|
|
1410
|
+
0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174,
|
|
1411
|
+
0xe49b69c1,0xefbe4786,0x0fc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da,
|
|
1412
|
+
0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x06ca6351,0x14292967,
|
|
1413
|
+
0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85,
|
|
1414
|
+
0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070,
|
|
1415
|
+
0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3,
|
|
1416
|
+
0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2,
|
|
1417
|
+
]
|
|
1418
|
+
async function imageByteHash(bytes, signal, forceFallback = false) {
|
|
1419
|
+
const active = () => { if (signal?.aborted) throw Object.assign(new Error('cancelled'), { name: 'AbortError' }) }
|
|
1420
|
+
active()
|
|
1421
|
+
if (!forceFallback && globalThis.crypto?.subtle) {
|
|
1422
|
+
const digest = await globalThis.crypto.subtle.digest('SHA-256', bytes); active()
|
|
1423
|
+
return Array.from(new Uint8Array(digest), value => value.toString(16).padStart(2, '0')).join('')
|
|
1424
|
+
}
|
|
1425
|
+
const padded = new Uint8Array(Math.ceil((bytes.length + 9) / 64) * 64)
|
|
1426
|
+
padded.set(bytes); padded[bytes.length] = 128
|
|
1427
|
+
const input = new DataView(padded.buffer)
|
|
1428
|
+
input.setUint32(padded.length - 8, Math.floor(bytes.length * 8 / 4294967296))
|
|
1429
|
+
input.setUint32(padded.length - 4, (bytes.length * 8) >>> 0)
|
|
1430
|
+
const state = [0x6a09e667,0xbb67ae85,0x3c6ef372,0xa54ff53a,0x510e527f,0x9b05688c,0x1f83d9ab,0x5be0cd19]
|
|
1431
|
+
const words = new Uint32Array(64), rotate = (value, amount) => (value >>> amount) | (value << (32 - amount))
|
|
1432
|
+
for (let offset = 0; offset < padded.length; offset += 64) {
|
|
1433
|
+
active()
|
|
1434
|
+
for (let i = 0; i < 16; i++) words[i] = input.getUint32(offset + i * 4)
|
|
1435
|
+
for (let i = 16; i < 64; i++) {
|
|
1436
|
+
const a = words[i - 15], b = words[i - 2]
|
|
1437
|
+
words[i] = (words[i - 16] + (rotate(a,7)^rotate(a,18)^(a>>>3)) + words[i - 7] + (rotate(b,17)^rotate(b,19)^(b>>>10))) >>> 0
|
|
1438
|
+
}
|
|
1439
|
+
let [a,b,c,d,e,f,g,h] = state
|
|
1440
|
+
for (let i = 0; i < 64; i++) {
|
|
1441
|
+
const first = (h + (rotate(e,6)^rotate(e,11)^rotate(e,25)) + ((e&f)^((~e)&g)) + SHA256_ROUNDS[i] + words[i]) >>> 0
|
|
1442
|
+
const second = ((rotate(a,2)^rotate(a,13)^rotate(a,22)) + ((a&b)^(a&c)^(b&c))) >>> 0
|
|
1443
|
+
h=g;g=f;f=e;e=(d+first)>>>0;d=c;c=b;b=a;a=(first+second)>>>0
|
|
1444
|
+
}
|
|
1445
|
+
const round = [a,b,c,d,e,f,g,h]
|
|
1446
|
+
for (let i = 0; i < 8; i++) state[i] = (state[i] + round[i]) >>> 0
|
|
1447
|
+
if (offset && offset % 65536 === 0) await new Promise(resolve => setTimeout(resolve, 0))
|
|
1448
|
+
}
|
|
1449
|
+
active()
|
|
1450
|
+
return state.map(value => value.toString(16).padStart(8, '0')).join('')
|
|
1451
|
+
}
|
|
1452
|
+
async function verifiedPngBytes(response, artifact, signal) {
|
|
1453
|
+
const fail = key => { throw Object.assign(new Error(t(key)), { messageKey: key }) }
|
|
1454
|
+
const active = () => { if (signal.aborted) throw Object.assign(new Error('cancelled'), { name: 'AbortError' }) }
|
|
1455
|
+
active()
|
|
1456
|
+
if (!response.ok || response.headers.get('content-type')?.split(';')[0].trim().toLowerCase() !== 'image/png') {
|
|
1457
|
+
await response.body?.cancel?.().catch(() => {})
|
|
1458
|
+
fail('download.failed')
|
|
1459
|
+
}
|
|
1460
|
+
const length = response.headers.get('content-length')
|
|
1461
|
+
if (length !== null && Number(length) !== artifact.bytes) { await response.body?.cancel?.(); fail('download.changed') }
|
|
1462
|
+
let bytes
|
|
1463
|
+
if (response.body?.getReader) {
|
|
1464
|
+
const reader = response.body.getReader(), chunks = []
|
|
1465
|
+
let received = 0
|
|
1466
|
+
const abort = () => { void reader.cancel().catch(() => {}) }
|
|
1467
|
+
signal.addEventListener('abort', abort, { once: true })
|
|
1468
|
+
try {
|
|
1469
|
+
for (;;) {
|
|
1470
|
+
const item = await reader.read(); active()
|
|
1471
|
+
if (item.done) break
|
|
1472
|
+
received += item.value.byteLength
|
|
1473
|
+
if (received > artifact.bytes) { await reader.cancel(); fail('download.changed') }
|
|
1474
|
+
chunks.push(item.value)
|
|
1475
|
+
}
|
|
1476
|
+
} catch (error) { await reader.cancel().catch(() => {}); throw error }
|
|
1477
|
+
finally { signal.removeEventListener('abort', abort); reader.releaseLock() }
|
|
1478
|
+
if (received !== artifact.bytes) fail('download.changed')
|
|
1479
|
+
bytes = new Uint8Array(received)
|
|
1480
|
+
let offset = 0
|
|
1481
|
+
for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.byteLength }
|
|
1482
|
+
} else {
|
|
1483
|
+
bytes = new Uint8Array(await response.arrayBuffer()); active()
|
|
1484
|
+
if (bytes.byteLength !== artifact.bytes) fail('download.changed')
|
|
1485
|
+
}
|
|
1486
|
+
const signature = [137, 80, 78, 71, 13, 10, 26, 10]
|
|
1487
|
+
if (bytes.length < 24 || !signature.every((byte, index) => bytes[index] === byte)) fail('download.changed')
|
|
1488
|
+
if (Number.isSafeInteger(artifact.width) && Number.isSafeInteger(artifact.height)) {
|
|
1489
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
|
|
1490
|
+
if (view.getUint32(16) !== artifact.width || view.getUint32(20) !== artifact.height) fail('download.changed')
|
|
1491
|
+
}
|
|
1492
|
+
const hash = await imageByteHash(bytes, signal)
|
|
1493
|
+
active()
|
|
1494
|
+
if (hash !== artifact.sha256.toLowerCase()) fail('download.changed')
|
|
1495
|
+
return bytes
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1312
1498
|
/** Short "when was this produced" for an artifact, or null. */
|
|
1313
1499
|
function artifactTime(artifact) {
|
|
1314
1500
|
const at = artifact && artifact.at ? artifact.at : null
|
|
@@ -2291,6 +2477,9 @@ window.__ModuleLoader__.load({
|
|
|
2291
2477
|
diff: null,
|
|
2292
2478
|
diffError: null,
|
|
2293
2479
|
previewBusy: false,
|
|
2480
|
+
imageDownloads: {},
|
|
2481
|
+
creationWork: null,
|
|
2482
|
+
creationRequestProtocol: null,
|
|
2294
2483
|
inspectionForms: {},
|
|
2295
2484
|
inspectionWork: {},
|
|
2296
2485
|
inspectionRevisions: {},
|
|
@@ -2339,6 +2528,9 @@ window.__ModuleLoader__.load({
|
|
|
2339
2528
|
const fetchImpl = settings.fetch ?? ((...args) => fetch(...args))
|
|
2340
2529
|
const pollLiveMs = settings.pollLiveMs ?? POLL_LIVE_MS
|
|
2341
2530
|
const pollIdleMs = settings.pollIdleMs ?? POLL_IDLE_MS
|
|
2531
|
+
const saveImage = settings.saveImage ?? savePngFile
|
|
2532
|
+
const imageDownloadTimeoutMs = settings.imageDownloadTimeoutMs ?? 30000
|
|
2533
|
+
if (!Number.isSafeInteger(imageDownloadTimeoutMs) || imageDownloadTimeoutMs <= 0 || imageDownloadTimeoutMs > 2147483647) throw new Error('Image download timeout must be a positive timer-safe integer')
|
|
2342
2534
|
|
|
2343
2535
|
const preferenceStorage = appearanceStorage(settings)
|
|
2344
2536
|
let data = { ...emptySnapshot(), appearance: savedAppearance(preferenceStorage) }
|
|
@@ -2348,10 +2540,12 @@ window.__ModuleLoader__.load({
|
|
|
2348
2540
|
let tick = 0
|
|
2349
2541
|
let live = false
|
|
2350
2542
|
let loadSequence = 0
|
|
2543
|
+
let creationAttempt = null
|
|
2351
2544
|
let fileChoiceSignature = null, fileChoicesStopped = false
|
|
2352
2545
|
const photographyControllers = new Map()
|
|
2353
2546
|
const photographyWork = (projectId, changes) => set({ photographyWork: { ...data.photographyWork, [projectId]: { ...data.photographyWork[projectId], ...changes } } })
|
|
2354
2547
|
const inspectionControllers = new Map()
|
|
2548
|
+
const imageDownloadControllers = new Map()
|
|
2355
2549
|
const assetControllers = new Map()
|
|
2356
2550
|
const assetListSequences = new Map()
|
|
2357
2551
|
const bundleFiles = new Map(), bundleOperations = new Map()
|
|
@@ -2401,7 +2595,7 @@ window.__ModuleLoader__.load({
|
|
|
2401
2595
|
const setIn = (table, key, value) => { data = { ...data, [table]: { ...data[table], [key]: value } }; notify() }
|
|
2402
2596
|
|
|
2403
2597
|
/** The project every per-project route is addressed to. */
|
|
2404
|
-
const target = () => data.projectId ??
|
|
2598
|
+
const target = () => data.projectId ?? data.projects.find(project => !project.creationPending)?.projectId ?? null
|
|
2405
2599
|
|
|
2406
2600
|
/** Read everything the panel shows, in one pass. */
|
|
2407
2601
|
const load = async () => {
|
|
@@ -2415,7 +2609,8 @@ window.__ModuleLoader__.load({
|
|
|
2415
2609
|
return
|
|
2416
2610
|
}
|
|
2417
2611
|
const payload = stateResult.payload
|
|
2418
|
-
const
|
|
2612
|
+
const pending = payload.projects?.some(project => project.projectId === projectId && project.creationPending)
|
|
2613
|
+
const active = pending ? null : projectId ?? payload.projects?.find(project => !project.creationPending)?.projectId ?? null
|
|
2419
2614
|
const patch = {
|
|
2420
2615
|
status: 'ok',
|
|
2421
2616
|
error: null,
|
|
@@ -2423,6 +2618,7 @@ window.__ModuleLoader__.load({
|
|
|
2423
2618
|
projects: payload.projects || [],
|
|
2424
2619
|
recipeCatalog: payload.recipeCatalog ?? { recipes: [], errors: [] },
|
|
2425
2620
|
projectsRoot: payload.projectsRoot ?? null,
|
|
2621
|
+
creationRequestProtocol: payload.creationRequestProtocol ?? null,
|
|
2426
2622
|
selected: payload.selected ?? null,
|
|
2427
2623
|
activeProjectId: active,
|
|
2428
2624
|
currentRevision: payload.selected ? payload.selected.currentRevision : null,
|
|
@@ -2488,6 +2684,44 @@ window.__ModuleLoader__.load({
|
|
|
2488
2684
|
/** A write just happened: re-run every read, including the view-specific ones. */
|
|
2489
2685
|
const reload = () => { tick += 1; void load() }
|
|
2490
2686
|
|
|
2687
|
+
const creationBody = () => editorClone({ title: data.forms.title.trim(),
|
|
2688
|
+
goal: data.forms.goal.length > 0 ? data.forms.goal : undefined,
|
|
2689
|
+
renderPreview: Boolean(data.forms.recipe), recipe: data.forms.recipe ? {
|
|
2690
|
+
id: data.forms.recipe.id, version: data.forms.recipe.version, digest: data.forms.recipe.digest,
|
|
2691
|
+
parameters: data.forms.recipeParameters,
|
|
2692
|
+
} : undefined })
|
|
2693
|
+
|
|
2694
|
+
async function submitCreation(attempt) {
|
|
2695
|
+
if (data.creationRequestProtocol !== CREATION_REQUEST_PROTOCOL) {
|
|
2696
|
+
set({ creationWork: { status: 'error', title: attempt.body.title, code: 'UI_HOST_API_STALE' } })
|
|
2697
|
+
setIn('notices', 'projects', { kind: 'creation', ok: false, message: t('projects.hostNeedsRestart') })
|
|
2698
|
+
return
|
|
2699
|
+
}
|
|
2700
|
+
setIn('busy', 'create', true)
|
|
2701
|
+
set({ creationWork: { status: 'busy', title: attempt.body.title }, previewResult: null })
|
|
2702
|
+
setIn('notices', 'projects', null)
|
|
2703
|
+
setIn('notices', 'preview', null)
|
|
2704
|
+
const outcome = await postJson(fetchImpl, ROUTES.projects, { ...editorClone(attempt.body), creationKey: attempt.key })
|
|
2705
|
+
setIn('busy', 'create', false)
|
|
2706
|
+
if (outcome.ok) {
|
|
2707
|
+
const sameInputs = creationSignature(creationBody()) === attempt.signature
|
|
2708
|
+
set({ ...(sameInputs ? { forms: { ...data.forms, title: '', goal: '', recipe: null, recipeParameters: {} } } : {}),
|
|
2709
|
+
creationWork: null, projectId: outcome.payload.project.projectId,
|
|
2710
|
+
...(attempt.body.renderPreview ? { view: 'preview', compareMode: 'result' } : {}) })
|
|
2711
|
+
if (creationAttempt === attempt) creationAttempt = null
|
|
2712
|
+
setIn('notices', attempt.body.renderPreview ? 'preview' : 'projects', { kind: 'creation', projectId: outcome.payload.project.projectId, ok: true, message: outcome.payload.project.creationReplayed
|
|
2713
|
+
? t('projects.recovered', { id: outcome.payload.project.projectId })
|
|
2714
|
+
: t('projects.created', { id: outcome.payload.project.projectId }) })
|
|
2715
|
+
} else {
|
|
2716
|
+
set({ creationWork: { status: 'error', title: attempt.body.title, code: outcome.error.code } })
|
|
2717
|
+
setIn('notices', 'projects', { kind: 'creation', ok: false, message: creationErrorMessage(outcome.error.code),
|
|
2718
|
+
technicalDetails: `${outcome.error.code}: ${outcome.error.message}` })
|
|
2719
|
+
}
|
|
2720
|
+
// A failed response may follow a successful Host commit. Refreshing is
|
|
2721
|
+
// read-only; retries remain explicit and reuse the captured request key.
|
|
2722
|
+
reload()
|
|
2723
|
+
}
|
|
2724
|
+
|
|
2491
2725
|
/** Run one write, report it in this view's notice, then reload. */
|
|
2492
2726
|
const write = async (view, path, body, describe) => {
|
|
2493
2727
|
const outcome = await postJson(fetchImpl, path, body)
|
|
@@ -3106,27 +3340,39 @@ window.__ModuleLoader__.load({
|
|
|
3106
3340
|
}
|
|
3107
3341
|
},
|
|
3108
3342
|
|
|
3343
|
+
downloadImage: async ({ projectId, artifactBase, artifact }) => {
|
|
3344
|
+
if (fileChoicesStopped || !projectId || typeof artifactBase !== 'string' || !artifactBase || !imageDownloadValid(artifact)) return
|
|
3345
|
+
const source = editorClone(artifact), key = imageDownloadKey(projectId, source)
|
|
3346
|
+
if (imageDownloadControllers.has(key)) return
|
|
3347
|
+
const controller = new AbortController(); imageDownloadControllers.set(key, controller)
|
|
3348
|
+
const work = changes => set({ imageDownloads: { ...data.imageDownloads, [key]: { ...data.imageDownloads[key], ...changes } } })
|
|
3349
|
+
work({ busy: true, status: 'preparing', messageKey: null, filename: null })
|
|
3350
|
+
const deadline = setTimeout(() => controller.abort('timeout'), imageDownloadTimeoutMs)
|
|
3351
|
+
try {
|
|
3352
|
+
const response = await fetchImpl(artifactUrl(artifactBase, source), { headers: { accept: 'image/png' }, cache: 'no-store', signal: controller.signal })
|
|
3353
|
+
const bytes = await verifiedPngBytes(response, source, controller.signal)
|
|
3354
|
+
if (controller.signal.aborted) throw Object.assign(new Error('cancelled'), { name: 'AbortError' })
|
|
3355
|
+
const filename = imageDownloadName(projectId, source)
|
|
3356
|
+
clearTimeout(deadline)
|
|
3357
|
+
await saveImage(bytes, filename)
|
|
3358
|
+
work({ status: 'ready', filename, messageKey: 'download.ready' })
|
|
3359
|
+
} catch (error) {
|
|
3360
|
+
work({ status: controller.signal.aborted ? 'cancelled' : 'error', messageKey: controller.signal.aborted
|
|
3361
|
+
? controller.signal.reason === 'timeout' ? 'download.timeout' : 'download.cancelled'
|
|
3362
|
+
: error.messageKey || 'download.failed' })
|
|
3363
|
+
} finally { clearTimeout(deadline); imageDownloadControllers.delete(key); work({ busy: false }) }
|
|
3364
|
+
},
|
|
3365
|
+
cancelImageDownload: key => imageDownloadControllers.get(key)?.abort('cancelled'),
|
|
3366
|
+
|
|
3109
3367
|
createProject: async () => {
|
|
3110
3368
|
if (data.busy.create) return
|
|
3111
3369
|
if (!recipeSelectionCurrent(data) || !recipeValuesValid(data.forms.recipe, data.forms.recipeParameters)) return
|
|
3112
|
-
const
|
|
3113
|
-
|
|
3114
|
-
|
|
3115
|
-
|
|
3116
|
-
goal: data.forms.goal.length > 0 ? data.forms.goal : undefined,
|
|
3117
|
-
renderPreview: withRecipe,
|
|
3118
|
-
recipe: data.forms.recipe ? { id: data.forms.recipe.id, version: data.forms.recipe.version, digest: data.forms.recipe.digest, parameters: data.forms.recipeParameters } : undefined,
|
|
3119
|
-
})
|
|
3120
|
-
setIn('busy', 'create', false)
|
|
3121
|
-
if (outcome.ok) {
|
|
3122
|
-
set({ forms: { ...data.forms, title: '', goal: '', recipe: null, recipeParameters: {} }, projectId: outcome.payload.project.projectId, ...(withRecipe ? { view: 'preview', compareMode: 'result' } : {}) })
|
|
3123
|
-
setIn('notices', 'projects', { ok: true, message: t('projects.created', { id: outcome.payload.project.projectId }) })
|
|
3124
|
-
reload()
|
|
3125
|
-
} else {
|
|
3126
|
-
setIn('notices', 'projects', { ok: false, message: `${outcome.error.code}: ${outcome.error.message}` })
|
|
3127
|
-
if (outcome.error.code === 'RECIPE_CHANGED' || outcome.error.code === 'RECIPE_NOT_FOUND') reload()
|
|
3128
|
-
}
|
|
3370
|
+
const body = creationBody()
|
|
3371
|
+
const signature = creationSignature(body)
|
|
3372
|
+
if (!creationAttempt || creationAttempt.signature !== signature) creationAttempt = { body, signature, key: newCreationKey() }
|
|
3373
|
+
return submitCreation(creationAttempt)
|
|
3129
3374
|
},
|
|
3375
|
+
retryCreation: () => data.busy.create || !creationAttempt ? undefined : submitCreation(creationAttempt),
|
|
3130
3376
|
|
|
3131
3377
|
applyPatch: async () => {
|
|
3132
3378
|
const projectId = data.activeProjectId
|
|
@@ -3192,6 +3438,7 @@ window.__ModuleLoader__.load({
|
|
|
3192
3438
|
},
|
|
3193
3439
|
|
|
3194
3440
|
renderPreview: async () => {
|
|
3441
|
+
setIn('notices', 'preview', null)
|
|
3195
3442
|
set({ previewBusy: true, previewResult: null })
|
|
3196
3443
|
const outcome = await postJson(fetchImpl, projectRoute(target(), '/preview'), { revision: data.selected?.scene?.revision })
|
|
3197
3444
|
set({ previewBusy: false, ...(outcome.ok ? { compareMode: 'renders' } : {}) })
|
|
@@ -3281,6 +3528,7 @@ window.__ModuleLoader__.load({
|
|
|
3281
3528
|
photographyControllers.clear()
|
|
3282
3529
|
for (const controller of inspectionControllers.values()) controller.abort()
|
|
3283
3530
|
inspectionControllers.clear()
|
|
3531
|
+
for (const controller of imageDownloadControllers.values()) controller.abort('stopped')
|
|
3284
3532
|
for (const controller of assetControllers.values()) controller.abort()
|
|
3285
3533
|
assetControllers.clear()
|
|
3286
3534
|
for (const operation of bundleOperations.values()) void cancelBundle(operation).catch(() => {})
|
|
@@ -3315,10 +3563,12 @@ window.__ModuleLoader__.load({
|
|
|
3315
3563
|
Button({
|
|
3316
3564
|
tone: project.projectId === state.activeProjectId ? 'primary' : undefined,
|
|
3317
3565
|
action: `select-project:${project.projectId}`,
|
|
3566
|
+
disabled: project.creationPending,
|
|
3318
3567
|
onClick: () => actions.selectProject(project.projectId),
|
|
3319
3568
|
children: project.title || project.projectId,
|
|
3320
3569
|
}),
|
|
3321
3570
|
el('span', { className: 'db-muted db-mono' }, project.projectId),
|
|
3571
|
+
project.creationPending ? Badge({ tone: 'live', name: 'creation-pending', children: t('projects.incomplete') }) : null,
|
|
3322
3572
|
project.unfinishedJobs > 0 ? Badge({ tone: 'live', name: 'unfinished', children: t('projects.unfinishedJobs', { count: project.unfinishedJobs }) }) : null,
|
|
3323
3573
|
el('span', { className: 'db-muted' }, t('projects.revisionCount', { count: project.revisionCount })),
|
|
3324
3574
|
),
|
|
@@ -3352,12 +3602,24 @@ window.__ModuleLoader__.load({
|
|
|
3352
3602
|
Button({
|
|
3353
3603
|
tone: 'primary',
|
|
3354
3604
|
action: 'create-project',
|
|
3355
|
-
disabled: state.busy.create || state.forms.title.trim().length === 0 || !recipeSelectionCurrent(state) || !recipeValuesValid(state.forms.recipe, state.forms.recipeParameters),
|
|
3605
|
+
disabled: state.busy.create || state.creationRequestProtocol !== CREATION_REQUEST_PROTOCOL || state.forms.title.trim().length === 0 || !recipeSelectionCurrent(state) || !recipeValuesValid(state.forms.recipe, state.forms.recipeParameters),
|
|
3356
3606
|
onClick: actions.createProject,
|
|
3357
3607
|
children: state.busy.create ? (state.forms.recipe ? t('recipes.creatingPreview') : t('projects.creating')) : (state.forms.recipe ? t('recipes.createPreview') : t('projects.createButton')),
|
|
3358
3608
|
}),
|
|
3359
3609
|
),
|
|
3360
3610
|
state.notices.projects ? Notice(state.notices.projects, { marginTop: '8px' }) : null,
|
|
3611
|
+
state.status === 'ok' && state.creationRequestProtocol !== CREATION_REQUEST_PROTOCOL
|
|
3612
|
+
? el('p', { className: 'db-muted', 'data-creation-host-stale': true }, t('projects.hostNeedsRestart')) : null,
|
|
3613
|
+
state.creationWork?.status === 'error' ? el('div', { 'data-creation-recovery': true, style: { marginTop: '8px' } },
|
|
3614
|
+
el('p', { className: 'db-muted' }, t('projects.originalTitle', { title: state.creationWork.title })),
|
|
3615
|
+
el('div', { className: 'db-inline' },
|
|
3616
|
+
Button({ action: 'retry-creation', disabled: state.busy.create, onClick: actions.retryCreation, children: t('projects.retryCreation') }),
|
|
3617
|
+
Button({ action: 'refresh-creation-projects', onClick: actions.reload, children: t('projects.refreshList') }),
|
|
3618
|
+
['BLENDER_NOT_FOUND', 'RUNTIME_UNAVAILABLE'].includes(state.creationWork.code)
|
|
3619
|
+
? el('a', { href: 'https://github.com/pearjelly/deep-blend/blob/main/deepblend/docs/environment-check.md', target: '_blank', rel: 'noreferrer' }, t('projects.environmentGuide')) : null),
|
|
3620
|
+
state.notices.projects?.technicalDetails ? el('details', { 'data-creation-diagnostics': true },
|
|
3621
|
+
el('summary', null, t('projects.creationDetails')), el('pre', { className: 'db-pre' }, state.notices.projects.technicalDetails)) : null,
|
|
3622
|
+
) : null,
|
|
3361
3623
|
),
|
|
3362
3624
|
el('details', { className: 'db-storage' }, el('summary', null, t('projects.storage')),
|
|
3363
3625
|
el('span', { className: 'db-muted db-mono' }, `projectsRoot: ${state.projectsRoot || '—'}`)),
|
|
@@ -3917,7 +4179,7 @@ window.__ModuleLoader__.load({
|
|
|
3917
4179
|
work.artifact ? el('figure', { 'data-photography-artifact': work.artifact.path },
|
|
3918
4180
|
el('figcaption', null, t('photo.ready', { revision: edit.after, camera: edit.cameraId, frame: edit.frame })),
|
|
3919
4181
|
el('img', { src: artifactUrl(state.artifactBase, work.artifact), alt: t('inspection.beauty'), style: { display: 'block', width: 'auto', maxWidth: '100%', height: 'auto', maxHeight: '640px', margin: '0 auto', objectFit: 'contain' } }),
|
|
3920
|
-
PreviewSettings(work.artifact)) : null) : null,
|
|
4182
|
+
PreviewSettings(work.artifact), ImageDownload({ state, actions, artifact: work.artifact })) : null) : null,
|
|
3921
4183
|
work.error ? el('p', { className: 'db-error', role: 'status', 'data-photography-error': true }, work.error) : null)
|
|
3922
4184
|
}
|
|
3923
4185
|
|
|
@@ -4193,6 +4455,16 @@ window.__ModuleLoader__.load({
|
|
|
4193
4455
|
references: scene.project.referenceImages?.length || 0, images: evidence?.diagnostics?.length || 0 }) : t('guide.noProject')),
|
|
4194
4456
|
el('small', { className: 'db-muted' }, t('guide.help'))))
|
|
4195
4457
|
}
|
|
4458
|
+
function ImageDownload({ state, actions, artifact }) {
|
|
4459
|
+
if (!artifact) return null
|
|
4460
|
+
const request = { projectId: state.activeProjectId, artifactBase: state.artifactBase, artifact }
|
|
4461
|
+
const key = imageDownloadKey(request.projectId, artifact), work = state.imageDownloads?.[key] || {}, valid = Boolean(imageDownloadValid(artifact) && request.projectId && request.artifactBase)
|
|
4462
|
+
return el('div', { className: 'db-inline', style: { flexWrap: 'wrap' }, 'data-image-download': artifact.path, 'data-image-download-digest': artifact.sha256 || '' },
|
|
4463
|
+
Button({ action: `download-image:${artifact.path}`, disabled: !valid || work.busy, title: t('download.help'), onClick: () => actions.downloadImage(request), children: work.busy ? t('download.loading') : t('download.png') }),
|
|
4464
|
+
work.busy ? Button({ action: `cancel-image-download:${artifact.path}`, onClick: () => actions.cancelImageDownload(key), children: t('inspection.cancel') }) : null,
|
|
4465
|
+
!valid ? el('small', { className: 'db-muted' }, t('download.source')) : null,
|
|
4466
|
+
work.messageKey ? el('small', { className: work.status === 'error' ? 'db-error' : 'db-muted', role: 'status', style: { overflowWrap: 'anywhere', maxWidth: '100%', minWidth: 0 }, 'data-image-download-status': work.status }, imageDownloadMessage(work)) : null)
|
|
4467
|
+
}
|
|
4196
4468
|
function InspectionPanel({ state, actions }) {
|
|
4197
4469
|
const projectId = state.activeProjectId, scene = state.selected?.scene, form = inspectionForm(state), work = state.inspectionWork?.[projectId] || {}
|
|
4198
4470
|
if (!scene || !form) return null
|
|
@@ -4226,7 +4498,7 @@ window.__ModuleLoader__.load({
|
|
|
4226
4498
|
selectedRevision !== scene.revision ? el('p', { className: 'db-muted' }, t('inspection.history', { revision: selectedRevision, current: scene.revision })) : null,
|
|
4227
4499
|
artifacts.length === 0 ? el('p', { className: 'db-muted' }, t('inspection.none')) : el('div', { className: 'db-grid' }, artifacts.map(artifact => el('article', { className: 'db-card', key: artifact.path,
|
|
4228
4500
|
'data-inspection-artifact': artifact.path, 'data-inspection-mode': artifact.mode, 'data-inspection-revision': artifact.sourceRevision || selectedRevision },
|
|
4229
|
-
el('h5', null, t(`inspection.${artifact.mode}`)),
|
|
4501
|
+
el('h5', null, t(`inspection.${artifact.mode}`)), ImageDownload({ state, actions, artifact }),
|
|
4230
4502
|
el('p', null, t('inspection.identity', { revision: artifact.sourceRevision || selectedRevision, camera: artifact.cameraId || '—', frame: artifact.frame ?? '—' })),
|
|
4231
4503
|
el('img', { src: artifactUrl(state.artifactBase, artifact), alt: t(`inspection.${artifact.mode}`), 'data-inspection-image': artifact.path, style: { width: '100%', objectFit: 'contain' } }),
|
|
4232
4504
|
el('p', { className: 'db-muted' }, t('inspection.actual', { engine: artifact.engine || '—', width: artifact.width ?? '—', height: artifact.height ?? '—', samples: artifact.samples ?? '—' })),
|
|
@@ -4255,7 +4527,7 @@ window.__ModuleLoader__.load({
|
|
|
4255
4527
|
&& Number.isSafeInteger(artifact?.height) && artifact.height > 0
|
|
4256
4528
|
? { width: artifact.width, height: artifact.height } : {}
|
|
4257
4529
|
return el('div', { className: 'db-shot', 'data-compare': side, 'data-compare-kind': artifact === null ? 'empty' : 'image' },
|
|
4258
|
-
el('h5', null, title),
|
|
4530
|
+
el('div', { className: 'db-inline', style: { justifyContent: 'space-between', flexWrap: 'wrap' } }, el('h5', null, title), ImageDownload({ state, actions, artifact })),
|
|
4259
4531
|
artifact === null
|
|
4260
4532
|
? el('div', { className: 'db-muted' }, missing)
|
|
4261
4533
|
: [
|
|
@@ -4286,7 +4558,7 @@ window.__ModuleLoader__.load({
|
|
|
4286
4558
|
|
|
4287
4559
|
/** The revision axis: two revisions side by side, each one's newest image. */
|
|
4288
4560
|
const revisionPane = (entry, side) => el('div', { className: 'db-shot', 'data-compare': side },
|
|
4289
|
-
el('h5', null, entry === null ? '—' : `${entry.revision}${entry.isCurrent ? t('revisions.currentSuffix') : ''}`),
|
|
4561
|
+
el('div', { className: 'db-inline', style: { justifyContent: 'space-between', flexWrap: 'wrap' } }, el('h5', null, entry === null ? '—' : `${entry.revision}${entry.isCurrent ? t('revisions.currentSuffix') : ''}`), ImageDownload({ state, actions, artifact: entry ? sheetOf(entry) : null })),
|
|
4290
4562
|
entry === null
|
|
4291
4563
|
? el('div', { className: 'db-muted' }, t('revisions.missing'))
|
|
4292
4564
|
: [
|
|
@@ -4362,6 +4634,8 @@ window.__ModuleLoader__.load({
|
|
|
4362
4634
|
children: state.previewBusy ? t('preview.rendering') : t('preview.render'),
|
|
4363
4635
|
}),
|
|
4364
4636
|
Notice(state.previewResult, { border: 0, padding: '0 6px', marginBottom: 0 }),
|
|
4637
|
+
state.notices.preview?.kind === 'creation' && state.notices.preview.projectId === state.activeProjectId
|
|
4638
|
+
? Notice(state.notices.preview, { border: 0, padding: '0 6px', marginBottom: 0 }) : null,
|
|
4365
4639
|
),
|
|
4366
4640
|
el('div', { className: 'db-tabs' },
|
|
4367
4641
|
el('span', { className: 'db-muted' }, t('preview.compare')),
|
|
@@ -5263,6 +5537,7 @@ window.__ModuleLoader__.load({
|
|
|
5263
5537
|
ROUTES,
|
|
5264
5538
|
projectRoute,
|
|
5265
5539
|
artifactUrl,
|
|
5540
|
+
imageDownload: { key: imageDownloadKey, valid: imageDownloadValid, name: imageDownloadName, hash: imageByteHash },
|
|
5266
5541
|
shortDigest,
|
|
5267
5542
|
formatTime,
|
|
5268
5543
|
statusTone,
|
package/lib/index.js
CHANGED
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
BlenderError,
|
|
33
33
|
BlenderErrorCode,
|
|
34
34
|
HOST_API_VERSION,
|
|
35
|
+
CREATION_REQUEST_VERSION,
|
|
35
36
|
UI_PANEL_ID,
|
|
36
37
|
UI_ROUTES,
|
|
37
38
|
UI_ROUTE_PREFIX,
|
|
@@ -419,6 +420,7 @@ export default class BlenderUiHost extends Service {
|
|
|
419
420
|
export function statusForError(error) {
|
|
420
421
|
if (error?.code === 'UI_HOST_API_STALE') return 503
|
|
421
422
|
const referenceStatuses = { ASSET_REQUEST_INVALID: 400, ASSET_CONTENT_MISMATCH: 415, ASSET_TOO_LARGE: 413,
|
|
423
|
+
CREATION_REQUEST_INVALID: 400, CREATION_REQUEST_CONFLICT: 409, PROJECT_ID_INVALID: 400,
|
|
422
424
|
ASSET_FORMAT_UNAVAILABLE: 415, SCENE_VALIDATION_FAILED: 422, SCENE_SPEC_INVALID: 422,
|
|
423
425
|
PATH_SEGMENT_INVALID: 400, ASSET_HASH_MISMATCH: 409, ASSET_SOURCE_NOT_FOUND: 404 }
|
|
424
426
|
if (referenceStatuses[error.code]) return referenceStatuses[error.code]
|
|
@@ -623,13 +625,15 @@ export function createHandlers(ctx) {
|
|
|
623
625
|
/** Everything the panel needs to render itself from scratch, in one request. */
|
|
624
626
|
state: async ({ query }) => {
|
|
625
627
|
const listed = await studio().listProjects()
|
|
626
|
-
const projectId = query.projectId ?? listed.projects
|
|
628
|
+
const projectId = query.projectId ?? listed.projects.find(project => !project.creationPending)?.projectId ?? null
|
|
629
|
+
const creationPending = listed.projects.some(project => project.projectId === projectId && project.creationPending)
|
|
627
630
|
return {
|
|
628
631
|
panelId: UI_PANEL_ID,
|
|
629
632
|
recipeCatalog: studio().listRecipes?.() ?? { recipes: [], errors: [] },
|
|
630
633
|
projects: listed.projects.map(record => buildProjectView(record)),
|
|
631
634
|
projectsRoot: listed.projectsRoot,
|
|
632
|
-
|
|
635
|
+
creationRequestProtocol: studio().creationRequestProtocol ?? null,
|
|
636
|
+
selected: projectId === null || creationPending
|
|
633
637
|
? null
|
|
634
638
|
: await buildProjectState(studio(), projectId, query.revision ?? undefined, approvalThreshold()),
|
|
635
639
|
}
|
|
@@ -644,12 +648,16 @@ export function createHandlers(ctx) {
|
|
|
644
648
|
},
|
|
645
649
|
|
|
646
650
|
'projects.create': async ({ body }) => {
|
|
651
|
+
if (body.creationKey !== undefined && studio().creationRequestProtocol !== CREATION_REQUEST_VERSION) {
|
|
652
|
+
throw new BlenderError('UI_HOST_API_STALE', 'The running Host does not support creation recovery. Restart the profile with the updated DeepBlend packages before retrying.')
|
|
653
|
+
}
|
|
647
654
|
const created = await studio().createProject({
|
|
648
655
|
title: body.title,
|
|
649
656
|
goal: body.goal,
|
|
650
657
|
sceneSpec: body.sceneSpec,
|
|
651
658
|
recipe: body.recipe,
|
|
652
659
|
projectId: body.projectId,
|
|
660
|
+
creationKey: body.creationKey,
|
|
653
661
|
saveCheckpoint: true,
|
|
654
662
|
renderPreview: body.renderPreview === true,
|
|
655
663
|
})
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@deepblend/dsh-blender-ui",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.5",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "DeepBlend workbench UI: Host half (typed HTTP API) and Web Client half (sidebar, panels, tool cards).",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
}
|
|
20
20
|
},
|
|
21
21
|
"dependencies": {
|
|
22
|
-
"@deepblend/dsh-blender-contracts": "0.3.
|
|
22
|
+
"@deepblend/dsh-blender-contracts": "0.3.5"
|
|
23
23
|
},
|
|
24
24
|
"license": "MIT",
|
|
25
25
|
"repository": {
|