@deepblend/dsh-blender-ui 0.3.4 → 0.3.6

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 +360 -20
  2. package/lib/index.js +10 -2
  3. package/package.json +2 -2
package/lib/client.js CHANGED
@@ -488,6 +488,34 @@ 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.drafts': '继续创建草稿',
499
+ 'projects.draftHint': '创建草稿保存在此浏览器。恢复只填写输入,不会自动创建项目。',
500
+ 'projects.draftSaved': '当前创建草稿已保存到此浏览器。',
501
+ 'projects.draftUnavailable': '此浏览器暂时无法保存草稿。当前输入仍可使用,但重载或关闭页面后可能丢失。',
502
+ 'projects.draftFull': '创建草稿已满。删除不用的浏览器草稿后可继续保存,当前输入仍保留。',
503
+ 'projects.draftTooLarge': '当前创建草稿过大,无法保存到浏览器。当前输入仍保留。',
504
+ 'projects.draftInvalid': '这份浏览器草稿无法读取,原记录没有被更改。',
505
+ 'projects.draftMissingRecipe': '原配方 {id}@{version} 当前不可用。原创建请求仍可重试;新建作品前请选择当前配方。',
506
+ 'projects.draftOldParameters': '原配方参数',
507
+ 'projects.draftRestored': '已恢复创建草稿。检查输入后继续;上次请求需要明确重试。',
508
+ 'projects.draftRestore': '继续这份草稿',
509
+ 'projects.draftDelete': '删除浏览器草稿',
510
+ 'projects.draftClear': '清空当前创建草稿',
511
+ 'projects.draftUncertain': '创建结果尚未确认',
512
+ 'projects.untitledDraft': '未命名创建草稿',
513
+ 'projects.damagedDraft': '无法读取的创建草稿',
514
+ 'projects.refreshList': '刷新项目列表',
515
+ 'projects.creationDetails': '详细诊断',
516
+ 'projects.environmentGuide': '查看环境检查指南',
517
+ 'projects.creationConflict': '上次创建记录需要检查。输入已保留;请刷新列表并查看详细诊断,确认项目状态。',
518
+ 'projects.hostNeedsRestart': '当前运行的 Host 尚不支持创建恢复。请更新 DeepBlend 并重启 DSH,输入仍保留在当前页面。',
491
519
  'projects.createButton': '创建',
492
520
  'scene.currentRevision': '当前 revision',
493
521
  'scene.revisionCount': 'revision 数',
@@ -1025,6 +1053,34 @@ window.__ModuleLoader__.load({
1025
1053
  'recipes.license': 'Author and license',
1026
1054
  'recipes.unavailable': 'Some recipes could not be loaded. Available recipes are shown below.',
1027
1055
  'projects.creating': 'Creating…',
1056
+ 'projects.incomplete': 'Creation is not complete',
1057
+ 'projects.recovered': 'Opened the previously created {id}; no duplicate creation or render',
1058
+ 'projects.replyMissing': 'The creation result could not be received. Your inputs are kept; retry the previous creation to check the same request.',
1059
+ 'projects.environmentFailed': 'Blender is not ready. Your inputs are kept; check its installation and path, then retry.',
1060
+ '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.',
1061
+ 'projects.retryCreation': 'Retry previous creation',
1062
+ 'projects.originalTitle': 'Previous creation: {title}',
1063
+ 'projects.drafts': 'Continue a creation draft',
1064
+ 'projects.draftHint': 'Creation drafts stay in this browser. Restoring fills the inputs and does not create a project automatically.',
1065
+ 'projects.draftSaved': 'The current creation draft is saved in this browser.',
1066
+ 'projects.draftUnavailable': 'This browser cannot save the draft right now. Current inputs still work, but reloading or closing the page may lose them.',
1067
+ 'projects.draftFull': 'Creation draft storage is full. Delete unused browser drafts to save more; current inputs remain.',
1068
+ 'projects.draftTooLarge': 'This creation draft is too large to save in the browser. Current inputs remain.',
1069
+ 'projects.draftInvalid': 'This browser draft cannot be read. Its original record has not been changed.',
1070
+ 'projects.draftMissingRecipe': 'Recipe {id}@{version} is currently unavailable. You can still retry its original creation request; choose a current recipe before starting a new project.',
1071
+ 'projects.draftOldParameters': 'Original recipe parameters',
1072
+ 'projects.draftRestored': 'Creation draft restored. Check the inputs before continuing; retry the previous request explicitly.',
1073
+ 'projects.draftRestore': 'Continue this draft',
1074
+ 'projects.draftDelete': 'Delete browser draft',
1075
+ 'projects.draftClear': 'Clear current creation draft',
1076
+ 'projects.draftUncertain': 'Creation result unconfirmed',
1077
+ 'projects.untitledDraft': 'Untitled creation draft',
1078
+ 'projects.damagedDraft': 'Unreadable creation draft',
1079
+ 'projects.refreshList': 'Refresh project list',
1080
+ 'projects.creationDetails': 'Diagnostic details',
1081
+ 'projects.environmentGuide': 'Open the environment check guide',
1082
+ 'projects.creationConflict': 'The previous creation record needs inspection. Your inputs are kept; refresh the list and read the diagnostic details to check its state.',
1083
+ 'projects.hostNeedsRestart': 'The running Host does not support creation recovery. Update DeepBlend and restart DSH; your inputs remain on this page.',
1028
1084
  'projects.createButton': 'Create',
1029
1085
  'scene.currentRevision': 'current revision',
1030
1086
  'scene.revisionCount': 'revisions',
@@ -1267,6 +1323,7 @@ window.__ModuleLoader__.load({
1267
1323
  const PANEL_LABEL = 'Blender'
1268
1324
  /** The host API this half was written against; a mismatch is a deployment state. */
1269
1325
  const EXPECTED_HOST_API = 6
1326
+ const CREATION_REQUEST_PROTOCOL = 'deepblend.creation-request/v1'
1270
1327
  /** Polling cadence while something is live; M3 writes progress once a second. */
1271
1328
  const POLL_LIVE_MS = 1500
1272
1329
  /** Polling cadence when nothing is running. */
@@ -1329,6 +1386,92 @@ window.__ModuleLoader__.load({
1329
1386
  return `${base}${path}?v=${version}`
1330
1387
  }
1331
1388
 
1389
+ const creationSignature = body => JSON.stringify(body, (_, value) => value && typeof value === 'object' && !Array.isArray(value)
1390
+ ? Object.fromEntries(Object.keys(value).sort().map(key => [key, value[key]])) : value)
1391
+ let creationKeySequence = 0
1392
+ function newCreationKey() {
1393
+ const bytes = new Uint32Array(4)
1394
+ if (globalThis.crypto?.getRandomValues) globalThis.crypto.getRandomValues(bytes)
1395
+ else for (let i = 0; i < bytes.length; i++) bytes[i] = Math.floor(Math.random() * 4294967296)
1396
+ return `create-${Date.now().toString(36)}-${(++creationKeySequence).toString(36)}-${[...bytes].map(n => n.toString(16).padStart(8, '0')).join('')}`
1397
+ }
1398
+ const CREATION_DRAFT_VERSION = 'deepblend.creation-draft/v1'
1399
+ const CREATION_DRAFT_BYTES = 262144
1400
+ const creationDraftPrefix = root => `${CREATION_DRAFT_VERSION}:${encodeURIComponent(root)}:`
1401
+ const creationRecipeReference = recipe => recipe ? { id: recipe.id, version: recipe.version, digest: recipe.digest } : null
1402
+ function creationDraftJson(value) {
1403
+ if (value === null || typeof value === 'string' || typeof value === 'boolean') return
1404
+ if (typeof value === 'number' && Number.isFinite(value)) return
1405
+ if (!value || typeof value !== 'object') throw new Error('Draft input is not JSON')
1406
+ for (const item of Object.values(value)) creationDraftJson(item)
1407
+ }
1408
+ function creationDraftForms(forms) {
1409
+ const snapshot = { title: forms.title, goal: forms.goal,
1410
+ recipe: creationRecipeReference(forms.recipe), recipeParameters: forms.recipeParameters }
1411
+ creationDraftJson(snapshot)
1412
+ return editorClone(snapshot)
1413
+ }
1414
+ const creationRequestBody = forms => editorClone({ title: forms.title.trim(),
1415
+ goal: forms.goal.length > 0 ? forms.goal : undefined, renderPreview: Boolean(forms.recipe),
1416
+ recipe: forms.recipe ? { ...creationRecipeReference(forms.recipe), parameters: forms.recipeParameters } : undefined })
1417
+ function validCreationRecipeReference(recipe) {
1418
+ return recipe === null || Boolean(recipe && typeof recipe === 'object' && !Array.isArray(recipe)
1419
+ && typeof recipe.id === 'string' && recipe.id && typeof recipe.version === 'string' && recipe.version
1420
+ && /^[a-f0-9]{64}$/.test(recipe.digest || ''))
1421
+ }
1422
+ function parseCreationDraft(raw, root, id) {
1423
+ try {
1424
+ if (typeof raw !== 'string' || new Blob([raw]).size > CREATION_DRAFT_BYTES) return null
1425
+ const draft = JSON.parse(raw), forms = draft.forms
1426
+ creationDraftJson(forms)
1427
+ if (draft.schemaVersion !== CREATION_DRAFT_VERSION || draft.projectsRoot !== root || draft.id !== id
1428
+ || (draft.ownerActive !== undefined && typeof draft.ownerActive !== 'boolean')
1429
+ || !Number.isFinite(Date.parse(draft.savedAt)) || !forms || typeof forms.title !== 'string'
1430
+ || typeof forms.goal !== 'string' || !validCreationRecipeReference(forms.recipe)
1431
+ || Object.keys(forms).some(key => !['title', 'goal', 'recipe', 'recipeParameters'].includes(key))
1432
+ || !forms.recipeParameters || typeof forms.recipeParameters !== 'object' || Array.isArray(forms.recipeParameters)) return null
1433
+ const attempt = draft.attempt
1434
+ if (attempt !== null) {
1435
+ const body = attempt?.body
1436
+ creationDraftJson(body)
1437
+ if (!attempt || typeof attempt.key !== 'string' || !attempt.key.trim() || attempt.key.length > 128 || /[\x00-\x1f\x7f]/.test(attempt.key)
1438
+ || !body || typeof body.title !== 'string' || !body.title.trim() || typeof body.renderPreview !== 'boolean'
1439
+ || (body.goal !== undefined && typeof body.goal !== 'string')
1440
+ || (body.recipe !== undefined && (!body.recipe || !validCreationRecipeReference(body.recipe)
1441
+ || !body.recipe.parameters || typeof body.recipe.parameters !== 'object' || Array.isArray(body.recipe.parameters)))
1442
+ || body.renderPreview !== Boolean(body.recipe)
1443
+ || Object.keys(body).some(key => !['title', 'goal', 'recipe', 'renderPreview'].includes(key))
1444
+ || attempt.signature !== creationSignature(body)) return null
1445
+ }
1446
+ return draft
1447
+ } catch { return null }
1448
+ }
1449
+ function creationDraftStorage(settings) {
1450
+ try { return settings.creationDraftStorage ?? globalThis.localStorage } catch { return undefined }
1451
+ }
1452
+ function readCreationDrafts(storage, root) {
1453
+ if (!root) return { entries: [], available: false }
1454
+ try {
1455
+ if (!storage || typeof storage.key !== 'function' || !Number.isSafeInteger(storage.length)) return { entries: [], available: false }
1456
+ const prefix = creationDraftPrefix(root), entries = []
1457
+ for (let index = 0; index < storage.length; index++) {
1458
+ const key = storage.key(index)
1459
+ if (typeof key !== 'string' || !key.startsWith(prefix)) continue
1460
+ const id = key.slice(prefix.length), raw = storage.getItem(key), draft = parseCreationDraft(raw, root, id)
1461
+ entries.push({ id, raw, draft })
1462
+ }
1463
+ entries.sort((a, b) => String(b.draft?.savedAt || '').localeCompare(String(a.draft?.savedAt || '')))
1464
+ return { entries, available: true }
1465
+ } catch { return { entries: [], available: false } }
1466
+ }
1467
+ function creationErrorMessage(code) {
1468
+ if (code === 'UI_FETCH_FAILED' || /^HTTP_/.test(code)) return t('projects.replyMissing')
1469
+ if (code === 'BLENDER_NOT_FOUND' || code === 'RUNTIME_UNAVAILABLE') return t('projects.environmentFailed')
1470
+ if (code === 'CREATION_REQUEST_CONFLICT') return t('projects.creationConflict')
1471
+ if (code === 'UI_HOST_API_STALE') return t('projects.hostNeedsRestart')
1472
+ return t('projects.creationFailed')
1473
+ }
1474
+
1332
1475
  const imageDownloadKey = (projectId, artifact) => JSON.stringify([projectId, artifact?.path, artifact?.sha256])
1333
1476
  function imageDownloadValid(artifact) {
1334
1477
  return Boolean(artifact && typeof artifact.path === 'string' && /\.png$/i.test(artifact.path)
@@ -2436,6 +2579,11 @@ window.__ModuleLoader__.load({
2436
2579
  diffError: null,
2437
2580
  previewBusy: false,
2438
2581
  imageDownloads: {},
2582
+ creationWork: null,
2583
+ creationRequestProtocol: null,
2584
+ creationDrafts: [],
2585
+ creationDraftStorageStatus: null,
2586
+ creationDraftMissingRecipe: null,
2439
2587
  inspectionForms: {},
2440
2588
  inspectionWork: {},
2441
2589
  inspectionRevisions: {},
@@ -2496,6 +2644,12 @@ window.__ModuleLoader__.load({
2496
2644
  let tick = 0
2497
2645
  let live = false
2498
2646
  let loadSequence = 0
2647
+ let creationAttempt = null
2648
+ const draftStorage = creationDraftStorage(settings)
2649
+ let creationDraftId = newCreationKey(), creationDraftRoot = null, persistedCreationSignature = null
2650
+ let restoredCreationSource = null, creationDraftWriting = false
2651
+ let creationDraftClosed = false
2652
+ const draftLifecycle = settings.creationDraftLifecycle ?? globalThis.window
2499
2653
  let fileChoiceSignature = null, fileChoicesStopped = false
2500
2654
  const photographyControllers = new Map()
2501
2655
  const photographyWork = (projectId, changes) => set({ photographyWork: { ...data.photographyWork, [projectId]: { ...data.photographyWork[projectId], ...changes } } })
@@ -2539,6 +2693,7 @@ window.__ModuleLoader__.load({
2539
2693
  const assetWork = (projectId, changes) => set({ assetWork: { ...data.assetWork, [projectId]: { ...data.assetWork[projectId], ...changes } } })
2540
2694
 
2541
2695
  const notify = () => {
2696
+ persistCreationDraft()
2542
2697
  const signature = JSON.stringify([data.activeProjectId, data.selected?.scene?.revision, data.view, ...FILE_FIELDS.map(field => fileChoiceAllowed(data, field))])
2543
2698
  if (signature !== fileChoiceSignature) { data.fileChoiceGeneration += 1; fileChoiceSignature = signature }
2544
2699
  snapshot = { ...data, forms: { ...data.forms }, busy: { ...data.busy }, notices: { ...data.notices } }
@@ -2549,8 +2704,58 @@ window.__ModuleLoader__.load({
2549
2704
  /** Change one nested table. @param {'forms'|'busy'|'notices'} table @param {string} key @param {any} value */
2550
2705
  const setIn = (table, key, value) => { data = { ...data, [table]: { ...data[table], [key]: value } }; notify() }
2551
2706
 
2707
+ function refreshCreationDrafts() {
2708
+ const reading = readCreationDrafts(draftStorage, creationDraftRoot)
2709
+ data.creationDrafts = reading.entries.filter(entry => entry.id !== creationDraftId).map(entry => ({ id: entry.id,
2710
+ title: entry.draft?.forms.title || t('projects.untitledDraft'), savedAt: entry.draft?.savedAt || null,
2711
+ uncertain: Boolean(entry.draft?.attempt), invalid: !entry.draft }))
2712
+ return reading
2713
+ }
2714
+ function persistCreationDraft() {
2715
+ if (creationDraftClosed || creationDraftWriting || !creationDraftRoot || data.projectsRoot !== creationDraftRoot) return
2716
+ creationDraftWriting = true
2717
+ try {
2718
+ const forms = { ...creationDraftForms(data.forms), recipe: creationRecipeReference(data.forms.recipe) || data.creationDraftMissingRecipe }, signature = JSON.stringify([forms, creationAttempt])
2719
+ if (signature === persistedCreationSignature) return
2720
+ const reading = refreshCreationDrafts()
2721
+ if (!reading.available) { data.creationDraftStorageStatus = 'unavailable'; return }
2722
+ const key = creationDraftPrefix(creationDraftRoot) + creationDraftId
2723
+ if (!forms.title && !forms.goal && !forms.recipe && !Object.keys(forms.recipeParameters).length && !creationAttempt) {
2724
+ draftStorage.removeItem(key); data.creationDraftStorageStatus = null
2725
+ } else {
2726
+ if (!reading.entries.some(entry => entry.id === creationDraftId) && reading.entries.length >= 20) {
2727
+ data.creationDraftStorageStatus = 'full'; return
2728
+ }
2729
+ const raw = JSON.stringify({ schemaVersion: CREATION_DRAFT_VERSION, projectsRoot: creationDraftRoot,
2730
+ id: creationDraftId, ownerActive: true, savedAt: new Date().toISOString(), forms, attempt: creationAttempt })
2731
+ if (new Blob([raw]).size > CREATION_DRAFT_BYTES) { data.creationDraftStorageStatus = 'tooLarge'; return }
2732
+ draftStorage.setItem(key, raw)
2733
+ if (draftStorage.getItem(key) !== raw) throw new Error('Draft write did not persist')
2734
+ data.creationDraftStorageStatus = 'saved'
2735
+ }
2736
+ persistedCreationSignature = signature
2737
+ refreshCreationDrafts()
2738
+ } catch { data.creationDraftStorageStatus = 'unavailable' }
2739
+ finally { creationDraftWriting = false }
2740
+ }
2741
+ function closeCreationDraft() {
2742
+ if (creationDraftClosed) return
2743
+ persistCreationDraft()
2744
+ creationDraftClosed = true
2745
+ if (!creationDraftRoot) return
2746
+ try {
2747
+ const key = creationDraftPrefix(creationDraftRoot) + creationDraftId
2748
+ const saved = parseCreationDraft(draftStorage?.getItem(key), creationDraftRoot, creationDraftId)
2749
+ if (saved) draftStorage.setItem(key, JSON.stringify({ ...saved, ownerActive: false }))
2750
+ } catch { /* Current inputs were already saved or visibly reported as unsaved. */ }
2751
+ }
2752
+ function reopenCreationDraft() {
2753
+ creationDraftClosed = false; persistedCreationSignature = null
2754
+ persistCreationDraft()
2755
+ }
2756
+
2552
2757
  /** The project every per-project route is addressed to. */
2553
- const target = () => data.projectId ?? (data.projects[0] ? data.projects[0].projectId : null)
2758
+ const target = () => data.projectId ?? data.projects.find(project => !project.creationPending)?.projectId ?? null
2554
2759
 
2555
2760
  /** Read everything the panel shows, in one pass. */
2556
2761
  const load = async () => {
@@ -2564,7 +2769,8 @@ window.__ModuleLoader__.load({
2564
2769
  return
2565
2770
  }
2566
2771
  const payload = stateResult.payload
2567
- const active = projectId !== null ? projectId : (payload.projects && payload.projects[0] ? payload.projects[0].projectId : null)
2772
+ const pending = payload.projects?.some(project => project.projectId === projectId && project.creationPending)
2773
+ const active = pending ? null : projectId ?? payload.projects?.find(project => !project.creationPending)?.projectId ?? null
2568
2774
  const patch = {
2569
2775
  status: 'ok',
2570
2776
  error: null,
@@ -2572,6 +2778,7 @@ window.__ModuleLoader__.load({
2572
2778
  projects: payload.projects || [],
2573
2779
  recipeCatalog: payload.recipeCatalog ?? { recipes: [], errors: [] },
2574
2780
  projectsRoot: payload.projectsRoot ?? null,
2781
+ creationRequestProtocol: payload.creationRequestProtocol ?? null,
2575
2782
  selected: payload.selected ?? null,
2576
2783
  activeProjectId: active,
2577
2784
  currentRevision: payload.selected ? payload.selected.currentRevision : null,
@@ -2621,6 +2828,17 @@ window.__ModuleLoader__.load({
2621
2828
  const next = { ...comparison, afterPreviews: editorClone(afterPreviews) }
2622
2829
  patch.editorComparison = { ...next, ...editorPreviewPair(next), resolved: afterPreviews.length > 0 }
2623
2830
  }
2831
+ if (patch.projectsRoot !== creationDraftRoot) {
2832
+ // Switching stores cannot carry an old store's request identity into a new one.
2833
+ if (creationDraftRoot !== null) {
2834
+ creationAttempt = null; restoredCreationSource = null; creationDraftId = newCreationKey()
2835
+ patch.creationWork = null; patch.creationDraftMissingRecipe = null
2836
+ patch.forms = { ...data.forms, title: '', goal: '', recipe: null, recipeParameters: {} }
2837
+ }
2838
+ creationDraftRoot = typeof patch.projectsRoot === 'string' && patch.projectsRoot ? patch.projectsRoot : null
2839
+ persistedCreationSignature = null
2840
+ }
2841
+ refreshCreationDrafts()
2624
2842
  set(patch)
2625
2843
  arm()
2626
2844
  }
@@ -2637,6 +2855,51 @@ window.__ModuleLoader__.load({
2637
2855
  /** A write just happened: re-run every read, including the view-specific ones. */
2638
2856
  const reload = () => { tick += 1; void load() }
2639
2857
 
2858
+ const creationBody = () => creationRequestBody(data.forms)
2859
+
2860
+ async function submitCreation(attempt) {
2861
+ if (data.creationRequestProtocol !== CREATION_REQUEST_PROTOCOL) {
2862
+ set({ creationWork: { status: 'error', title: attempt.body.title, code: 'UI_HOST_API_STALE' } })
2863
+ setIn('notices', 'projects', { kind: 'creation', ok: false, message: t('projects.hostNeedsRestart') })
2864
+ return
2865
+ }
2866
+ setIn('busy', 'create', true)
2867
+ const attemptRoot = creationDraftRoot
2868
+ set({ creationWork: { status: 'busy', title: attempt.body.title }, previewResult: null })
2869
+ setIn('notices', 'projects', null)
2870
+ setIn('notices', 'preview', null)
2871
+ const outcome = await postJson(fetchImpl, ROUTES.projects, { ...editorClone(attempt.body), creationKey: attempt.key })
2872
+ setIn('busy', 'create', false)
2873
+ if (attemptRoot !== creationDraftRoot) { reload(); return }
2874
+ if (outcome.ok) {
2875
+ const sameInputs = creationSignature(creationBody()) === attempt.signature
2876
+ set({ ...(sameInputs ? { forms: { ...data.forms, title: '', goal: '', recipe: null, recipeParameters: {} } } : {}),
2877
+ creationWork: null, projectId: outcome.payload.project.projectId,
2878
+ ...(attempt.body.renderPreview ? { view: 'preview', compareMode: 'result' } : {}) })
2879
+ if (creationAttempt === attempt) creationAttempt = null
2880
+ if (restoredCreationSource && creationDraftRoot) {
2881
+ try {
2882
+ const sourceKey = creationDraftPrefix(creationDraftRoot) + restoredCreationSource.id
2883
+ const source = parseCreationDraft(restoredCreationSource.raw, creationDraftRoot, restoredCreationSource.id)
2884
+ if (source && creationSignature(creationRequestBody(source.forms)) === attempt.signature
2885
+ && draftStorage?.getItem(sourceKey) === restoredCreationSource.raw) draftStorage.removeItem(sourceKey)
2886
+ } catch { data.creationDraftStorageStatus = 'unavailable' }
2887
+ }
2888
+ restoredCreationSource = null
2889
+ persistedCreationSignature = null
2890
+ setIn('notices', attempt.body.renderPreview ? 'preview' : 'projects', { kind: 'creation', projectId: outcome.payload.project.projectId, ok: true, message: outcome.payload.project.creationReplayed
2891
+ ? t('projects.recovered', { id: outcome.payload.project.projectId })
2892
+ : t('projects.created', { id: outcome.payload.project.projectId }) })
2893
+ } else {
2894
+ set({ creationWork: { status: 'error', title: attempt.body.title, code: outcome.error.code } })
2895
+ setIn('notices', 'projects', { kind: 'creation', ok: false, message: creationErrorMessage(outcome.error.code),
2896
+ technicalDetails: `${outcome.error.code}: ${outcome.error.message}` })
2897
+ }
2898
+ // A failed response may follow a successful Host commit. Refreshing is
2899
+ // read-only; retries remain explicit and reuse the captured request key.
2900
+ reload()
2901
+ }
2902
+
2640
2903
  /** Run one write, report it in this view's notice, then reload. */
2641
2904
  const write = async (view, path, body, describe) => {
2642
2905
  const outcome = await postJson(fetchImpl, path, body)
@@ -2934,7 +3197,7 @@ window.__ModuleLoader__.load({
2934
3197
  } else assetWork(projectId, { error: `${outcome.error.code}: ${outcome.error.message}` })
2935
3198
  reload()
2936
3199
  },
2937
- selectRecipe: recipe => set({ forms: { ...data.forms, recipe,
3200
+ selectRecipe: recipe => set({ creationDraftMissingRecipe: null, forms: { ...data.forms, recipe,
2938
3201
  recipeParameters: recipe ? Object.fromEntries(recipe.parameters.map(parameter => [parameter.id, parameter.default])) : {},
2939
3202
  title: !data.forms.title || data.forms.title === data.forms.recipe?.title ? recipe?.title || '' : data.forms.title,
2940
3203
  } }),
@@ -3281,25 +3544,49 @@ window.__ModuleLoader__.load({
3281
3544
 
3282
3545
  createProject: async () => {
3283
3546
  if (data.busy.create) return
3547
+ if (data.creationDraftMissingRecipe) return
3284
3548
  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()
3549
+ const body = creationBody()
3550
+ const signature = creationSignature(body)
3551
+ if (!creationAttempt || creationAttempt.signature !== signature) creationAttempt = { body, signature, key: newCreationKey() }
3552
+ return submitCreation(creationAttempt)
3553
+ },
3554
+ retryCreation: () => data.busy.create || !creationAttempt ? undefined : submitCreation(creationAttempt),
3555
+ restoreCreationDraft: id => {
3556
+ if (data.busy.create || !creationDraftRoot) return
3557
+ const reading = readCreationDrafts(draftStorage, creationDraftRoot), source = reading.entries.find(entry => entry.id === id)
3558
+ if (!source?.draft) { set({ creationDraftStorageStatus: 'invalid' }); return }
3559
+ const saved = source.draft, ref = saved.forms.recipe
3560
+ const recipe = ref ? data.recipeCatalog.recipes.find(recipe => recipe.id === ref.id && recipe.version === ref.version && recipe.digest === ref.digest) : null
3561
+ creationDraftId = newCreationKey(); restoredCreationSource = { id, raw: source.raw }; persistedCreationSignature = null
3562
+ creationAttempt = saved.attempt ? editorClone(saved.attempt) : null
3563
+ set({ view: 'projects', forms: { ...data.forms, ...editorClone(saved.forms), recipe: recipe || null },
3564
+ creationDraftMissingRecipe: ref && !recipe ? ref : null,
3565
+ creationWork: creationAttempt ? { status: 'error', title: creationAttempt.body.title, code: 'UI_FETCH_FAILED' } : null,
3566
+ notices: { ...data.notices, projects: { kind: 'creation', ok: true, message: t('projects.draftRestored') } } })
3567
+ // A closed page's unchanged record can be consumed after the copy
3568
+ // persisted. Live pages retain their independent saved record.
3569
+ if (saved.ownerActive === false && data.creationDraftStorageStatus === 'saved') {
3570
+ try {
3571
+ const sourceKey = creationDraftPrefix(creationDraftRoot) + id
3572
+ if (draftStorage.getItem(sourceKey) === source.raw) draftStorage.removeItem(sourceKey)
3573
+ refreshCreationDrafts(); notify()
3574
+ } catch { set({ creationDraftStorageStatus: 'unavailable' }) }
3301
3575
  }
3302
3576
  },
3577
+ deleteCreationDraft: id => {
3578
+ if (!creationDraftRoot || id === creationDraftId) return
3579
+ const entry = readCreationDrafts(draftStorage, creationDraftRoot).entries.find(entry => entry.id === id)
3580
+ if (!entry) return
3581
+ try { draftStorage.removeItem(creationDraftPrefix(creationDraftRoot) + id); refreshCreationDrafts(); notify() }
3582
+ catch { set({ creationDraftStorageStatus: 'unavailable' }) }
3583
+ },
3584
+ clearCreationDraft: () => {
3585
+ if (data.busy.create) return
3586
+ creationAttempt = null; restoredCreationSource = null; persistedCreationSignature = null
3587
+ set({ forms: { ...data.forms, title: '', goal: '', recipe: null, recipeParameters: {} }, creationWork: null,
3588
+ creationDraftMissingRecipe: null, notices: { ...data.notices, projects: null } })
3589
+ },
3303
3590
 
3304
3591
  applyPatch: async () => {
3305
3592
  const projectId = data.activeProjectId
@@ -3365,6 +3652,7 @@ window.__ModuleLoader__.load({
3365
3652
  },
3366
3653
 
3367
3654
  renderPreview: async () => {
3655
+ setIn('notices', 'preview', null)
3368
3656
  set({ previewBusy: true, previewResult: null })
3369
3657
  const outcome = await postJson(fetchImpl, projectRoute(target(), '/preview'), { revision: data.selected?.scene?.revision })
3370
3658
  set({ previewBusy: false, ...(outcome.ok ? { compareMode: 'renders' } : {}) })
@@ -3442,10 +3730,16 @@ window.__ModuleLoader__.load({
3442
3730
  start() {
3443
3731
  if (running) return
3444
3732
  running = true
3733
+ reopenCreationDraft()
3734
+ draftLifecycle?.addEventListener?.('pagehide', closeCreationDraft)
3735
+ draftLifecycle?.addEventListener?.('pageshow', reopenCreationDraft)
3445
3736
  fileChoicesStopped = false
3446
3737
  void load()
3447
3738
  },
3448
3739
  stop() {
3740
+ closeCreationDraft()
3741
+ draftLifecycle?.removeEventListener?.('pagehide', closeCreationDraft)
3742
+ draftLifecycle?.removeEventListener?.('pageshow', reopenCreationDraft)
3449
3743
  fileChoicesStopped = true
3450
3744
  data = { ...data, fileChoiceGeneration: data.fileChoiceGeneration + 1 }
3451
3745
  running = false
@@ -3475,6 +3769,35 @@ window.__ModuleLoader__.load({
3475
3769
  // touches React, the DOM, or the network.
3476
3770
 
3477
3771
  /** 项目: the list, the create form, and the selected project's summary. */
3772
+ function CreationDraftsView({ state, actions }) {
3773
+ const messages = { saved: t('projects.draftSaved'), unavailable: t('projects.draftUnavailable'), full: t('projects.draftFull'),
3774
+ tooLarge: t('projects.draftTooLarge'), invalid: t('projects.draftInvalid') }
3775
+ const hasInputs = Boolean(state.forms.title || state.forms.goal || state.forms.recipe || state.creationWork || state.creationDraftMissingRecipe || Object.keys(state.forms.recipeParameters).length)
3776
+ return el('div', { 'data-creation-drafts': true },
3777
+ el('p', { className: 'db-muted' }, t('projects.draftHint')),
3778
+ (hasInputs || state.creationDraftStorageStatus !== 'saved') && messages[state.creationDraftStorageStatus] ? el('p', {
3779
+ className: state.creationDraftStorageStatus === 'saved' ? 'db-muted' : 'db-error',
3780
+ 'data-creation-draft-status': state.creationDraftStorageStatus,
3781
+ }, messages[state.creationDraftStorageStatus]) : null,
3782
+ state.creationDraftMissingRecipe ? el('div', { 'data-creation-draft-recipe-missing': true },
3783
+ el('p', { className: 'db-error' }, t('projects.draftMissingRecipe', state.creationDraftMissingRecipe)),
3784
+ el('details', null, el('summary', null, t('projects.draftOldParameters')),
3785
+ el('pre', { className: 'db-pre' }, JSON.stringify(state.forms.recipeParameters, null, 2)))) : null,
3786
+ hasInputs ? Button({ action: 'clear-creation-draft', disabled: state.busy.create, onClick: actions.clearCreationDraft, children: t('projects.draftClear') }) : null,
3787
+ state.creationDrafts.length ? el('div', { style: { marginTop: '8px' } },
3788
+ el('h5', null, t('projects.drafts')),
3789
+ ...state.creationDrafts.map(draft => el('div', { className: 'db-card', 'data-creation-draft': draft.id },
3790
+ el('strong', null, draft.invalid ? t('projects.damagedDraft') : draft.title),
3791
+ draft.savedAt ? el('span', { className: 'db-muted', style: { marginLeft: '8px' } }, formatTime(draft.savedAt)) : null,
3792
+ draft.uncertain ? el('p', { className: 'db-muted' }, t('projects.draftUncertain')) : null,
3793
+ el('div', { className: 'db-inline' },
3794
+ Button({ action: `restore-creation-draft:${draft.id}`, disabled: state.busy.create || draft.invalid,
3795
+ onClick: () => actions.restoreCreationDraft(draft.id), children: t('projects.draftRestore') }),
3796
+ Button({ action: `delete-creation-draft:${draft.id}`, onClick: () => actions.deleteCreationDraft(draft.id), children: t('projects.draftDelete') })),
3797
+ ))) : null,
3798
+ )
3799
+ }
3800
+
3478
3801
  function ProjectsView(ctx) {
3479
3802
  const state = ctx.state
3480
3803
  const actions = ctx.actions
@@ -3489,10 +3812,12 @@ window.__ModuleLoader__.load({
3489
3812
  Button({
3490
3813
  tone: project.projectId === state.activeProjectId ? 'primary' : undefined,
3491
3814
  action: `select-project:${project.projectId}`,
3815
+ disabled: project.creationPending,
3492
3816
  onClick: () => actions.selectProject(project.projectId),
3493
3817
  children: project.title || project.projectId,
3494
3818
  }),
3495
3819
  el('span', { className: 'db-muted db-mono' }, project.projectId),
3820
+ project.creationPending ? Badge({ tone: 'live', name: 'creation-pending', children: t('projects.incomplete') }) : null,
3496
3821
  project.unfinishedJobs > 0 ? Badge({ tone: 'live', name: 'unfinished', children: t('projects.unfinishedJobs', { count: project.unfinishedJobs }) }) : null,
3497
3822
  el('span', { className: 'db-muted' }, t('projects.revisionCount', { count: project.revisionCount })),
3498
3823
  ),
@@ -3503,6 +3828,7 @@ window.__ModuleLoader__.load({
3503
3828
 
3504
3829
  el('div', { className: 'db-card db-project-create' },
3505
3830
  el('h4', null, t('projects.create')),
3831
+ CreationDraftsView(ctx),
3506
3832
  !state.projects.length ? el('p', { className: 'db-muted' }, t('projects.startHint')) : null,
3507
3833
  RecipesView(ctx),
3508
3834
  el('div', { className: 'db-create-footer' },
@@ -3526,12 +3852,24 @@ window.__ModuleLoader__.load({
3526
3852
  Button({
3527
3853
  tone: 'primary',
3528
3854
  action: 'create-project',
3529
- disabled: state.busy.create || state.forms.title.trim().length === 0 || !recipeSelectionCurrent(state) || !recipeValuesValid(state.forms.recipe, state.forms.recipeParameters),
3855
+ disabled: state.busy.create || Boolean(state.creationDraftMissingRecipe) || state.creationRequestProtocol !== CREATION_REQUEST_PROTOCOL || state.forms.title.trim().length === 0 || !recipeSelectionCurrent(state) || !recipeValuesValid(state.forms.recipe, state.forms.recipeParameters),
3530
3856
  onClick: actions.createProject,
3531
3857
  children: state.busy.create ? (state.forms.recipe ? t('recipes.creatingPreview') : t('projects.creating')) : (state.forms.recipe ? t('recipes.createPreview') : t('projects.createButton')),
3532
3858
  }),
3533
3859
  ),
3534
3860
  state.notices.projects ? Notice(state.notices.projects, { marginTop: '8px' }) : null,
3861
+ state.status === 'ok' && state.creationRequestProtocol !== CREATION_REQUEST_PROTOCOL
3862
+ ? el('p', { className: 'db-muted', 'data-creation-host-stale': true }, t('projects.hostNeedsRestart')) : null,
3863
+ state.creationWork?.status === 'error' ? el('div', { 'data-creation-recovery': true, style: { marginTop: '8px' } },
3864
+ el('p', { className: 'db-muted' }, t('projects.originalTitle', { title: state.creationWork.title })),
3865
+ el('div', { className: 'db-inline' },
3866
+ Button({ action: 'retry-creation', disabled: state.busy.create, onClick: actions.retryCreation, children: t('projects.retryCreation') }),
3867
+ Button({ action: 'refresh-creation-projects', onClick: actions.reload, children: t('projects.refreshList') }),
3868
+ ['BLENDER_NOT_FOUND', 'RUNTIME_UNAVAILABLE'].includes(state.creationWork.code)
3869
+ ? el('a', { href: 'https://github.com/pearjelly/deep-blend/blob/main/deepblend/docs/environment-check.md', target: '_blank', rel: 'noreferrer' }, t('projects.environmentGuide')) : null),
3870
+ state.notices.projects?.technicalDetails ? el('details', { 'data-creation-diagnostics': true },
3871
+ el('summary', null, t('projects.creationDetails')), el('pre', { className: 'db-pre' }, state.notices.projects.technicalDetails)) : null,
3872
+ ) : null,
3535
3873
  ),
3536
3874
  el('details', { className: 'db-storage' }, el('summary', null, t('projects.storage')),
3537
3875
  el('span', { className: 'db-muted db-mono' }, `projectsRoot: ${state.projectsRoot || '—'}`)),
@@ -4546,6 +4884,8 @@ window.__ModuleLoader__.load({
4546
4884
  children: state.previewBusy ? t('preview.rendering') : t('preview.render'),
4547
4885
  }),
4548
4886
  Notice(state.previewResult, { border: 0, padding: '0 6px', marginBottom: 0 }),
4887
+ state.notices.preview?.kind === 'creation' && state.notices.preview.projectId === state.activeProjectId
4888
+ ? Notice(state.notices.preview, { border: 0, padding: '0 6px', marginBottom: 0 }) : null,
4549
4889
  ),
4550
4890
  el('div', { className: 'db-tabs' },
4551
4891
  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.6",
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.6"
23
23
  },
24
24
  "license": "MIT",
25
25
  "repository": {