@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.
Files changed (3) hide show
  1. package/lib/client.js +299 -24
  2. package/lib/index.js +10 -2
  3. 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 ?? (data.projects[0] ? data.projects[0].projectId : null)
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 active = projectId !== null ? projectId : (payload.projects && payload.projects[0] ? payload.projects[0].projectId : null)
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 withRecipe = Boolean(data.forms.recipe)
3113
- setIn('busy', 'create', true)
3114
- const outcome = await postJson(fetchImpl, ROUTES.projects, {
3115
- title: data.forms.title,
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[0]?.projectId ?? null
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
- selected: projectId === null
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",
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.3"
22
+ "@deepblend/dsh-blender-contracts": "0.3.5"
23
23
  },
24
24
  "license": "MIT",
25
25
  "repository": {