@deepblend/dsh-blender-ui 0.3.8 → 0.4.0

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 CHANGED
@@ -333,6 +333,24 @@ window.__ModuleLoader__.load({
333
333
  "photo.cameraChanges": "相机",
334
334
  "photo.lightChanges": "灯光",
335
335
  "photo.restoreFailed": "恢复失败,已保留摄影记录:{message}",
336
+ "photo.reuseUnavailable": "无法读取完整摄影设置,请重新选择来源。",
337
+ "photo.reuseTitle": "复用已保存作品的摄影设置",
338
+ "photo.reuseHelp": "原坐标以米复用,不自动缩放或取景。替换本作品全部灯光和所选相机的可编辑设置;模型、材质、世界、动画、其他相机与交付设置保留。套用后检查构图,再明确保存。",
339
+ "photo.reuseProject": "来源作品",
340
+ "photo.reuseRevision": "来源保存版本",
341
+ "photo.reuseCamera": "来源相机",
342
+ "photo.reuseTarget": "瞄准目标作品中的对象",
343
+ "photo.reuseReading": "正在读取已保存摄影设置…",
344
+ "photo.reuseSource": "来源:{project} · {revision} · {digest} · {count} 盏灯",
345
+ "photo.reuseReplace": "替换当前未提交的摄影草稿(其他编辑保留)",
346
+ "photo.reuseStale": "目标草稿来源已变化,请重新读取摄影设置。",
347
+ "photo.reuseApplied": "已套用到草稿,尚未保存。请检查构图与灯光。",
348
+ "photo.reuseSame": "当前摄影设置已与来源一致,无需再次保存。",
349
+ "photo.reuseReload": "重新读取来源",
350
+ "photo.reuseApply": "套用到摄影草稿",
351
+ "photo.reuseUpgrade": "更新插件并重启 DSH 后即可复用摄影设置。",
352
+ "photo.reuseNote": "摄影设置从 {project} · {revision} · {digest} · 相机 {camera} 开始复用;目标对象 {target}。保存前可继续调整草稿。",
353
+ "photo.reuseTargetRequired": "请选择目标作品中的瞄准对象。",
336
354
  'editor.title': '编辑对象',
337
355
  'editor.unknownSettings': '这张预览缺少实测渲染设置,无法比较编辑差异。',
338
356
  'editor.missingBaseline': '没有同相机、同帧和同渲染设置的编辑前预览,暂时无法比较差异。',
@@ -542,6 +560,9 @@ window.__ModuleLoader__.load({
542
560
  'revisions.currentSuffix': ' (当前)',
543
561
  'revisions.missing': '没有这个 revision',
544
562
  'preview.noneForRevision': '这个 revision 还没有预览图(渲染一次预览即可)。',
563
+ 'preview.reading': '正在读取所选项目的预览…',
564
+ 'preview.readFailed': '未能读取这个项目的预览。刷新后重试;保存版本和未提交修改仍保留。',
565
+ 'preview.sourceMismatch': '预览回执与所选项目不一致。刷新后重试。',
545
566
  'preview.noPrevious': '还没有上一次渲染:再点一次「渲染预览」,或者先在某个更早的 revision 上渲一次。',
546
567
  'preview.notRenderedHere': '这个 revision 还没有由面板渲过预览(上方的「渲染预览」会生成第一张)。',
547
568
  'preview.rendering': '渲染中…',
@@ -900,6 +921,24 @@ window.__ModuleLoader__.load({
900
921
  "photo.cameraChanges": "cameras",
901
922
  "photo.lightChanges": "lighting",
902
923
  "photo.restoreFailed": "Restore failed; the photography record is retained: {message}",
924
+ "photo.reuseUnavailable": "Complete photography settings are unavailable. Select the source again.",
925
+ "photo.reuseTitle": "Reuse photography from saved work",
926
+ "photo.reuseHelp": "Reuse absolute coordinates in metres, without automatic scaling or framing. Replace all lights and the selected camera’s editable settings. Keep models, materials, world, animation, other cameras and delivery settings. Check the framing before explicitly saving.",
927
+ "photo.reuseProject": "Source work",
928
+ "photo.reuseRevision": "Saved source version",
929
+ "photo.reuseCamera": "Source camera",
930
+ "photo.reuseTarget": "Aim at an object in the destination work",
931
+ "photo.reuseReading": "Reading saved photography settings…",
932
+ "photo.reuseSource": "Source: {project} · {revision} · {digest} · {count} lights",
933
+ "photo.reuseReplace": "Replace the unsaved photography draft (keep other edits)",
934
+ "photo.reuseStale": "The destination draft source changed. Read the photography settings again.",
935
+ "photo.reuseApplied": "Applied to the draft; not saved yet. Check the framing and lighting.",
936
+ "photo.reuseSame": "The current photography matches the source; no new save is needed.",
937
+ "photo.reuseReload": "Read source again",
938
+ "photo.reuseApply": "Apply to photography draft",
939
+ "photo.reuseUpgrade": "Update the plugin and restart DSH to reuse photography settings.",
940
+ "photo.reuseNote": "Photography started from {project} · {revision} · {digest} · camera {camera}; destination aim object {target}. The draft may be adjusted before saving.",
941
+ "photo.reuseTargetRequired": "Choose an aim target in the destination work.",
903
942
  'editor.title': 'Edit object',
904
943
  'editor.unknownSettings': 'Measured render settings are missing. This preview cannot establish the edit difference.',
905
944
  'editor.missingBaseline': 'No before preview has the same camera, frame and measured render settings. The edit difference is unavailable.',
@@ -1109,6 +1148,9 @@ window.__ModuleLoader__.load({
1109
1148
  'revisions.currentSuffix': ' (current)',
1110
1149
  'revisions.missing': 'no such revision',
1111
1150
  'preview.noneForRevision': 'This revision has no preview image yet (render a preview and it will).',
1151
+ 'preview.reading': 'Reading previews for the selected project…',
1152
+ 'preview.readFailed': 'Could not read this project’s previews. Refresh to retry; saved versions and unsaved edits are kept.',
1153
+ 'preview.sourceMismatch': 'The preview response does not match the selected project. Refresh to retry.',
1112
1154
  'preview.noPrevious': 'No previous render yet: press “Render preview” again, or render once on an earlier revision.',
1113
1155
  'preview.notRenderedHere': 'This revision has no preview rendered from this panel yet (the “Render preview” button above makes the first one).',
1114
1156
  'preview.rendering': 'Rendering…',
@@ -2142,10 +2184,10 @@ window.__ModuleLoader__.load({
2142
2184
  return state.editorDrafts?.[editorKey(state.activeProjectId, state.editorEntityId)] || null
2143
2185
  }
2144
2186
 
2145
- function createPhotographyDraft(scene, projectId, previous = {}) {
2187
+ function createPhotographyDraft(scene, projectId, previous = {}, projectsRoot = null) {
2146
2188
  const cameras = (scene.nodes.cameras || []).map(item => editorClone(item.definition)).filter(Boolean)
2147
2189
  const lights = (scene.nodes.lights || []).map(item => editorClone(item.definition)).filter(Boolean)
2148
- return { projectId, baseRevision: scene.revision, cameras, lights, original: editorClone({ cameras, lights }),
2190
+ return { projectId, projectsRoot, baseRevision: scene.revision, cameras, lights, original: editorClone({ cameras, lights }),
2149
2191
  entities: editorClone(scene.nodes.entities), tracks: editorClone(scene.nodes.animationTracks || []),
2150
2192
  cameraId: cameras.some(item => item.id === previous.cameraId) ? previous.cameraId : scene.project.activeCamera || cameras[0]?.id || '',
2151
2193
  lightId: lights.some(item => item.id === previous.lightId) ? previous.lightId : lights[0]?.id || '',
@@ -2155,6 +2197,8 @@ window.__ModuleLoader__.load({
2155
2197
  const photographyDraftFor = state => state.photographyDrafts?.[state.activeProjectId] || null
2156
2198
  const photographyBusy = work => Boolean(work?.saving || work?.previewing || work?.restoring)
2157
2199
  const photographyAim = camera => camera?.targetEntityId !== undefined ? 'entity' : camera?.targetPoint !== undefined ? 'point' : 'free'
2200
+ const photographySameLights = (a, b) => creationSignature(Object.fromEntries(a.map(item => [item.id, item])))
2201
+ === creationSignature(Object.fromEntries(b.map(item => [item.id, item])))
2158
2202
  const photographyAimLocked = (draft, camera) => EDITOR_AXES.some(axis => editorTrackLocked(draft, 'camera', camera.id, `rotationEuler.${axis}`))
2159
2203
  function photographyErrors(draft) {
2160
2204
  const errors = [], bad = key => errors.push(key), number = (value, min, inclusive = true) => Number.isFinite(value) && (inclusive ? value >= min : value > min)
@@ -2175,14 +2219,14 @@ window.__ModuleLoader__.load({
2175
2219
  if (item.targetPoint !== undefined && !vector(item.targetPoint)) bad(`${item.id}.targetPoint`)
2176
2220
  if (item.targetEntityId !== undefined && item.targetPoint !== undefined) bad(`${item.id}.target`)
2177
2221
  const targetChanged = !editorEqual([item.targetEntityId, item.targetPoint], [original?.targetEntityId, original?.targetPoint])
2178
- if (targetChanged && (photographyAimLocked(draft, item) || photographyAim(item) === 'free')) bad(`${item.id}.target`)
2222
+ if (targetChanged && photographyAimLocked(draft, item)) bad(`${item.id}.target`)
2179
2223
  } else {
2180
2224
  if (!number(editorNumberDefault(item.energy, 100), 0)) bad(`${item.id}.energy`)
2181
2225
  if (!Array.isArray(item.color ?? [1, 1, 1]) || ![3, 4].includes((item.color ?? [1, 1, 1]).length)
2182
2226
  || !(item.color ?? [1, 1, 1]).every(value => number(value, 0) && value <= 1)) bad(`${item.id}.color`)
2183
2227
  if (item.type !== 'sun' && !number(editorNumberDefault(item.size, item.type === 'area' ? 1 : .25), 0, false)) bad(`${item.id}.size`)
2184
- if (!original && item.type !== 'area') bad(`${item.id}.type`)
2185
- if (original && item.type !== original.type) bad(`${item.id}.type`)
2228
+ if (!draft.reusedLights && ((!original && item.type !== 'area') || (original && item.type !== original.type))) bad(`${item.id}.type`)
2229
+ if (!['area', 'point', 'spot', 'sun'].includes(item.type)) bad(`${item.id}.type`)
2186
2230
  }
2187
2231
  }
2188
2232
  if (!draft.cameras.some(camera => camera.id === draft.cameraId)) bad('camera')
@@ -2194,11 +2238,18 @@ window.__ModuleLoader__.load({
2194
2238
  const errors = photographyErrors(draft)
2195
2239
  if (errors.length) throw new Error(t('photo.invalid', { fields: errors.join(', ') }))
2196
2240
  const operations = []
2241
+ const replaceLights = draft.reusedLights && !photographySameLights(draft.lights, draft.original.lights)
2197
2242
  for (const [kind, items, fields] of [['camera', draft.cameras, ['lens', 'targetEntityId', 'targetPoint']], ['light', draft.lights, ['energy', 'color', 'size']]]) {
2243
+ if (kind === 'light' && replaceLights) {
2244
+ for (const light of draft.original.lights) operations.push({ op: 'light.remove', lightId: light.id })
2245
+ for (const light of items) operations.push({ op: 'light.add', light: editorClone(light) })
2246
+ continue
2247
+ }
2198
2248
  for (const item of items) {
2199
2249
  const original = draft.original[`${kind}s`].find(old => old.id === item.id)
2200
2250
  if (!original) { operations.push({ op: 'light.add', light: editorClone(item) }); continue }
2201
2251
  const changes = {}
2252
+ if (kind === 'camera' && photographyAim(item) === 'free' && photographyAim(original) !== 'free') changes.clearTarget = true
2202
2253
  for (const key of fields) if (item[key] !== undefined && !editorEqual(item[key], original[key])) changes[key] = editorClone(item[key])
2203
2254
  const transform = {}
2204
2255
  for (const key of ['location', 'rotationEuler']) if (!editorEqual(item.transform?.[key], original.transform?.[key])) transform[key] = editorClone(item.transform[key])
@@ -2206,10 +2257,40 @@ window.__ModuleLoader__.load({
2206
2257
  if (Object.keys(changes).length) operations.push({ op: `${kind}.update`, [`${kind}Id`]: item.id, ...changes })
2207
2258
  }
2208
2259
  }
2209
- return { projectId: draft.projectId, baseRevision: draft.baseRevision, actor: 'ui', saveCheckpoint: true, renderPreview: false, operations }
2260
+ const note = draft.reuseSource ? t('photo.reuseNote', draft.reuseSource) : null
2261
+ return { projectId: draft.projectId, baseRevision: draft.baseRevision, actor: 'ui', saveCheckpoint: true, renderPreview: false, operations, ...(note ? { note } : {}) }
2210
2262
  }
2211
2263
  function photographyDirty(draft) {
2212
- return Boolean(draft && !editorEqual({ cameras: draft.cameras, lights: draft.lights }, draft.original))
2264
+ return Boolean(draft && (creationSignature(draft.cameras) !== creationSignature(draft.original.cameras)
2265
+ || !photographySameLights(draft.lights, draft.original.lights)))
2266
+ }
2267
+ function reusePhotography(draft, source, cameraId, targetEntityId) {
2268
+ const camera = source?.nodes?.cameras?.find(item => item.id === cameraId)?.definition
2269
+ const target = draft?.cameras?.find(item => item.id === draft.cameraId)
2270
+ if (!camera || !target || !Array.isArray(source.nodes.lights)) throw new Error(t('photo.reuseUnavailable'))
2271
+ const next = editorClone(draft), selected = next.cameras.find(item => item.id === next.cameraId)
2272
+ selected.lens = editorNumberDefault(camera.lens, 50)
2273
+ selected.transform ||= {}
2274
+ selected.transform.location = editorClone(camera.transform?.location || [0, 0, 0])
2275
+ delete selected.targetEntityId; delete selected.targetPoint
2276
+ if (photographyAim(camera) === 'entity') {
2277
+ if (!next.entities.some(item => item.id === targetEntityId)) throw new Error(t('photo.reuseTargetRequired'))
2278
+ selected.targetEntityId = targetEntityId
2279
+ } else if (photographyAim(camera) === 'point') selected.targetPoint = editorClone(camera.targetPoint)
2280
+ if (photographyAim(camera) === 'free') selected.transform.rotationEuler = editorClone(camera.transform?.rotationEuler || [0, 0, 0])
2281
+ else {
2282
+ const original = next.original.cameras.find(item => item.id === selected.id)
2283
+ if (original?.transform?.rotationEuler) selected.transform.rotationEuler = editorClone(original.transform.rotationEuler)
2284
+ else delete selected.transform.rotationEuler
2285
+ }
2286
+ next.lights = source.nodes.lights.map(item => editorClone(item.definition))
2287
+ if (next.lights.some(item => !item)) throw new Error(t('photo.reuseUnavailable'))
2288
+ next.lightId = next.lights.some(item => item.id === next.lightId) ? next.lightId : next.lights[0]?.id || ''
2289
+ next.reusedLights = true
2290
+ next.reuseSource = { project: source.project.id, revision: source.revision, digest: source.digest, camera: cameraId, target: targetEntityId || '—' }
2291
+ const errors = photographyErrors(next)
2292
+ if (errors.length) throw new Error(t('photo.invalid', { fields: errors.join(', ') }))
2293
+ return next
2213
2294
  }
2214
2295
  function inspectionReceiptValid(receipt, request, digest) {
2215
2296
  const artifacts = receipt?.artifacts, view = request.views[0]
@@ -2522,7 +2603,7 @@ window.__ModuleLoader__.load({
2522
2603
  if (typeof payload?.hostApiVersion === 'number' && payload.hostApiVersion < EXPECTED_HOST_API) {
2523
2604
  return { status: 'stale', payload: null, error: staleHostError(expectedRoute, { payload, status: response.status }) }
2524
2605
  }
2525
- if (payload && payload.ok) return { status: 'ok', payload, error: null }
2606
+ if (payload && payload.ok && response.ok !== false) return { status: 'ok', payload, error: null }
2526
2607
  return {
2527
2608
  status: 'error',
2528
2609
  payload: null,
@@ -2602,6 +2683,8 @@ window.__ModuleLoader__.load({
2602
2683
  unfinishedJobs: [],
2603
2684
  previews: null,
2604
2685
  artifactBase: '',
2686
+ previewContext: null,
2687
+ previewReadStatus: 'idle',
2605
2688
  view: 'projects',
2606
2689
  projectId: null,
2607
2690
  activeProjectId: null,
@@ -2614,6 +2697,7 @@ window.__ModuleLoader__.load({
2614
2697
  imageDownloads: {},
2615
2698
  creationWork: null,
2616
2699
  creationRequestProtocol: null,
2700
+ cameraTargetResetSupported: false,
2617
2701
  creationDrafts: [],
2618
2702
  creationDraftStorageStatus: null,
2619
2703
  creationDraftMissingRecipe: null,
@@ -2635,6 +2719,7 @@ window.__ModuleLoader__.load({
2635
2719
  photographyDrafts: {},
2636
2720
  photographyWork: {},
2637
2721
  photographyEdits: {},
2722
+ photographyReuse: {},
2638
2723
  editorEntityId: null,
2639
2724
  editorDrafts: {},
2640
2725
  editorLastEdits: {},
@@ -2703,6 +2788,8 @@ window.__ModuleLoader__.load({
2703
2788
  const draftLifecycle = settings.creationDraftLifecycle ?? globalThis.window
2704
2789
  let fileChoiceSignature = null, fileChoicesStopped = false
2705
2790
  const photographyControllers = new Map()
2791
+ const photographySourceControllers = new Map()
2792
+ let photographySourceSequence = 0
2706
2793
  const photographyWork = (projectId, changes) => set({ photographyWork: { ...data.photographyWork, [projectId]: { ...data.photographyWork[projectId], ...changes } } })
2707
2794
  const inspectionControllers = new Map()
2708
2795
  const imageDownloadControllers = new Map()
@@ -2754,6 +2841,21 @@ window.__ModuleLoader__.load({
2754
2841
  }
2755
2842
  /** Change the snapshot and tell everyone. @param {object} patch */
2756
2843
  const set = (patch) => {
2844
+ // Automatic edit images are source snapshots too. They cannot override
2845
+ // fresh rows from another store, even when its project/revision IDs match.
2846
+ if (Object.hasOwn(patch, 'projectsRoot') && patch.projectsRoot !== data.projectsRoot) {
2847
+ for (const controller of photographySourceControllers.values()) controller.abort()
2848
+ photographySourceControllers.clear()
2849
+ patch = { ...patch, editorComparison: null, photographyReuse: {} }
2850
+ }
2851
+ // Retire the old source before selection/creation navigation can expose
2852
+ // it with a new project. A load supplies the complete new source atomically.
2853
+ if (!Object.hasOwn(patch, 'previews') && ['projectId', 'projectsRoot', 'activeProjectId']
2854
+ .some(key => Object.hasOwn(patch, key) && patch[key] !== data[key])) {
2855
+ const requested = Object.hasOwn(patch, 'projectId') ? patch.projectId : data.projectId
2856
+ const active = Object.hasOwn(patch, 'activeProjectId') ? patch.activeProjectId : data.activeProjectId
2857
+ patch = { ...patch, previews: null, artifactBase: '', previewContext: null, previewReadStatus: requested || active ? 'loading' : 'idle' }
2858
+ }
2757
2859
  if (['projectsRoot', 'activeProjectId', 'currentRevision', 'view', 'compareMode', 'compareLeft', 'compareRight']
2758
2860
  .some(key => Object.hasOwn(patch, key) && patch[key] !== data[key])) {
2759
2861
  diffSequence += 1
@@ -2841,11 +2943,13 @@ window.__ModuleLoader__.load({
2841
2943
  recipeCatalog: payload.recipeCatalog ?? { recipes: [], errors: [] },
2842
2944
  projectsRoot: payload.projectsRoot ?? null,
2843
2945
  creationRequestProtocol: payload.creationRequestProtocol ?? null,
2946
+ cameraTargetResetSupported: payload.cameraTargetResetSupported === true,
2844
2947
  selected: payload.selected ?? null,
2845
2948
  activeProjectId: active,
2846
2949
  currentRevision: payload.selected ? payload.selected.currentRevision : null,
2847
2950
  jobs: payload.selected ? payload.selected.jobs || [] : [],
2848
2951
  unfinishedJobs: payload.selected ? payload.selected.unfinishedJobs || [] : [],
2952
+ previews: null, artifactBase: '', previewContext: null, previewReadStatus: active === null ? 'idle' : 'loading',
2849
2953
  }
2850
2954
  live = patch.unfinishedJobs.length > 0
2851
2955
 
@@ -2861,11 +2965,22 @@ window.__ModuleLoader__.load({
2861
2965
  }
2862
2966
  const previewResult = await readRoute(fetchImpl, `${projectRoute(active, '/previews')}?t=${tick}`, 'project.previews')
2863
2967
  if (previewResult.status === 'ok') {
2864
- patch.previews = previewResult.payload.previews ?? null
2865
- patch.artifactBase = previewResult.payload.artifactBase ?? ''
2866
- } else if (previewResult.status === 'stale') {
2867
- patch.status = 'stale'
2968
+ const result = previewResult.payload
2969
+ if (result.previews?.projectId === active && Array.isArray(result.previews.revisions)
2970
+ && (result.projectId === undefined || result.projectId === active)
2971
+ && result.artifactBase === `/deepblend/artifacts/${encodeURIComponent(active)}/`) {
2972
+ patch.previews = result.previews
2973
+ patch.artifactBase = result.artifactBase
2974
+ patch.previewContext = { projectId: active, projectsRoot: patch.projectsRoot }
2975
+ patch.previewReadStatus = 'ok'
2976
+ } else {
2977
+ patch.previewReadStatus = 'error'
2978
+ patch.error = { code: 'UI_PREVIEW_MISMATCH', message: t('preview.sourceMismatch') }
2979
+ }
2980
+ } else {
2981
+ patch.previewReadStatus = 'error'
2868
2982
  patch.error = previewResult.error
2983
+ if (previewResult.status === 'stale') patch.status = 'stale'
2869
2984
  }
2870
2985
  } else {
2871
2986
  patch.previews = null
@@ -2876,7 +2991,7 @@ window.__ModuleLoader__.load({
2876
2991
  if (patch.selected?.scene && active) {
2877
2992
  const photography = data.photographyDrafts[active]
2878
2993
  if ((!photography || !photographyDirty(photography)) && !data.photographyWork[active]?.saving) {
2879
- patch.photographyDrafts = { ...data.photographyDrafts, [active]: createPhotographyDraft(patch.selected.scene, active, photography) }
2994
+ patch.photographyDrafts = { ...data.photographyDrafts, [active]: createPhotographyDraft(patch.selected.scene, active, photography, patch.projectsRoot) }
2880
2995
  }
2881
2996
  const draft = data.briefDrafts[active], work = data.briefWork[active]
2882
2997
  if (!draft || !briefDirty(draft) && !work?.uploading && !work?.saving) {
@@ -3003,6 +3118,46 @@ window.__ModuleLoader__.load({
3003
3118
 
3004
3119
  const acceptsFileChoice = (source, field) => !fileChoicesStopped && typeof source === 'string'
3005
3120
  && FILE_FIELDS.includes(field) && source === fileChoiceKey(data, field) && fileChoiceAllowed(data, field)
3121
+ const loadPhotographySource = async requestedRevision => {
3122
+ const draft = photographyDraftFor(data), projectId = data.activeProjectId, selection = data.photographyReuse[projectId]
3123
+ if (!data.cameraTargetResetSupported || !draft || !selection?.sourceProjectId || photographyBusy(data.photographyWork[projectId])
3124
+ || draft.baseRevision !== data.selected?.scene?.revision || draft.projectsRoot !== data.projectsRoot) return
3125
+ photographySourceControllers.get(projectId)?.abort()
3126
+ const controller = new AbortController(), sequence = ++photographySourceSequence
3127
+ photographySourceControllers.set(projectId, controller)
3128
+ const root = data.projectsRoot, baseRevision = draft.baseRevision, sourceProjectId = selection.sourceProjectId
3129
+ const alive = () => running && !controller.signal.aborted && photographySourceControllers.get(projectId) === controller && data.projectsRoot === root
3130
+ const update = changes => set({ photographyReuse: { ...data.photographyReuse, [projectId]: { ...data.photographyReuse[projectId], ...changes } } })
3131
+ update({ source: null, loading: true, error: null, applied: false, unchanged: false, replaceDraft: false, projectsRoot: root, baseRevision, sequence })
3132
+ const deadline = setTimeout(() => controller.abort(), 30000)
3133
+ const read = (path, route) => readRoute((url, options) => fetchImpl(url, { ...options, signal: controller.signal }), path, route)
3134
+ try {
3135
+ const listed = await read(projectRoute(sourceProjectId, '/revisions'), 'project.revisions')
3136
+ if (!alive()) return
3137
+ const list = listed.payload
3138
+ if (listed.status !== 'ok' || list?.projectId !== sourceProjectId || !Array.isArray(list.revisions)
3139
+ || list.revisions.some(item => !/^r[0-9]{4,}$/.test(item.revision))) throw new Error(listed.error?.message || t('photo.reuseUnavailable'))
3140
+ const revision = requestedRevision || list.currentRevision, saved = list.revisions.find(item => item.revision === revision)
3141
+ if (!saved) throw new Error(t('photo.reuseUnavailable'))
3142
+ update({ revisions: editorClone(list.revisions), revision })
3143
+ const result = await read(`${projectRoute(sourceProjectId, '/scene')}?revision=${encodeURIComponent(revision)}`, 'project.scene')
3144
+ if (!alive()) return
3145
+ const payload = result.payload, scene = payload?.scene
3146
+ if (result.status !== 'ok' || payload?.projectId !== sourceProjectId || payload.revision !== revision
3147
+ || scene?.revision !== revision || scene?.project?.id !== sourceProjectId || scene.digest !== payload.digest
3148
+ || !/^[a-f0-9]{64}$/.test(payload.digest || '') || (saved.digest && saved.digest !== payload.digest)
3149
+ || !Array.isArray(scene.nodes?.cameras) || !Array.isArray(scene.nodes?.lights)) throw new Error(result.error?.message || t('photo.reuseUnavailable'))
3150
+ update({ source: editorClone(scene), cameraId: scene.nodes.cameras.find(item => item.id === scene.project.activeCamera)?.id || scene.nodes.cameras[0]?.id || '', targetEntityId: '', error: null })
3151
+ } catch (error) {
3152
+ if (alive()) update({ source: null, error: error.message || String(error) })
3153
+ } finally {
3154
+ clearTimeout(deadline)
3155
+ if (photographySourceControllers.get(projectId) === controller) {
3156
+ photographySourceControllers.delete(projectId)
3157
+ if (running && data.projectsRoot === root) update({ loading: false, ...(controller.signal.aborted ? { source: null, error: t('photo.reuseUnavailable') } : {}) })
3158
+ }
3159
+ }
3160
+ }
3006
3161
  const actions = {
3007
3162
  acceptsFileChoice,
3008
3163
  setAppearance: value => {
@@ -3406,7 +3561,43 @@ window.__ModuleLoader__.load({
3406
3561
  resetPhotography: () => {
3407
3562
  const projectId = data.activeProjectId, scene = data.selected?.scene
3408
3563
  if (!scene || photographyBusy(data.photographyWork[projectId])) return
3409
- set({ photographyDrafts: { ...data.photographyDrafts, [projectId]: createPhotographyDraft(scene, projectId, photographyDraftFor(data)) } })
3564
+ set({ photographyDrafts: { ...data.photographyDrafts, [projectId]: createPhotographyDraft(scene, projectId, photographyDraftFor(data), data.projectsRoot) },
3565
+ photographyReuse: { ...data.photographyReuse, ...(data.photographyReuse[projectId] ? { [projectId]: { ...data.photographyReuse[projectId], applied: false, unchanged: false, replaceDraft: false } } : {}) } })
3566
+ },
3567
+ setPhotographySource: (field, value) => {
3568
+ const projectId = data.activeProjectId, selection = data.photographyReuse[projectId] || {}
3569
+ if (!data.cameraTargetResetSupported || !photographyDraftFor(data) || photographyBusy(data.photographyWork[projectId])) return
3570
+ if (field === 'project') {
3571
+ if (value && !data.projects.some(item => item.projectId === value && !item.creationPending)) return
3572
+ photographySourceControllers.get(projectId)?.abort(); photographySourceControllers.delete(projectId)
3573
+ set({ photographyReuse: { ...data.photographyReuse, [projectId]: { sourceProjectId: value, source: null, revisions: [], revision: '', cameraId: '', targetEntityId: '', replaceDraft: false } } })
3574
+ if (value) return loadPhotographySource()
3575
+ } else if (field === 'revision') {
3576
+ if (!selection.revisions?.some(item => item.revision === value)) return
3577
+ return loadPhotographySource(value)
3578
+ } else if (field === 'camera' && selection.source?.nodes.cameras.some(item => item.id === value)) {
3579
+ set({ photographyReuse: { ...data.photographyReuse, [projectId]: { ...selection, cameraId: value, targetEntityId: '', applied: false, unchanged: false } } })
3580
+ } else if (field === 'target' && (!value || photographyDraftFor(data).entities.some(item => item.id === value))) {
3581
+ set({ photographyReuse: { ...data.photographyReuse, [projectId]: { ...selection, targetEntityId: value, applied: false, unchanged: false } } })
3582
+ } else if (field === 'replace' && typeof value === 'boolean') {
3583
+ set({ photographyReuse: { ...data.photographyReuse, [projectId]: { ...selection, replaceDraft: value } } })
3584
+ }
3585
+ },
3586
+ reloadPhotographySource: () => loadPhotographySource(data.photographyReuse[data.activeProjectId]?.revision),
3587
+ reusePhotography: () => {
3588
+ const projectId = data.activeProjectId, selection = data.photographyReuse[projectId], draft = photographyDraftFor(data)
3589
+ if (!data.cameraTargetResetSupported || !draft || !selection?.source || selection.loading || photographyBusy(data.photographyWork[projectId])
3590
+ || selection.projectsRoot !== data.projectsRoot || draft.projectsRoot !== data.projectsRoot
3591
+ || selection.baseRevision !== draft.baseRevision || draft.baseRevision !== data.selected?.scene?.revision
3592
+ || (photographyDirty(draft) && !selection.replaceDraft)) return
3593
+ try {
3594
+ const next = reusePhotography(draft, selection.source, selection.cameraId, selection.targetEntityId)
3595
+ set({ photographyDrafts: { ...data.photographyDrafts, [projectId]: next }, photographyReuse: {
3596
+ ...data.photographyReuse, [projectId]: { ...selection, replaceDraft: false, error: null, applied: photographyDirty(next), unchanged: !photographyDirty(next) },
3597
+ } })
3598
+ } catch (error) {
3599
+ set({ photographyReuse: { ...data.photographyReuse, [projectId]: { ...selection, applied: false, error: error.message || String(error) } } })
3600
+ }
3410
3601
  },
3411
3602
  updatePhotography: (kind, path, value) => {
3412
3603
  const current = photographyDraftFor(data)
@@ -3423,7 +3614,7 @@ window.__ModuleLoader__.load({
3423
3614
  } else if (kind === 'camera' && path.length === 1 && path[0] === 'aim') {
3424
3615
  if (!['free', 'entity', 'point'].includes(value) || photographyAimLocked(draft, item)) return
3425
3616
  const original = draft.original.cameras.find(camera => camera.id === item.id)
3426
- if (value === 'free' && photographyAim(original) !== 'free') return
3617
+ if (value === 'free' && photographyAim(original) !== 'free' && !data.cameraTargetResetSupported) return
3427
3618
  delete item.targetEntityId; delete item.targetPoint
3428
3619
  if (value === 'entity') item.targetEntityId = draft.entities.find(entity => entity.id === data.editorEntityId)?.id || data.selected?.scene?.project?.reviewSubjectId || draft.entities[0]?.id || ''
3429
3620
  if (value === 'point') item.targetPoint = [0, 0, 0]
@@ -3439,7 +3630,8 @@ window.__ModuleLoader__.load({
3439
3630
  else if (kind === 'light' && path[0] === 'color' && path.length === 2 && [0, 1, 2].includes(path[1])) { item.color ||= [1, 1, 1]; item.color[path[1]] = value }
3440
3631
  else return
3441
3632
  }
3442
- set({ photographyDrafts: { ...data.photographyDrafts, [current.projectId]: draft } })
3633
+ set({ photographyDrafts: { ...data.photographyDrafts, [current.projectId]: draft },
3634
+ photographyReuse: { ...data.photographyReuse, ...(data.photographyReuse[current.projectId] ? { [current.projectId]: { ...data.photographyReuse[current.projectId], unchanged: false } } : {}) } })
3443
3635
  },
3444
3636
  addPhotographyLight: () => {
3445
3637
  const current = photographyDraftFor(data)
@@ -3463,7 +3655,9 @@ window.__ModuleLoader__.load({
3463
3655
  setIn('busy', 'editor', false)
3464
3656
  if (outcome.ok) {
3465
3657
  const saved = outcome.payload.revision, drafts = { ...data.photographyDrafts, [projectId]: { ...editorClone(draft), baseRevision: outcome.payload.revision.revision, original: editorClone({ cameras: draft.cameras, lights: draft.lights }) } }
3466
- set({ photographyDrafts: drafts, photographyEdits: { ...data.photographyEdits, [projectId]: {
3658
+ set({ photographyDrafts: drafts,
3659
+ photographyReuse: { ...data.photographyReuse, ...(data.photographyReuse[projectId] ? { [projectId]: { ...data.photographyReuse[projectId], baseRevision: saved.revision, applied: false, unchanged: false, replaceDraft: false } } : {}) },
3660
+ photographyEdits: { ...data.photographyEdits, [projectId]: {
3467
3661
  before: draft.baseRevision, after: saved.revision, digest: saved.digest, cameraId: draft.cameraId, frame: draft.frame, samples: draft.samples,
3468
3662
  changes: [...new Set(patch.operations.map(operation => operation.op.split('.')[0]))] } } })
3469
3663
  photographyWork(projectId, { saving: false, error: null, artifact: null })
@@ -3852,6 +4046,7 @@ window.__ModuleLoader__.load({
3852
4046
  void load()
3853
4047
  },
3854
4048
  stop() {
4049
+ loadSequence += 1
3855
4050
  diffSequence += 1
3856
4051
  closeCreationDraft()
3857
4052
  draftLifecycle?.removeEventListener?.('pagehide', closeCreationDraft)
@@ -3862,6 +4057,8 @@ window.__ModuleLoader__.load({
3862
4057
  if (timer !== null) { clearInterval(timer); timer = null }
3863
4058
  for (const controller of photographyControllers.values()) controller.abort()
3864
4059
  photographyControllers.clear()
4060
+ for (const controller of photographySourceControllers.values()) controller.abort()
4061
+ photographySourceControllers.clear()
3865
4062
  for (const controller of inspectionControllers.values()) controller.abort()
3866
4063
  inspectionControllers.clear()
3867
4064
  for (const controller of imageDownloadControllers.values()) controller.abort('stopped')
@@ -4496,18 +4693,38 @@ window.__ModuleLoader__.load({
4496
4693
  { factor, locked: locked || editorTrackLocked(draft, kind, kind === 'camera' ? camera.id : light.id, `${field}.${axis}`) })))
4497
4694
  const aim = photographyAim(camera), originalCamera = draft?.original.cameras.find(item => item.id === camera?.id)
4498
4695
  const aimLocked = camera && photographyAimLocked(draft, camera)
4696
+ const reuse = state.photographyReuse?.[projectId] || {}, sourceCamera = reuse.source?.nodes.cameras.find(item => item.id === reuse.cameraId)?.definition
4697
+ const reuseStale = reuse.source && (reuse.projectsRoot !== state.projectsRoot || reuse.baseRevision !== draft?.baseRevision || draft?.projectsRoot !== state.projectsRoot)
4499
4698
  return el('section', { className: 'db-card', 'data-photography-editor': projectId },
4500
4699
  el('h4', null, t('photo.title')), el('p', { className: 'db-muted' }, t('photo.help')),
4501
4700
  draft ? el('div', null,
4502
4701
  el('p', { className: 'db-muted', 'data-photography-base': draft.baseRevision }, t('photo.base', { revision: draft.baseRevision })),
4503
4702
  conflict ? el('p', { className: 'db-error', 'data-photography-conflict': true }, t('photo.conflict', { before: draft.baseRevision, current: state.selected?.scene?.revision })) : null,
4703
+ state.cameraTargetResetSupported ? el('div', { 'data-photography-reuse': true },
4704
+ el('h4', null, t('photo.reuseTitle')), el('p', { className: 'db-muted' }, t('photo.reuseHelp')),
4705
+ choose(t('photo.reuseProject'), 'source-project', reuse.sourceProjectId || '', [['', '—'], ...state.projects.filter(item => !item.creationPending).map(item => [item.projectId, item.title || item.projectId])], value => actions.setPhotographySource('project', value)),
4706
+ choose(t('photo.reuseRevision'), 'source-revision', reuse.revision || '', [['', '—'], ...(reuse.revisions || []).map(item => [item.revision, item.revision])], value => actions.setPhotographySource('revision', value), reuse.loading || !reuse.revisions?.length),
4707
+ reuse.loading ? el('p', { role: 'status' }, t('photo.reuseReading')) : null,
4708
+ reuse.source ? el('div', { 'data-photography-source': `${reuse.sourceProjectId}:${reuse.revision}` },
4709
+ el('p', { className: 'db-muted' }, t('photo.reuseSource', { project: reuse.sourceProjectId, revision: reuse.revision, digest: shortDigest(reuse.source.digest), count: reuse.source.nodes.lights.length })),
4710
+ choose(t('photo.reuseCamera'), 'source-camera', reuse.cameraId, reuse.source.nodes.cameras.map(item => [item.id, item.id]), value => actions.setPhotographySource('camera', value)),
4711
+ photographyAim(sourceCamera) === 'entity' ? choose(t('photo.reuseTarget'), 'source-target', reuse.targetEntityId || '', [['', '—'], ...draft.entities.map(item => [item.id, item.id])], value => actions.setPhotographySource('target', value)) : null,
4712
+ photographyDirty(draft) ? el('label', { className: 'db-row' }, el('input', { type: 'checkbox', 'data-field': 'photo-source-replace', checked: Boolean(reuse.replaceDraft), disabled: disabled || reuse.loading,
4713
+ onChange: event => actions.setPhotographySource('replace', Boolean(event.target.checked)) }), t('photo.reuseReplace')) : null,
4714
+ reuseStale ? el('p', { className: 'db-error' }, t('photo.reuseStale')) : null,
4715
+ reuse.applied ? el('p', { role: 'status', 'data-photography-reused': true }, t('photo.reuseApplied')) : null,
4716
+ reuse.unchanged ? el('p', { role: 'status', 'data-photography-reuse-unchanged': true }, t('photo.reuseSame')) : null) : null,
4717
+ reuse.error ? el('p', { className: 'db-error', role: 'status', 'data-photography-source-error': true }, reuse.error) : null,
4718
+ el('div', { className: 'db-inline' },
4719
+ Button({ action: 'photo-source-reload', disabled: disabled || !reuse.sourceProjectId || reuse.loading, onClick: actions.reloadPhotographySource, children: t('photo.reuseReload') }),
4720
+ Button({ action: 'photo-reuse', disabled: disabled || reuse.loading || !reuse.source || reuseStale || (photographyDirty(draft) && !reuse.replaceDraft) || (photographyAim(sourceCamera) === 'entity' && !reuse.targetEntityId), onClick: actions.reusePhotography, children: t('photo.reuseApply') }))) : el('p', { className: 'db-muted' }, t('photo.reuseUpgrade')),
4504
4721
  el('div', { className: 'db-grid' },
4505
4722
  el('div', null,
4506
4723
  choose(t('photo.camera'), 'camera', draft.cameraId, draft.cameras.map(item => [item.id, item.id]), id => actions.selectPhotography('camera', id)),
4507
4724
  camera ? el('div', null,
4508
4725
  numeric(t('photo.lens'), 'camera-lens', editorNumberDefault(camera.lens, 50), update('camera', ['lens']), { min: .001 }),
4509
4726
  vector('camera', 'location', camera.transform?.location, t('photo.position')),
4510
- choose(t('photo.aim'), 'camera-aim', aim, [...(photographyAim(originalCamera) === 'free' ? [['free', t('photo.free')]] : []), ['entity', t('photo.entity')], ['point', t('photo.point')]], update('camera', ['aim']), aimLocked),
4727
+ choose(t('photo.aim'), 'camera-aim', aim, [...(state.cameraTargetResetSupported || photographyAim(originalCamera) === 'free' ? [['free', t('photo.free')]] : []), ['entity', t('photo.entity')], ['point', t('photo.point')]], update('camera', ['aim']), aimLocked),
4511
4728
  aim === 'entity' ? choose(t('photo.target'), 'camera-target', camera.targetEntityId, draft.entities.map(item => [item.id, item.id]), update('camera', ['targetEntityId']), aimLocked) : null,
4512
4729
  aim === 'point' ? vector('camera', 'targetPoint', camera.targetPoint, t('photo.targetPoint'), 1, aimLocked) : null,
4513
4730
  vector('camera', 'rotationEuler', camera.transform?.rotationEuler, t('photo.rotation'), 180 / Math.PI, aim !== 'free'),
@@ -4877,7 +5094,13 @@ window.__ModuleLoader__.load({
4877
5094
  const state = ctx.state
4878
5095
  const actions = ctx.actions
4879
5096
  const previews = state.previews
4880
- if (previews === null) return el('div', { 'data-view': 'preview', className: 'db-muted' }, t('common.noProject'))
5097
+ if (previews === null) {
5098
+ if (state.previewReadStatus === 'error') return el('div', { 'data-view': 'preview', 'data-preview-state': 'error', className: 'db-card' },
5099
+ el('div', { className: 'db-muted' }, t('preview.readFailed')),
5100
+ Button({ action: 'preview-reload', onClick: actions.reload, children: t('common.refresh') }))
5101
+ if (state.previewReadStatus === 'loading') return el('div', { 'data-view': 'preview', 'data-preview-state': 'loading', className: 'db-muted' }, t('preview.reading'))
5102
+ return el('div', { 'data-view': 'preview', className: 'db-muted' }, t('common.noProject'))
5103
+ }
4881
5104
  const revisions = previews.revisions
4882
5105
  const { left, right } = previewRevisionPair(state)
4883
5106
  const entryOf = id => revisions.find(entry => entry.revision === id) || null
@@ -5915,7 +6138,7 @@ window.__ModuleLoader__.load({
5915
6138
  inspection: { form: inspectionForm, valid: inspectionFormValid },
5916
6139
  assetLibrary: { selection: assetBundleSelection, createDraft: createAssetDraft, buildPatch: buildAssetPatch, error: assetDraftError, key: assetKey },
5917
6140
  referenceBrief: { createDraft: createBriefDraft, buildPatch: buildBriefPatch, dirty: briefDirty, valid: briefValid },
5918
- photographyEditor: { createDraft: createPhotographyDraft, buildPatch: buildPhotographyPatch, errors: photographyErrors, dirty: photographyDirty, draftFor: photographyDraftFor },
6141
+ photographyEditor: { createDraft: createPhotographyDraft, buildPatch: buildPhotographyPatch, errors: photographyErrors, dirty: photographyDirty, draftFor: photographyDraftFor, reuse: reusePhotography },
5919
6142
  sceneEditor: { createDraft: createEditorDraft, buildPatch: buildEditorPatch, errors: editorErrors, dirty: editorDirty, previewPair: editorPreviewPair, draftFor: editorDraftFor },
5920
6143
  renderView,
5921
6144
  mountStandalone,
package/lib/index.js CHANGED
@@ -633,6 +633,7 @@ export function createHandlers(ctx) {
633
633
  projects: listed.projects.map(record => buildProjectView(record)),
634
634
  projectsRoot: listed.projectsRoot,
635
635
  creationRequestProtocol: studio().creationRequestProtocol ?? null,
636
+ cameraTargetResetSupported: studio().cameraTargetResetSupported === true,
636
637
  selected: projectId === null || creationPending
637
638
  ? null
638
639
  : await buildProjectState(studio(), projectId, query.revision ?? undefined, approvalThreshold()),
@@ -669,6 +670,7 @@ export function createHandlers(ctx) {
669
670
  'project.scene': async ({ params, query }) => {
670
671
  const detail = await studio().getScene(params.projectId, { revision: query.revision, full: true })
671
672
  return {
673
+ projectId: params.projectId,
672
674
  revision: detail.revision,
673
675
  digest: detail.digest,
674
676
  scene: buildSceneTree(detail.spec, {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deepblend/dsh-blender-ui",
3
- "version": "0.3.8",
3
+ "version": "0.4.0",
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.8"
22
+ "@deepblend/dsh-blender-contracts": "0.4.0"
23
23
  },
24
24
  "license": "MIT",
25
25
  "repository": {