@deepblend/dsh-blender-ui 0.3.4 → 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 +110 -20
  2. package/lib/index.js +10 -2
  3. package/package.json +2 -2
package/lib/client.js CHANGED
@@ -488,6 +488,18 @@ window.__ModuleLoader__.load({
488
488
  'recipes.license': '作者与许可',
489
489
  'recipes.unavailable': '部分配方无法载入;可继续使用下方可用的配方。',
490
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,输入仍保留在当前页面。',
491
503
  'projects.createButton': '创建',
492
504
  'scene.currentRevision': '当前 revision',
493
505
  'scene.revisionCount': 'revision 数',
@@ -1025,6 +1037,18 @@ window.__ModuleLoader__.load({
1025
1037
  'recipes.license': 'Author and license',
1026
1038
  'recipes.unavailable': 'Some recipes could not be loaded. Available recipes are shown below.',
1027
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.',
1028
1052
  'projects.createButton': 'Create',
1029
1053
  'scene.currentRevision': 'current revision',
1030
1054
  'scene.revisionCount': 'revisions',
@@ -1267,6 +1291,7 @@ window.__ModuleLoader__.load({
1267
1291
  const PANEL_LABEL = 'Blender'
1268
1292
  /** The host API this half was written against; a mismatch is a deployment state. */
1269
1293
  const EXPECTED_HOST_API = 6
1294
+ const CREATION_REQUEST_PROTOCOL = 'deepblend.creation-request/v1'
1270
1295
  /** Polling cadence while something is live; M3 writes progress once a second. */
1271
1296
  const POLL_LIVE_MS = 1500
1272
1297
  /** Polling cadence when nothing is running. */
@@ -1329,6 +1354,23 @@ window.__ModuleLoader__.load({
1329
1354
  return `${base}${path}?v=${version}`
1330
1355
  }
1331
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
+
1332
1374
  const imageDownloadKey = (projectId, artifact) => JSON.stringify([projectId, artifact?.path, artifact?.sha256])
1333
1375
  function imageDownloadValid(artifact) {
1334
1376
  return Boolean(artifact && typeof artifact.path === 'string' && /\.png$/i.test(artifact.path)
@@ -2436,6 +2478,8 @@ window.__ModuleLoader__.load({
2436
2478
  diffError: null,
2437
2479
  previewBusy: false,
2438
2480
  imageDownloads: {},
2481
+ creationWork: null,
2482
+ creationRequestProtocol: null,
2439
2483
  inspectionForms: {},
2440
2484
  inspectionWork: {},
2441
2485
  inspectionRevisions: {},
@@ -2496,6 +2540,7 @@ window.__ModuleLoader__.load({
2496
2540
  let tick = 0
2497
2541
  let live = false
2498
2542
  let loadSequence = 0
2543
+ let creationAttempt = null
2499
2544
  let fileChoiceSignature = null, fileChoicesStopped = false
2500
2545
  const photographyControllers = new Map()
2501
2546
  const photographyWork = (projectId, changes) => set({ photographyWork: { ...data.photographyWork, [projectId]: { ...data.photographyWork[projectId], ...changes } } })
@@ -2550,7 +2595,7 @@ window.__ModuleLoader__.load({
2550
2595
  const setIn = (table, key, value) => { data = { ...data, [table]: { ...data[table], [key]: value } }; notify() }
2551
2596
 
2552
2597
  /** The project every per-project route is addressed to. */
2553
- 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
2554
2599
 
2555
2600
  /** Read everything the panel shows, in one pass. */
2556
2601
  const load = async () => {
@@ -2564,7 +2609,8 @@ window.__ModuleLoader__.load({
2564
2609
  return
2565
2610
  }
2566
2611
  const payload = stateResult.payload
2567
- 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
2568
2614
  const patch = {
2569
2615
  status: 'ok',
2570
2616
  error: null,
@@ -2572,6 +2618,7 @@ window.__ModuleLoader__.load({
2572
2618
  projects: payload.projects || [],
2573
2619
  recipeCatalog: payload.recipeCatalog ?? { recipes: [], errors: [] },
2574
2620
  projectsRoot: payload.projectsRoot ?? null,
2621
+ creationRequestProtocol: payload.creationRequestProtocol ?? null,
2575
2622
  selected: payload.selected ?? null,
2576
2623
  activeProjectId: active,
2577
2624
  currentRevision: payload.selected ? payload.selected.currentRevision : null,
@@ -2637,6 +2684,44 @@ window.__ModuleLoader__.load({
2637
2684
  /** A write just happened: re-run every read, including the view-specific ones. */
2638
2685
  const reload = () => { tick += 1; void load() }
2639
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
+
2640
2725
  /** Run one write, report it in this view's notice, then reload. */
2641
2726
  const write = async (view, path, body, describe) => {
2642
2727
  const outcome = await postJson(fetchImpl, path, body)
@@ -3282,24 +3367,12 @@ window.__ModuleLoader__.load({
3282
3367
  createProject: async () => {
3283
3368
  if (data.busy.create) return
3284
3369
  if (!recipeSelectionCurrent(data) || !recipeValuesValid(data.forms.recipe, data.forms.recipeParameters)) return
3285
- const withRecipe = Boolean(data.forms.recipe)
3286
- setIn('busy', 'create', true)
3287
- const outcome = await postJson(fetchImpl, ROUTES.projects, {
3288
- title: data.forms.title,
3289
- goal: data.forms.goal.length > 0 ? data.forms.goal : undefined,
3290
- renderPreview: withRecipe,
3291
- recipe: data.forms.recipe ? { id: data.forms.recipe.id, version: data.forms.recipe.version, digest: data.forms.recipe.digest, parameters: data.forms.recipeParameters } : undefined,
3292
- })
3293
- setIn('busy', 'create', false)
3294
- if (outcome.ok) {
3295
- set({ forms: { ...data.forms, title: '', goal: '', recipe: null, recipeParameters: {} }, projectId: outcome.payload.project.projectId, ...(withRecipe ? { view: 'preview', compareMode: 'result' } : {}) })
3296
- setIn('notices', 'projects', { ok: true, message: t('projects.created', { id: outcome.payload.project.projectId }) })
3297
- reload()
3298
- } else {
3299
- setIn('notices', 'projects', { ok: false, message: `${outcome.error.code}: ${outcome.error.message}` })
3300
- if (outcome.error.code === 'RECIPE_CHANGED' || outcome.error.code === 'RECIPE_NOT_FOUND') reload()
3301
- }
3370
+ const body = creationBody()
3371
+ const signature = creationSignature(body)
3372
+ if (!creationAttempt || creationAttempt.signature !== signature) creationAttempt = { body, signature, key: newCreationKey() }
3373
+ return submitCreation(creationAttempt)
3302
3374
  },
3375
+ retryCreation: () => data.busy.create || !creationAttempt ? undefined : submitCreation(creationAttempt),
3303
3376
 
3304
3377
  applyPatch: async () => {
3305
3378
  const projectId = data.activeProjectId
@@ -3365,6 +3438,7 @@ window.__ModuleLoader__.load({
3365
3438
  },
3366
3439
 
3367
3440
  renderPreview: async () => {
3441
+ setIn('notices', 'preview', null)
3368
3442
  set({ previewBusy: true, previewResult: null })
3369
3443
  const outcome = await postJson(fetchImpl, projectRoute(target(), '/preview'), { revision: data.selected?.scene?.revision })
3370
3444
  set({ previewBusy: false, ...(outcome.ok ? { compareMode: 'renders' } : {}) })
@@ -3489,10 +3563,12 @@ window.__ModuleLoader__.load({
3489
3563
  Button({
3490
3564
  tone: project.projectId === state.activeProjectId ? 'primary' : undefined,
3491
3565
  action: `select-project:${project.projectId}`,
3566
+ disabled: project.creationPending,
3492
3567
  onClick: () => actions.selectProject(project.projectId),
3493
3568
  children: project.title || project.projectId,
3494
3569
  }),
3495
3570
  el('span', { className: 'db-muted db-mono' }, project.projectId),
3571
+ project.creationPending ? Badge({ tone: 'live', name: 'creation-pending', children: t('projects.incomplete') }) : null,
3496
3572
  project.unfinishedJobs > 0 ? Badge({ tone: 'live', name: 'unfinished', children: t('projects.unfinishedJobs', { count: project.unfinishedJobs }) }) : null,
3497
3573
  el('span', { className: 'db-muted' }, t('projects.revisionCount', { count: project.revisionCount })),
3498
3574
  ),
@@ -3526,12 +3602,24 @@ window.__ModuleLoader__.load({
3526
3602
  Button({
3527
3603
  tone: 'primary',
3528
3604
  action: 'create-project',
3529
- 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),
3530
3606
  onClick: actions.createProject,
3531
3607
  children: state.busy.create ? (state.forms.recipe ? t('recipes.creatingPreview') : t('projects.creating')) : (state.forms.recipe ? t('recipes.createPreview') : t('projects.createButton')),
3532
3608
  }),
3533
3609
  ),
3534
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,
3535
3623
  ),
3536
3624
  el('details', { className: 'db-storage' }, el('summary', null, t('projects.storage')),
3537
3625
  el('span', { className: 'db-muted db-mono' }, `projectsRoot: ${state.projectsRoot || '—'}`)),
@@ -4546,6 +4634,8 @@ window.__ModuleLoader__.load({
4546
4634
  children: state.previewBusy ? t('preview.rendering') : t('preview.render'),
4547
4635
  }),
4548
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,
4549
4639
  ),
4550
4640
  el('div', { className: 'db-tabs' },
4551
4641
  el('span', { className: 'db-muted' }, t('preview.compare')),
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.4",
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.4"
22
+ "@deepblend/dsh-blender-contracts": "0.3.5"
23
23
  },
24
24
  "license": "MIT",
25
25
  "repository": {