@deepblend/dsh-blender-ui 0.3.2 → 0.3.4
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 +189 -4
- 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': '新建项目',
|
|
@@ -985,6 +995,16 @@ window.__ModuleLoader__.load({
|
|
|
985
995
|
'preview.keptPrevious': '; the previous one is kept as “last render”, so they can be compared side by side',
|
|
986
996
|
'preview.firstSheet': '(this is the first; render once more to compare before and after)',
|
|
987
997
|
'preview.isArtifact': '. A preview is an artifact: it replaces the old image at the same path and creates no revision.',
|
|
998
|
+
'download.png': 'Save preview PNG',
|
|
999
|
+
'download.loading': 'Preparing PNG…',
|
|
1000
|
+
'download.help': 'Save this image’s original PNG at its displayed resolution and samples. Use render jobs for higher-quality delivery.',
|
|
1001
|
+
'download.ready': 'Sent to your browser to save: {filename}',
|
|
1002
|
+
'download.source': 'This image has incomplete source information. Generate a new preview before saving.',
|
|
1003
|
+
'download.changed': 'The image bytes do not match the displayed source. Refresh the preview and retry.',
|
|
1004
|
+
'download.failed': 'The preview image could not be read. Refresh and retry.',
|
|
1005
|
+
'download.unsupported': 'This browser cannot save the image. Use a browser with file-download support.',
|
|
1006
|
+
'download.timeout': 'Preparing the image timed out. No save was started. Retry.',
|
|
1007
|
+
'download.cancelled': 'Cancelled. No save was started.',
|
|
988
1008
|
'jobs.cancelRequested': 'cancellation requested, but the process is still there',
|
|
989
1009
|
'projects.empty': 'This workspace has no projects yet.',
|
|
990
1010
|
'projects.create': 'New project',
|
|
@@ -1309,6 +1329,130 @@ window.__ModuleLoader__.load({
|
|
|
1309
1329
|
return `${base}${path}?v=${version}`
|
|
1310
1330
|
}
|
|
1311
1331
|
|
|
1332
|
+
const imageDownloadKey = (projectId, artifact) => JSON.stringify([projectId, artifact?.path, artifact?.sha256])
|
|
1333
|
+
function imageDownloadValid(artifact) {
|
|
1334
|
+
return Boolean(artifact && typeof artifact.path === 'string' && /\.png$/i.test(artifact.path)
|
|
1335
|
+
&& /^[a-f0-9]{64}$/i.test(artifact.sha256 || '') && Number.isSafeInteger(artifact.bytes) && artifact.bytes > 0)
|
|
1336
|
+
}
|
|
1337
|
+
function imageDownloadMessage(work) {
|
|
1338
|
+
switch (work.messageKey) {
|
|
1339
|
+
case 'download.ready': return t('download.ready', { filename: work.filename || '' })
|
|
1340
|
+
case 'download.changed': return t('download.changed')
|
|
1341
|
+
case 'download.timeout': return t('download.timeout')
|
|
1342
|
+
case 'download.cancelled': return t('download.cancelled')
|
|
1343
|
+
case 'download.unsupported': return t('download.unsupported')
|
|
1344
|
+
default: return t('download.failed')
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
function imageDownloadName(projectId, artifact) {
|
|
1348
|
+
const label = value => String(value).replace(/[\\/:*?"<>|\u0000-\u001f]/g, '-').replace(/^[. ]+|[. ]+$/g, '').slice(0, 60) || 'image'
|
|
1349
|
+
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`
|
|
1350
|
+
}
|
|
1351
|
+
function savePngFile(bytes, filename) {
|
|
1352
|
+
const doc = typeof document === 'object' ? document : null
|
|
1353
|
+
if (!doc || typeof Blob !== 'function' || typeof URL === 'undefined' || typeof URL.createObjectURL !== 'function') throw Object.assign(new Error(t('download.unsupported')), { messageKey: 'download.unsupported' })
|
|
1354
|
+
const url = URL.createObjectURL(new Blob([bytes], { type: 'image/png' }))
|
|
1355
|
+
const link = doc.createElement('a')
|
|
1356
|
+
link.href = url; link.download = filename; link.hidden = true
|
|
1357
|
+
try { doc.body.appendChild(link); link.click() } finally {
|
|
1358
|
+
link.remove()
|
|
1359
|
+
// The browser needs the Blob until its download has started.
|
|
1360
|
+
setTimeout(() => URL.revokeObjectURL(url), 1000)
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
// SHA-256 per FIPS 180-4 §6.2.2. LAN HTTP lacks SubtleCrypto; keep byte
|
|
1364
|
+
// verification available there without adding a framework or network call.
|
|
1365
|
+
// https://csrc.nist.gov/pubs/fips/180-4/upd1/final
|
|
1366
|
+
const SHA256_ROUNDS = [
|
|
1367
|
+
0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5,
|
|
1368
|
+
0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174,
|
|
1369
|
+
0xe49b69c1,0xefbe4786,0x0fc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da,
|
|
1370
|
+
0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x06ca6351,0x14292967,
|
|
1371
|
+
0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85,
|
|
1372
|
+
0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070,
|
|
1373
|
+
0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3,
|
|
1374
|
+
0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2,
|
|
1375
|
+
]
|
|
1376
|
+
async function imageByteHash(bytes, signal, forceFallback = false) {
|
|
1377
|
+
const active = () => { if (signal?.aborted) throw Object.assign(new Error('cancelled'), { name: 'AbortError' }) }
|
|
1378
|
+
active()
|
|
1379
|
+
if (!forceFallback && globalThis.crypto?.subtle) {
|
|
1380
|
+
const digest = await globalThis.crypto.subtle.digest('SHA-256', bytes); active()
|
|
1381
|
+
return Array.from(new Uint8Array(digest), value => value.toString(16).padStart(2, '0')).join('')
|
|
1382
|
+
}
|
|
1383
|
+
const padded = new Uint8Array(Math.ceil((bytes.length + 9) / 64) * 64)
|
|
1384
|
+
padded.set(bytes); padded[bytes.length] = 128
|
|
1385
|
+
const input = new DataView(padded.buffer)
|
|
1386
|
+
input.setUint32(padded.length - 8, Math.floor(bytes.length * 8 / 4294967296))
|
|
1387
|
+
input.setUint32(padded.length - 4, (bytes.length * 8) >>> 0)
|
|
1388
|
+
const state = [0x6a09e667,0xbb67ae85,0x3c6ef372,0xa54ff53a,0x510e527f,0x9b05688c,0x1f83d9ab,0x5be0cd19]
|
|
1389
|
+
const words = new Uint32Array(64), rotate = (value, amount) => (value >>> amount) | (value << (32 - amount))
|
|
1390
|
+
for (let offset = 0; offset < padded.length; offset += 64) {
|
|
1391
|
+
active()
|
|
1392
|
+
for (let i = 0; i < 16; i++) words[i] = input.getUint32(offset + i * 4)
|
|
1393
|
+
for (let i = 16; i < 64; i++) {
|
|
1394
|
+
const a = words[i - 15], b = words[i - 2]
|
|
1395
|
+
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
|
|
1396
|
+
}
|
|
1397
|
+
let [a,b,c,d,e,f,g,h] = state
|
|
1398
|
+
for (let i = 0; i < 64; i++) {
|
|
1399
|
+
const first = (h + (rotate(e,6)^rotate(e,11)^rotate(e,25)) + ((e&f)^((~e)&g)) + SHA256_ROUNDS[i] + words[i]) >>> 0
|
|
1400
|
+
const second = ((rotate(a,2)^rotate(a,13)^rotate(a,22)) + ((a&b)^(a&c)^(b&c))) >>> 0
|
|
1401
|
+
h=g;g=f;f=e;e=(d+first)>>>0;d=c;c=b;b=a;a=(first+second)>>>0
|
|
1402
|
+
}
|
|
1403
|
+
const round = [a,b,c,d,e,f,g,h]
|
|
1404
|
+
for (let i = 0; i < 8; i++) state[i] = (state[i] + round[i]) >>> 0
|
|
1405
|
+
if (offset && offset % 65536 === 0) await new Promise(resolve => setTimeout(resolve, 0))
|
|
1406
|
+
}
|
|
1407
|
+
active()
|
|
1408
|
+
return state.map(value => value.toString(16).padStart(8, '0')).join('')
|
|
1409
|
+
}
|
|
1410
|
+
async function verifiedPngBytes(response, artifact, signal) {
|
|
1411
|
+
const fail = key => { throw Object.assign(new Error(t(key)), { messageKey: key }) }
|
|
1412
|
+
const active = () => { if (signal.aborted) throw Object.assign(new Error('cancelled'), { name: 'AbortError' }) }
|
|
1413
|
+
active()
|
|
1414
|
+
if (!response.ok || response.headers.get('content-type')?.split(';')[0].trim().toLowerCase() !== 'image/png') {
|
|
1415
|
+
await response.body?.cancel?.().catch(() => {})
|
|
1416
|
+
fail('download.failed')
|
|
1417
|
+
}
|
|
1418
|
+
const length = response.headers.get('content-length')
|
|
1419
|
+
if (length !== null && Number(length) !== artifact.bytes) { await response.body?.cancel?.(); fail('download.changed') }
|
|
1420
|
+
let bytes
|
|
1421
|
+
if (response.body?.getReader) {
|
|
1422
|
+
const reader = response.body.getReader(), chunks = []
|
|
1423
|
+
let received = 0
|
|
1424
|
+
const abort = () => { void reader.cancel().catch(() => {}) }
|
|
1425
|
+
signal.addEventListener('abort', abort, { once: true })
|
|
1426
|
+
try {
|
|
1427
|
+
for (;;) {
|
|
1428
|
+
const item = await reader.read(); active()
|
|
1429
|
+
if (item.done) break
|
|
1430
|
+
received += item.value.byteLength
|
|
1431
|
+
if (received > artifact.bytes) { await reader.cancel(); fail('download.changed') }
|
|
1432
|
+
chunks.push(item.value)
|
|
1433
|
+
}
|
|
1434
|
+
} catch (error) { await reader.cancel().catch(() => {}); throw error }
|
|
1435
|
+
finally { signal.removeEventListener('abort', abort); reader.releaseLock() }
|
|
1436
|
+
if (received !== artifact.bytes) fail('download.changed')
|
|
1437
|
+
bytes = new Uint8Array(received)
|
|
1438
|
+
let offset = 0
|
|
1439
|
+
for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.byteLength }
|
|
1440
|
+
} else {
|
|
1441
|
+
bytes = new Uint8Array(await response.arrayBuffer()); active()
|
|
1442
|
+
if (bytes.byteLength !== artifact.bytes) fail('download.changed')
|
|
1443
|
+
}
|
|
1444
|
+
const signature = [137, 80, 78, 71, 13, 10, 26, 10]
|
|
1445
|
+
if (bytes.length < 24 || !signature.every((byte, index) => bytes[index] === byte)) fail('download.changed')
|
|
1446
|
+
if (Number.isSafeInteger(artifact.width) && Number.isSafeInteger(artifact.height)) {
|
|
1447
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
|
|
1448
|
+
if (view.getUint32(16) !== artifact.width || view.getUint32(20) !== artifact.height) fail('download.changed')
|
|
1449
|
+
}
|
|
1450
|
+
const hash = await imageByteHash(bytes, signal)
|
|
1451
|
+
active()
|
|
1452
|
+
if (hash !== artifact.sha256.toLowerCase()) fail('download.changed')
|
|
1453
|
+
return bytes
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1312
1456
|
/** Short "when was this produced" for an artifact, or null. */
|
|
1313
1457
|
function artifactTime(artifact) {
|
|
1314
1458
|
const at = artifact && artifact.at ? artifact.at : null
|
|
@@ -2291,6 +2435,7 @@ window.__ModuleLoader__.load({
|
|
|
2291
2435
|
diff: null,
|
|
2292
2436
|
diffError: null,
|
|
2293
2437
|
previewBusy: false,
|
|
2438
|
+
imageDownloads: {},
|
|
2294
2439
|
inspectionForms: {},
|
|
2295
2440
|
inspectionWork: {},
|
|
2296
2441
|
inspectionRevisions: {},
|
|
@@ -2339,6 +2484,9 @@ window.__ModuleLoader__.load({
|
|
|
2339
2484
|
const fetchImpl = settings.fetch ?? ((...args) => fetch(...args))
|
|
2340
2485
|
const pollLiveMs = settings.pollLiveMs ?? POLL_LIVE_MS
|
|
2341
2486
|
const pollIdleMs = settings.pollIdleMs ?? POLL_IDLE_MS
|
|
2487
|
+
const saveImage = settings.saveImage ?? savePngFile
|
|
2488
|
+
const imageDownloadTimeoutMs = settings.imageDownloadTimeoutMs ?? 30000
|
|
2489
|
+
if (!Number.isSafeInteger(imageDownloadTimeoutMs) || imageDownloadTimeoutMs <= 0 || imageDownloadTimeoutMs > 2147483647) throw new Error('Image download timeout must be a positive timer-safe integer')
|
|
2342
2490
|
|
|
2343
2491
|
const preferenceStorage = appearanceStorage(settings)
|
|
2344
2492
|
let data = { ...emptySnapshot(), appearance: savedAppearance(preferenceStorage) }
|
|
@@ -2352,6 +2500,7 @@ window.__ModuleLoader__.load({
|
|
|
2352
2500
|
const photographyControllers = new Map()
|
|
2353
2501
|
const photographyWork = (projectId, changes) => set({ photographyWork: { ...data.photographyWork, [projectId]: { ...data.photographyWork[projectId], ...changes } } })
|
|
2354
2502
|
const inspectionControllers = new Map()
|
|
2503
|
+
const imageDownloadControllers = new Map()
|
|
2355
2504
|
const assetControllers = new Map()
|
|
2356
2505
|
const assetListSequences = new Map()
|
|
2357
2506
|
const bundleFiles = new Map(), bundleOperations = new Map()
|
|
@@ -3106,6 +3255,30 @@ window.__ModuleLoader__.load({
|
|
|
3106
3255
|
}
|
|
3107
3256
|
},
|
|
3108
3257
|
|
|
3258
|
+
downloadImage: async ({ projectId, artifactBase, artifact }) => {
|
|
3259
|
+
if (fileChoicesStopped || !projectId || typeof artifactBase !== 'string' || !artifactBase || !imageDownloadValid(artifact)) return
|
|
3260
|
+
const source = editorClone(artifact), key = imageDownloadKey(projectId, source)
|
|
3261
|
+
if (imageDownloadControllers.has(key)) return
|
|
3262
|
+
const controller = new AbortController(); imageDownloadControllers.set(key, controller)
|
|
3263
|
+
const work = changes => set({ imageDownloads: { ...data.imageDownloads, [key]: { ...data.imageDownloads[key], ...changes } } })
|
|
3264
|
+
work({ busy: true, status: 'preparing', messageKey: null, filename: null })
|
|
3265
|
+
const deadline = setTimeout(() => controller.abort('timeout'), imageDownloadTimeoutMs)
|
|
3266
|
+
try {
|
|
3267
|
+
const response = await fetchImpl(artifactUrl(artifactBase, source), { headers: { accept: 'image/png' }, cache: 'no-store', signal: controller.signal })
|
|
3268
|
+
const bytes = await verifiedPngBytes(response, source, controller.signal)
|
|
3269
|
+
if (controller.signal.aborted) throw Object.assign(new Error('cancelled'), { name: 'AbortError' })
|
|
3270
|
+
const filename = imageDownloadName(projectId, source)
|
|
3271
|
+
clearTimeout(deadline)
|
|
3272
|
+
await saveImage(bytes, filename)
|
|
3273
|
+
work({ status: 'ready', filename, messageKey: 'download.ready' })
|
|
3274
|
+
} catch (error) {
|
|
3275
|
+
work({ status: controller.signal.aborted ? 'cancelled' : 'error', messageKey: controller.signal.aborted
|
|
3276
|
+
? controller.signal.reason === 'timeout' ? 'download.timeout' : 'download.cancelled'
|
|
3277
|
+
: error.messageKey || 'download.failed' })
|
|
3278
|
+
} finally { clearTimeout(deadline); imageDownloadControllers.delete(key); work({ busy: false }) }
|
|
3279
|
+
},
|
|
3280
|
+
cancelImageDownload: key => imageDownloadControllers.get(key)?.abort('cancelled'),
|
|
3281
|
+
|
|
3109
3282
|
createProject: async () => {
|
|
3110
3283
|
if (data.busy.create) return
|
|
3111
3284
|
if (!recipeSelectionCurrent(data) || !recipeValuesValid(data.forms.recipe, data.forms.recipeParameters)) return
|
|
@@ -3281,6 +3454,7 @@ window.__ModuleLoader__.load({
|
|
|
3281
3454
|
photographyControllers.clear()
|
|
3282
3455
|
for (const controller of inspectionControllers.values()) controller.abort()
|
|
3283
3456
|
inspectionControllers.clear()
|
|
3457
|
+
for (const controller of imageDownloadControllers.values()) controller.abort('stopped')
|
|
3284
3458
|
for (const controller of assetControllers.values()) controller.abort()
|
|
3285
3459
|
assetControllers.clear()
|
|
3286
3460
|
for (const operation of bundleOperations.values()) void cancelBundle(operation).catch(() => {})
|
|
@@ -3917,7 +4091,7 @@ window.__ModuleLoader__.load({
|
|
|
3917
4091
|
work.artifact ? el('figure', { 'data-photography-artifact': work.artifact.path },
|
|
3918
4092
|
el('figcaption', null, t('photo.ready', { revision: edit.after, camera: edit.cameraId, frame: edit.frame })),
|
|
3919
4093
|
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,
|
|
4094
|
+
PreviewSettings(work.artifact), ImageDownload({ state, actions, artifact: work.artifact })) : null) : null,
|
|
3921
4095
|
work.error ? el('p', { className: 'db-error', role: 'status', 'data-photography-error': true }, work.error) : null)
|
|
3922
4096
|
}
|
|
3923
4097
|
|
|
@@ -4193,6 +4367,16 @@ window.__ModuleLoader__.load({
|
|
|
4193
4367
|
references: scene.project.referenceImages?.length || 0, images: evidence?.diagnostics?.length || 0 }) : t('guide.noProject')),
|
|
4194
4368
|
el('small', { className: 'db-muted' }, t('guide.help'))))
|
|
4195
4369
|
}
|
|
4370
|
+
function ImageDownload({ state, actions, artifact }) {
|
|
4371
|
+
if (!artifact) return null
|
|
4372
|
+
const request = { projectId: state.activeProjectId, artifactBase: state.artifactBase, artifact }
|
|
4373
|
+
const key = imageDownloadKey(request.projectId, artifact), work = state.imageDownloads?.[key] || {}, valid = Boolean(imageDownloadValid(artifact) && request.projectId && request.artifactBase)
|
|
4374
|
+
return el('div', { className: 'db-inline', style: { flexWrap: 'wrap' }, 'data-image-download': artifact.path, 'data-image-download-digest': artifact.sha256 || '' },
|
|
4375
|
+
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') }),
|
|
4376
|
+
work.busy ? Button({ action: `cancel-image-download:${artifact.path}`, onClick: () => actions.cancelImageDownload(key), children: t('inspection.cancel') }) : null,
|
|
4377
|
+
!valid ? el('small', { className: 'db-muted' }, t('download.source')) : null,
|
|
4378
|
+
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)
|
|
4379
|
+
}
|
|
4196
4380
|
function InspectionPanel({ state, actions }) {
|
|
4197
4381
|
const projectId = state.activeProjectId, scene = state.selected?.scene, form = inspectionForm(state), work = state.inspectionWork?.[projectId] || {}
|
|
4198
4382
|
if (!scene || !form) return null
|
|
@@ -4226,7 +4410,7 @@ window.__ModuleLoader__.load({
|
|
|
4226
4410
|
selectedRevision !== scene.revision ? el('p', { className: 'db-muted' }, t('inspection.history', { revision: selectedRevision, current: scene.revision })) : null,
|
|
4227
4411
|
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
4412
|
'data-inspection-artifact': artifact.path, 'data-inspection-mode': artifact.mode, 'data-inspection-revision': artifact.sourceRevision || selectedRevision },
|
|
4229
|
-
el('h5', null, t(`inspection.${artifact.mode}`)),
|
|
4413
|
+
el('h5', null, t(`inspection.${artifact.mode}`)), ImageDownload({ state, actions, artifact }),
|
|
4230
4414
|
el('p', null, t('inspection.identity', { revision: artifact.sourceRevision || selectedRevision, camera: artifact.cameraId || '—', frame: artifact.frame ?? '—' })),
|
|
4231
4415
|
el('img', { src: artifactUrl(state.artifactBase, artifact), alt: t(`inspection.${artifact.mode}`), 'data-inspection-image': artifact.path, style: { width: '100%', objectFit: 'contain' } }),
|
|
4232
4416
|
el('p', { className: 'db-muted' }, t('inspection.actual', { engine: artifact.engine || '—', width: artifact.width ?? '—', height: artifact.height ?? '—', samples: artifact.samples ?? '—' })),
|
|
@@ -4255,7 +4439,7 @@ window.__ModuleLoader__.load({
|
|
|
4255
4439
|
&& Number.isSafeInteger(artifact?.height) && artifact.height > 0
|
|
4256
4440
|
? { width: artifact.width, height: artifact.height } : {}
|
|
4257
4441
|
return el('div', { className: 'db-shot', 'data-compare': side, 'data-compare-kind': artifact === null ? 'empty' : 'image' },
|
|
4258
|
-
el('h5', null, title),
|
|
4442
|
+
el('div', { className: 'db-inline', style: { justifyContent: 'space-between', flexWrap: 'wrap' } }, el('h5', null, title), ImageDownload({ state, actions, artifact })),
|
|
4259
4443
|
artifact === null
|
|
4260
4444
|
? el('div', { className: 'db-muted' }, missing)
|
|
4261
4445
|
: [
|
|
@@ -4286,7 +4470,7 @@ window.__ModuleLoader__.load({
|
|
|
4286
4470
|
|
|
4287
4471
|
/** The revision axis: two revisions side by side, each one's newest image. */
|
|
4288
4472
|
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') : ''}`),
|
|
4473
|
+
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
4474
|
entry === null
|
|
4291
4475
|
? el('div', { className: 'db-muted' }, t('revisions.missing'))
|
|
4292
4476
|
: [
|
|
@@ -5263,6 +5447,7 @@ window.__ModuleLoader__.load({
|
|
|
5263
5447
|
ROUTES,
|
|
5264
5448
|
projectRoute,
|
|
5265
5449
|
artifactUrl,
|
|
5450
|
+
imageDownload: { key: imageDownloadKey, valid: imageDownloadValid, name: imageDownloadName, hash: imageByteHash },
|
|
5266
5451
|
shortDigest,
|
|
5267
5452
|
formatTime,
|
|
5268
5453
|
statusTone,
|
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.4",
|
|
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.4"
|
|
23
23
|
},
|
|
24
24
|
"license": "MIT",
|
|
25
25
|
"repository": {
|