@ninkch/dsh-infinite-canvas 0.19.1 → 0.19.2

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/client.js CHANGED
@@ -632,6 +632,7 @@ window.__ModuleLoader__.load({
632
632
  'dirBone': '骨骼',
633
633
  'dirBoneRot': '骨骼旋转',
634
634
  'dirPoseLib': '姿态库',
635
+ 'dirCustomAction': '自定义动作',
635
636
  'dirPoseAnimAdd': '记录姿态帧',
636
637
  'dirPoseAnimClear': '清空姿态动画',
637
638
  'dirPoseAnimAdded': '姿态帧已记录',
@@ -682,6 +683,46 @@ window.__ModuleLoader__.load({
682
683
  'dirCurFill': '当前构图',
683
684
  'dirAddShot': '添加镜头',
684
685
  'dirPoseKey': '姿态关键帧',
686
+ 'dirIk': 'IK 反向动力学',
687
+ 'dirIkAdd': '添加 IK',
688
+ 'dirIkChain': '骨骼链',
689
+ 'dirIkTarget': '目标',
690
+ 'dirIkTargetPoint': '固定点',
691
+ 'dirIkTargetKeys': '关键帧',
692
+ 'dirIkTakeCurrent': '取当前位置',
693
+ 'dirIkKey': '打目标帧',
694
+ 'dirIkPole': '极向量',
695
+ 'dirIkEmpty': '暂无 IK',
696
+ 'dirConstraint': '约束',
697
+ 'dirConstraintAdd': '添加约束',
698
+ 'dirConstraintType': '类型',
699
+ 'dirConstraintTarget': '目标点',
700
+ 'dirConstraintFrom': '来源骨骼',
701
+ 'dirConstraintMix': '混合',
702
+ 'dirConstraintLimit': '角度限制(度)',
703
+ 'dirConstraintEmpty': '暂无约束',
704
+ 'dirClips': '动作层',
705
+ 'dirClipAdd': '添加动作层',
706
+ 'dirClipBake': '烘焙动作',
707
+ 'dirClipBvh': 'BVH 导入',
708
+ 'dirClipBvhPh': '粘贴 BVH 文本(可选:先在外部减采样)',
709
+ 'dirClipBlend': '混合方式',
710
+ 'dirClipInfluence': '影响',
711
+ 'dirClipLoop': '循环',
712
+ 'dirClipEmpty': '暂无动作层',
713
+ 'dirClipBaked': '已从姿态关键帧烘焙动作层',
714
+ 'dirClipBvhImported': 'BVH 已导入为动作层',
715
+ 'dirClipBvhFail': 'BVH 解析/重定向失败',
716
+ 'dirClipKeys': '关键帧数',
717
+ 'dirClipName': '名称',
718
+ 'dirClipUp': '上移',
719
+ 'dirClipDown': '下移',
720
+ 'assetMigrated': '已迁移 {n} 个旧资产到数据目录',
721
+ 'assetMigration': '资产迁移',
722
+ 'assetMigrateNow': '立即迁移',
723
+ 'assetMigrateDone': '已迁移 {n} · 缺失 {m}',
724
+ 'assetMigrateNoRoot': '未设置数据根目录,暂不迁移',
725
+ 'dirPoseKey': '姿态关键帧',
685
726
  'dirPoseKeyCount': '姿态帧',
686
727
  'dirPoseKeyClear2': '清除姿态帧',
687
728
  'dirPoseKeyAdded': '姿态关键帧已记录',
@@ -701,11 +742,11 @@ window.__ModuleLoader__.load({
701
742
  'dirPathNone': '无',
702
743
  'dirPathAddPoint': '加点',
703
744
  'dirPathPointAdded': '路径点已添加',
704
- 'dirPathDraw': '画路径',
745
+ 'dirPathDraw': '画线',
705
746
  'dirPathDrawing': '正在画(点地面)',
706
747
  'dirPathDrawHint': '点地面添加路径点,再点按钮结束',
707
- 'dirPathClear': '清空路径',
708
- 'dirPathPointDel': '删除路径点',
748
+ 'dirPathClear': '清空',
749
+ 'dirPathPointDel': '删点',
709
750
  'dirSpecTitle': 'AI 预演规格',
710
751
  'dirSpecShow': '规格 JSON',
711
752
  'dirSpecCopy': '生成规格',
@@ -721,7 +762,7 @@ window.__ModuleLoader__.load({
721
762
  'dirPathPointNone': '请先点选一个路径点',
722
763
  'dirPathCleared': '路径已清除,物体已复位',
723
764
  'dirPathCount': '路径点',
724
- 'dirLocomotion': '移动驱动(自动步频)',
765
+ 'dirLocomotion': '移动驱动',
725
766
  'dirLocoSource': '动作来源',
726
767
  'dirLocoNone': '不驱动',
727
768
  'dirLocoClip': '自动步频(跟随动作)',
@@ -732,7 +773,7 @@ window.__ModuleLoader__.load({
732
773
  'dirLocoCycle': '循环周期(s)',
733
774
  'dirLocoReadout': '当前',
734
775
  'dirLocoStepsPerSec': '步/秒',
735
- 'dirPathHint': '物体沿路径运动',
776
+ 'dirPathHint': '沿路径运动',
736
777
  'dirPathLine': '折线',
737
778
  'dirPathCurve': '曲线(跟随朝向)',
738
779
  'dirLookAhead': '前瞻距离',
@@ -1474,13 +1515,13 @@ window.__ModuleLoader__.load({
1474
1515
  'dirCamAdded': 'Camera added (current view)',
1475
1516
  'dirCamDeleted': 'Camera deleted',
1476
1517
  'dirPathNone': 'None',
1477
- 'dirPathAddPoint': 'Add point',
1518
+ 'dirPathAddPoint': 'Add',
1478
1519
  'dirPathPointAdded': 'Path point added',
1479
- 'dirPathDraw': 'Draw path',
1520
+ 'dirPathDraw': 'Draw',
1480
1521
  'dirPathDrawing': 'Drawing (click ground)',
1481
1522
  'dirPathDrawHint': 'Click ground to add points, click again to finish',
1482
- 'dirPathClear': 'Clear path',
1483
- 'dirPathPointDel': 'Delete point',
1523
+ 'dirPathClear': 'Clear',
1524
+ 'dirPathPointDel': 'Del pt',
1484
1525
  'dirSpecTitle': 'AI stage spec',
1485
1526
  'dirSpecShow': 'Spec JSON',
1486
1527
  'dirSpecCopy': 'Generate spec',
@@ -1507,7 +1548,7 @@ window.__ModuleLoader__.load({
1507
1548
  'dirLocoCycle': 'Cycle (s)',
1508
1549
  'dirLocoReadout': 'Current',
1509
1550
  'dirLocoStepsPerSec': 'steps/s',
1510
- 'dirPathHint': 'Object follows path',
1551
+ 'dirPathHint': 'Follows path',
1511
1552
  'dirShot': 'Shot',
1512
1553
  'dirSkeleton': 'Skeleton',
1513
1554
  'dirMaterial': 'Material',
@@ -1522,6 +1563,7 @@ window.__ModuleLoader__.load({
1522
1563
  'dirBone': 'Bone',
1523
1564
  'dirBoneRot': 'Bone rotation',
1524
1565
  'dirPoseLib': 'Pose library',
1566
+ 'dirCustomAction': 'Custom action',
1525
1567
  'dirPoseAnimAdd': 'Record pose key',
1526
1568
  'dirPoseAnimClear': 'Clear pose anim',
1527
1569
  'dirPoseAnimAdded': 'Pose key recorded',
@@ -1575,6 +1617,46 @@ window.__ModuleLoader__.load({
1575
1617
  'dirShotApplied': 'Shot applied',
1576
1618
  'dirCurFill': 'Framing',
1577
1619
  'dirAddShot': 'Add shot',
1620
+ 'dirPoseKey': 'Pose keyframe',
1621
+ 'dirIk': 'Inverse kinematics',
1622
+ 'dirIkAdd': 'Add IK',
1623
+ 'dirIkChain': 'Bone chain',
1624
+ 'dirIkTarget': 'Target',
1625
+ 'dirIkTargetPoint': 'Fixed point',
1626
+ 'dirIkTargetKeys': 'Keyframes',
1627
+ 'dirIkTakeCurrent': 'Use current',
1628
+ 'dirIkKey': 'Key target',
1629
+ 'dirIkPole': 'Pole',
1630
+ 'dirIkEmpty': 'No IK',
1631
+ 'dirConstraint': 'Constraints',
1632
+ 'dirConstraintAdd': 'Add constraint',
1633
+ 'dirConstraintType': 'Type',
1634
+ 'dirConstraintTarget': 'Target point',
1635
+ 'dirConstraintFrom': 'Source bone',
1636
+ 'dirConstraintMix': 'Mix',
1637
+ 'dirConstraintLimit': 'Angle limits (deg)',
1638
+ 'dirConstraintEmpty': 'No constraints',
1639
+ 'dirClips': 'Action layers',
1640
+ 'dirClipAdd': 'Add layer',
1641
+ 'dirClipBake': 'Bake',
1642
+ 'dirClipBvh': 'Import BVH',
1643
+ 'dirClipBvhPh': 'Paste BVH text (decimate first if large)',
1644
+ 'dirClipBlend': 'Blend',
1645
+ 'dirClipInfluence': 'Influence',
1646
+ 'dirClipLoop': 'Loop',
1647
+ 'dirClipEmpty': 'No layers',
1648
+ 'dirClipBaked': 'Action layer baked from pose keys',
1649
+ 'dirClipBvhImported': 'BVH imported as action layer',
1650
+ 'dirClipBvhFail': 'BVH parse/retarget failed',
1651
+ 'dirClipKeys': 'Keys',
1652
+ 'dirClipName': 'Name',
1653
+ 'dirClipUp': 'Up',
1654
+ 'dirClipDown': 'Down',
1655
+ 'assetMigrated': 'Migrated {n} legacy assets to data root',
1656
+ 'assetMigration': 'Asset migration',
1657
+ 'assetMigrateNow': 'Migrate now',
1658
+ 'assetMigrateDone': 'Migrated {n} · missing {m}',
1659
+ 'assetMigrateNoRoot': 'No data root configured; migration deferred',
1578
1660
  'dirPoseKey': 'Pose key',
1579
1661
  'dirPoseKeyCount': 'Pose keys',
1580
1662
  'dirPoseKeyClear2': 'Clear pose keys',
@@ -2418,8 +2500,8 @@ body[data-ds-dark-theme] .dshic-studio-modal{border-color:var(--dsw-alias-border
2418
2500
  .dshic-dir-treeitem{height:24px;padding:0 8px;border:none;border-radius:6px;background:transparent;color:var(--dsw-alias-label-primary);font:inherit;font-size:11.5px;text-align:left;cursor:pointer;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
2419
2501
  .dshic-dir-treeitem:hover{background:var(--dsw-alias-interactive-bg-hover);}
2420
2502
  .dshic-dir-treeitem.is-on{background:color-mix(in srgb,var(--dsw-alias-brand-primary) 14%,transparent);color:var(--dsw-alias-brand-primary);}
2421
- .dshic-dir-keyrow{display:flex;gap:6px;margin-top:6px;}
2422
- .dshic-dir-mini{height:22px;padding:0 8px;border:1px solid var(--dsw-alias-border-l2);border-radius:6px;background:transparent;color:var(--dsw-alias-label-secondary);font:inherit;font-size:11px;cursor:pointer;}
2503
+ .dshic-dir-keyrow{display:flex;flex-wrap:wrap;gap:6px;margin-top:6px;align-items:center;}
2504
+ .dshic-dir-mini{height:22px;min-width:0;padding:0 8px;border:1px solid var(--dsw-alias-border-l2);border-radius:6px;background:transparent;color:var(--dsw-alias-label-secondary);font:inherit;font-size:11px;cursor:pointer;white-space:nowrap;flex:none;display:inline-flex;align-items:center;justify-content:center;line-height:1;}
2423
2505
  .dshic-dir-mini:hover{border-color:var(--dsw-alias-brand-primary);color:var(--dsw-alias-brand-primary);}
2424
2506
  .dshic-dir-mini.is-danger:hover{border-color:var(--dsw-alias-state-error-primary);color:var(--dsw-alias-state-error-primary);}
2425
2507
  .dshic-dir-props{flex:1;padding:0 12px 12px;display:flex;flex-direction:column;gap:6px;min-height:0;}
@@ -2431,7 +2513,9 @@ body[data-ds-dark-theme] .dshic-studio-modal{border-color:var(--dsw-alias-border
2431
2513
  .dshic-dir-row{display:flex;align-items:center;gap:6px;font-size:11.5px;color:var(--dsw-alias-label-secondary);}
2432
2514
  .dshic-dir-row>span{flex:none;width:44px;}
2433
2515
  .dshic-dir-prompt{width:100%;box-sizing:border-box;height:96px;resize:none;padding:7px 9px;border:1px solid var(--dsw-alias-border-l1);border-radius:8px;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-secondary);font:inherit;font-size:11px;line-height:1.5;}
2434
- .dshic-dir-dock{flex:none;border-top:1px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-1);overflow-x:auto;overflow-y:hidden;}
2516
+ /* 时间轴:底部固定 1/3 高度,内部滚动,不再挤压 3D 视口 */
2517
+ .dshic-dir-dock{flex:none;height:min(33vh,320px);max-height:320px;min-height:150px;border-top:1px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-1);overflow-x:auto;overflow-y:auto;overscroll-behavior:contain;}
2518
+ .dshic-dir-dockbar{position:sticky;top:0;z-index:6;}
2435
2519
  .dshic-dir-dockbar{height:34px;display:flex;align-items:center;gap:8px;padding:0 12px;position:sticky;left:0;min-width:max-content;background:var(--dsw-alias-bg-layer-1);z-index:4;}
2436
2520
  .dshic-dir-docktitle{font-size:11.5px;font-weight:600;color:var(--dsw-alias-label-secondary);}
2437
2521
  .dshic-dir-time{font-size:11px;color:var(--dsw-alias-label-secondary);font-variant-numeric:tabular-nums;white-space:nowrap;}
@@ -2493,7 +2577,7 @@ body[data-ds-dark-theme] .dshic-studio-modal{border-color:var(--dsw-alias-border
2493
2577
  .dshic-dir-posebtn{height:26px;border:1px solid var(--dsw-alias-border-l2);border-radius:6px;background:transparent;color:var(--dsw-alias-label-secondary);font:inherit;font-size:11px;cursor:pointer;}
2494
2578
  .dshic-dir-posebtn:hover{border-color:var(--dsw-alias-brand-primary);color:var(--dsw-alias-brand-primary);}
2495
2579
  .dshic-dir-posebtn.is-on{background:color-mix(in srgb,var(--dsw-alias-brand-primary) 16%,transparent);color:var(--dsw-alias-brand-primary);border-color:var(--dsw-alias-brand-primary);}
2496
- .dshic-dir-btnrow{display:flex;gap:6px;margin:6px 0 2px;}
2580
+ .dshic-dir-btnrow{display:flex;flex-wrap:wrap;gap:6px;margin:6px 0 2px;align-items:center;}
2497
2581
  .dshic-dir-btnrow .dshic-mini{flex:1;justify-content:center;display:inline-flex;align-items:center;}
2498
2582
  .dshic-dir-envrow{display:flex;align-items:center;gap:6px;font-size:11.5px;color:var(--dsw-alias-label-secondary);margin-bottom:5px;}
2499
2583
  .dshic-dir-envrow>span{flex:none;width:52px;}
@@ -2502,7 +2586,7 @@ body[data-ds-dark-theme] .dshic-studio-modal{border-color:var(--dsw-alias-border
2502
2586
  .dshic-dir-envrow input[type="color"]{height:24px;padding:0;border:1px solid var(--dshic-line2);border-radius:6px;background:var(--dshic-field-bg);width:100%;}
2503
2587
  .dshic-dir-empty{font-size:11.5px;color:var(--dsw-alias-label-secondary);line-height:1.7;padding:8px 0;}
2504
2588
  .dshic-dir-msg.is-err{background:color-mix(in srgb,var(--dsw-alias-state-error-primary) 88%,#000);}
2505
- .dshic-dir-row{display:flex;align-items:center;gap:7px;flex:none;}
2589
+ .dshic-dir-row{display:flex;align-items:center;gap:7px;flex:none;flex-wrap:nowrap;white-space:nowrap;}
2506
2590
  .dshic-dir-lbl{width:52px;flex:none;font-size:11.5px;color:var(--dsw-alias-label-secondary);}
2507
2591
  .dshic-dir-num{flex:1;min-width:0;height:24px;border-radius:6px;padding:0 6px;font:inherit;font-size:12px;
2508
2592
  border:1px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);}
@@ -4645,7 +4729,11 @@ body[data-ds-dark-theme] .dshic-pick-item.is-on{background:color-mix(in srgb,var
4645
4729
  // 服务端不可用 → 回退 IDB;服务端可用但无记录 → 迁移期只查一次(miss 后永不再查)
4646
4730
  if (serverOk && idbMiss.has(id)) return null;
4647
4731
  const local = await idbGetRaw(id).catch(() => null);
4648
- if (local) return local;
4732
+ if (local) {
4733
+ // 旧 IDB-only 记录:命中即后台触发一次性迁移(幂等),下次就走磁盘/SQLite
4734
+ if (serverOk && !local.rel && !local.sha) migrateLegacyAssets('idb-hit').catch(() => { /* 静默 */ });
4735
+ return local;
4736
+ }
4649
4737
  if (serverOk) idbMiss.add(id);
4650
4738
  return null;
4651
4739
  }
@@ -4660,6 +4748,7 @@ body[data-ds-dark-theme] .dshic-pick-item.is-on{background:color-mix(in srgb,var
4660
4748
  } catch { return null; }
4661
4749
  }
4662
4750
  async function idbDelete(id) {
4751
+ if (idbDeleteImpl) return idbDeleteImpl(id);
4663
4752
  assetNameCache.delete(id);
4664
4753
  // 释放该资产的 objectURL:否则 Blob(视频可达几十 MB)会被 URL 钉在内存里直到刷新
4665
4754
  if (assetUrlCache.has(id)) {
@@ -4740,7 +4829,9 @@ body[data-ds-dark-theme] .dshic-pick-item.is-on{background:color-mix(in srgb,var
4740
4829
  } catch { /* 忽略 */ }
4741
4830
  return out.sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0));
4742
4831
  }
4832
+ let idbAllImpl = null; // 测试注入(迁移逻辑单测)
4743
4833
  async function idbAll() {
4834
+ if (idbAllImpl) return idbAllImpl();
4744
4835
  const db = await openDb();
4745
4836
  return new Promise((resolve, reject) => {
4746
4837
  const rq = db.transaction(DB_STORE, 'readonly').objectStore(DB_STORE).getAll();
@@ -4900,36 +4991,66 @@ body[data-ds-dark-theme] .dshic-pick-item.is-on{background:color-mix(in srgb,var
4900
4991
  projectId: rec.projectId, projectName: (proj && proj.name) || rec.projectId, asset: assetIndexRecord(rec),
4901
4992
  }).catch(() => { /* 索引失败不影响资产本身 */ });
4902
4993
  }
4903
- /** 数据根目录就绪后:把浏览器 IndexedDB 里的存量 blob 资产搬到数据目录的项目文件夹
4904
- * (projects/<项目>/<类型>/<文件>),搬完从 IndexedDB 移除——**IndexedDB 只减不增,最终清零**。 */
4905
- async function autoMigrateAssetsToRoot() {
4906
- if (!S.dataRoot) return;
4907
- try {
4908
- const all = await idbAll();
4909
- const pending = all.filter((r) => r && r.blob && !r.rel && !r.sha);
4910
- if (!pending.length) return;
4911
- let done = 0;
4912
- for (const rec of pending) {
4913
- try {
4914
- const mime = rec.blob.type || rec.mime || (rec.kind === 'image' ? 'image/png' : 'video/mp4');
4915
- const rel = `projects/${rec.projectId}/${rec.kind}/${assetFileBase(rec)}.${extFromMime(mime)}`;
4916
- const r = await fetch('/' + NS + '/api/asset?rel=' + encodeURIComponent(rel), { method: 'POST', body: rec.blob });
4917
- if (!r.ok) continue;
4918
- const meta = { ...rec, rel, size: rec.size || rec.blob.size };
4919
- delete meta.blob;
4920
- meta.stored = 'disk';
4921
- const proj = S.projects.find((p) => p.id === rec.projectId);
4922
- await hostRpc('assetCreate', { asset: assetIndexRecord(meta), projectName: (proj && proj.name) || '' });
4923
- try { await idbDelete(rec.id); } catch { /* 本地清理失败不阻塞迁移 */ }
4924
- done++;
4925
- } catch { /* 单个失败继续下一个 */ }
4926
- }
4927
- if (done) {
4928
- toast(T('dataRootMigrated').replace('{n}', String(done)));
4929
- update((s) => { s.assetsTick++; });
4994
+ /**
4995
+ * 一次性迁移:把浏览器 IndexedDB 里的存量资产搬进数据目录 + SQLite,然后**清空 IDB**。
4996
+ * 设计要求:IndexedDB 只减不增、最终清零;本函数是唯一迁移入口,幂等且并发安全。
4997
+ * - 有 blob:落盘 projects/<pid>/<kind>/<file> → assetCreate(**保留原 id**)→ idbDelete
4998
+ * - 无 blob:记入 missing(WebView 重建后真丢失,只能重生成,不再假装能显示)
4999
+ * - 无 dataRoot:只记 pending,等数据根就绪后自动补跑
5000
+ * 触发点:启动、数据根变化、打开资产面板前、设置页手动。
5001
+ */
5002
+ let migrateBusy = null;
5003
+ async function migrateLegacyAssets(reason) {
5004
+ if (!S.dataRoot) {
5005
+ update((st) => { st.assetMigration = Object.assign({}, st.assetMigration || {}, { pendingDataRoot: true, reason: reason || '' }); });
5006
+ return { ok: false, skipped: 'no-data-root' };
5007
+ }
5008
+ if (migrateBusy) return migrateBusy;
5009
+ migrateBusy = (async () => {
5010
+ const stat = { reason: reason || 'auto', at: Date.now(), total: 0, migrated: 0, failed: 0, missing: 0, missingIds: [] };
5011
+ try {
5012
+ const all = await idbAll();
5013
+ stat.total = all.length;
5014
+ for (const rec of all) {
5015
+ if (!rec || !rec.id) continue;
5016
+ if (rec.rel || rec.sha) continue; // 已落盘/已有 CAS 记录
5017
+ if (!rec.blob) {
5018
+ stat.missing++;
5019
+ if (stat.missingIds.length < 200) stat.missingIds.push(rec.id);
5020
+ continue;
5021
+ }
5022
+ try {
5023
+ const mime = rec.blob.type || rec.mime || (rec.kind === 'image' ? 'image/png' : rec.kind === 'video' ? 'video/mp4' : 'application/octet-stream');
5024
+ const pid = String(rec.projectId || '');
5025
+ if (!pid) { stat.failed++; continue; }
5026
+ const rel = `projects/${pid}/${rec.kind || 'image'}/${assetFileBase(rec)}.${extFromMime(mime)}`;
5027
+ const r = await fetch('/' + NS + '/api/asset?rel=' + encodeURIComponent(rel), { method: 'POST', body: rec.blob });
5028
+ if (!r.ok) { stat.failed++; continue; }
5029
+ const meta = { ...rec, rel, size: rec.size || rec.blob.size };
5030
+ delete meta.blob;
5031
+ meta.stored = 'disk';
5032
+ const proj = S.projects.find((p) => p.id === pid);
5033
+ await hostRpc('assetCreate', { asset: assetIndexRecord(meta), projectName: (proj && proj.name) || '' });
5034
+ try { await idbDelete(rec.id); } catch { /* 清理失败不阻塞 */ }
5035
+ if (assetMem.has(rec.id)) assetMem.set(rec.id, meta);
5036
+ stat.migrated++;
5037
+ } catch { stat.failed++; }
5038
+ }
5039
+ update((st) => { st.assetMigration = stat; if (stat.migrated) st.assetsTick++; });
5040
+ if (stat.migrated) toast((T('assetMigrated') || '已迁移 {n} 个旧资产到数据目录').replace('{n}', String(stat.migrated)));
5041
+ return { ok: true, ...stat };
5042
+ } catch (e) {
5043
+ update((st) => { st.assetMigration = Object.assign({}, stat, { error: String((e && e.message) || e) }); });
5044
+ return { ok: false, error: String((e && e.message) || e) };
5045
+ } finally {
5046
+ migrateBusy = null;
4930
5047
  }
4931
- } catch { /* 静默:迁移失败不影响主流程,下次设置数据根目录时再试 */ }
5048
+ })();
5049
+ return migrateBusy;
4932
5050
  }
5051
+ /** 兼容旧调用名(外部/测试可能引用)。 */
5052
+ function autoMigrateAssetsToRoot() { return migrateLegacyAssets('legacy-alias'); }
5053
+
4933
5054
  function useAssetUrl(id) {
4934
5055
  const [url, setUrl] = useState(null);
4935
5056
  useEffect(() => {
@@ -5161,7 +5282,18 @@ async function ensureThumbForAsset(assetId) {
5161
5282
  if (thumbInflight.has(assetId)) return thumbInflight.get(assetId);
5162
5283
  const task = (async () => {
5163
5284
  const rec = await idbGet(assetId).catch(() => null);
5164
- if (!rec || !rec.rel || rec.kind === 'video') return null; // 视频走首帧 poster
5285
+ if (!rec || rec.kind === 'video') return null; // 视频走首帧 poster
5286
+ // 旧 IDB-only 资产(无 rel):先用 blob 生成内存缩略图保证可见,并触发一次性迁移
5287
+ if (!rec.rel) {
5288
+ if (thumbFailed.has(assetId)) return null;
5289
+ const legacyBlob = await assetBlob(assetId).catch(() => null);
5290
+ const legacyThumb = await makeThumbBlob(legacyBlob);
5291
+ if (!legacyThumb) { thumbFailed.add(assetId); return null; }
5292
+ const objUrl = URL.createObjectURL(legacyThumb);
5293
+ thumbCache.set(assetId, objUrl);
5294
+ migrateLegacyAssets('thumb-fallback').catch(() => { /* 后台自愈,失败不阻塞显示 */ });
5295
+ return objUrl;
5296
+ }
5165
5297
  const url = thumbUrlForRel(rec.rel);
5166
5298
  if (await probeHeadUrl(url)) { thumbCache.set(assetId, url); return url; }
5167
5299
  if (thumbFailed.has(assetId)) return null;
@@ -7520,12 +7652,15 @@ function queueThumb(assetId) {
7520
7652
  }
7521
7653
  /** 从 Host 拉取数据根目录配置(Host 旧版无此 RPC → 标记不支持)。 */
7522
7654
  async function refreshDataRoot() {
7655
+ const prevRoot = S.dataRoot;
7523
7656
  try {
7524
7657
  const r = await hostRpc('dataRootGet');
7525
7658
  update((s) => { s.dataRoot = r.dataRoot || null; s.dataRootExists = !!r.exists; s.dataRootSupported = true; s.store = r.store || (r.sql ? 'sqlite' : (r.dataRoot ? 'json' : null)); });
7526
7659
  } catch (e) {
7527
7660
  update((s) => { s.dataRoot = null; s.store = null; s.dataRootSupported = !/未知操作/.test(String((e && e.message) || e)); });
7528
7661
  }
7662
+ // 数据根从「空」变为「有」:自动补跑一次性迁移(此前因未配置被跳过)
7663
+ if (S.dataRoot && !prevRoot) migrateLegacyAssets('root-ready').catch(() => { /* 静默 */ });
7529
7664
  }
7530
7665
  // ---------- 对话 ↔ 画布同步:轮询 Host 项目索引 + 跳转请求 ----------
7531
7666
  let hostSyncStopped = false;
@@ -13407,6 +13542,26 @@ function queueThumb(assetId) {
13407
13542
  MODEL_INDEX = (j && j.models) || [];
13408
13543
  return MODEL_INDEX;
13409
13544
  }
13545
+ // 动作库(内置成熟动作 clip:three.js AnimationClip → 本地 tracks)
13546
+ const ACTION_DIR = 'assets/director/clips/';
13547
+ let ACTION_CLIPS = null;
13548
+ async function loadActionClips() {
13549
+ if (ACTION_CLIPS) return ACTION_CLIPS;
13550
+ try {
13551
+ const E2 = directorEngine();
13552
+ const r = await fetch(pluginAssetUrl(ACTION_DIR + 'manifest.json'));
13553
+ if (!r.ok) { ACTION_CLIPS = []; return ACTION_CLIPS; }
13554
+ const j = await r.json();
13555
+ const list = [];
13556
+ for (const it of (j && j.clips) || []) {
13557
+ const rr = await fetch(pluginAssetUrl(ACTION_DIR + it.file));
13558
+ if (!rr.ok) continue;
13559
+ list.push(await rr.json());
13560
+ }
13561
+ ACTION_CLIPS = E2 ? E2.decodeActionClips(list) : [];
13562
+ } catch { ACTION_CLIPS = []; }
13563
+ return ACTION_CLIPS;
13564
+ }
13410
13565
  /** 按需加载一个模型并注册进引擎;返回 asset。 */
13411
13566
  async function loadModel(id) {
13412
13567
  const E2 = directorEngine();
@@ -13415,7 +13570,8 @@ function queueThumb(assetId) {
13415
13570
  if (cached) return cached;
13416
13571
  const r = await fetch(pluginAssetUrl(MODEL_DIR + id + '.json'));
13417
13572
  if (!r.ok) throw new Error('模型加载失败 ' + id);
13418
- const asset = E2.decodeModelPack(await r.json());
13573
+ const clips = await loadActionClips().catch(() => []);
13574
+ const asset = E2.decodeModelPack(await r.json(), clips);
13419
13575
  MODEL_CACHE.add(id);
13420
13576
  return E2.registerGltfAsset(id, asset);
13421
13577
  }
@@ -13430,6 +13586,7 @@ function queueThumb(assetId) {
13430
13586
  idle: { zh: '待机', en: 'Idle' }, run: { zh: '奔跑', en: 'Run' },
13431
13587
  walk: { zh: '行走', en: 'Walk' }, sneak_pose: { zh: '潜行', en: 'Sneak' },
13432
13588
  jump: { zh: '跳跃', en: 'Jump' }, dance: { zh: '舞蹈', en: 'Dance' },
13589
+ wave: { zh: '挥手', en: 'Wave' },
13433
13590
  death: { zh: '倒地', en: 'Death' }, hit: { zh: '受击', en: 'Hit' },
13434
13591
  };
13435
13592
  const clipLabel = (name) => {
@@ -13558,6 +13715,9 @@ const DIRECTOR_KEY_HELP = [
13558
13715
  durRef.current = Math.max(0.5,
13559
13716
  ...(scene && Array.isArray(scene.shots) && scene.shots.length ? scene.shots.map((s2) => Number(s2.end) || 0) : [Number(scene && scene.shot && scene.shot.duration) || 6]));
13560
13717
  const dragRef = useRef(null);
13718
+ const [bvhOpen, setBvhOpen] = useState(false);
13719
+ const [customActionOpen, setCustomActionOpen] = useState(false);
13720
+ const [bvhText, setBvhText] = useState('');
13561
13721
  const fileRef = useRef(null);
13562
13722
  const recRef = useRef(null);
13563
13723
  const undoRef = useRef([]);
@@ -14185,7 +14345,7 @@ const DIRECTOR_KEY_HELP = [
14185
14345
  patch((n) => {
14186
14346
  const id = 'obj_' + Date.now().toString(36);
14187
14347
  const base = { id, kind, name: p.label || p.id, pos: E.v3(Math.round(pos.x * 4) / 4, 0, Math.round(pos.z * 4) / 4), rotY: 0, visible: true };
14188
- if (kind === 'mannequin') Object.assign(base, { kind: 'model', assetId: 'xbot', typeId: p.typeId, h: p.h, scale: E.v3(p.w || 1, 1, p.d || 1), clip: (E.gltfAssetOf('xbot') && E.gltfAssetOf('xbot').animations[0] ? E.gltfAssetOf('xbot').animations[0].name : ''), animTime: 0, color: p.color || '' });
14348
+ if (kind === 'mannequin') Object.assign(base, { kind: 'model', assetId: 'xbot', typeId: p.typeId, h: p.h, scale: E.v3(p.w || 1, 1, p.d || 1), clip: 'idle', animTime: 0, color: p.color || '' });
14189
14349
  else if (kind === 'model') Object.assign(base, { assetId: p.id, h: p.height || 1.7, clip: '', animTime: 0, color: p.color || '' });
14190
14350
  else if (kind === 'actor') Object.assign(base, { preset: p.id, h: 1, pose: 'stand' });
14191
14351
  else if (kind === 'building') Object.assign(base, { preset: p.id, s: 1 });
@@ -15256,6 +15416,193 @@ const DIRECTOR_KEY_HELP = [
15256
15416
  value: String(((((scene.env || {}).fog) || {}).far !== undefined ? ((scene.env || {}).fog).far : 40)),
15257
15417
  onChange: (e2) => patch((n) => { n.env = Object.assign({}, n.env || {}, { fog: Object.assign({ enabled: true, color: '#aab4c0', near: 6, density: 1 }, (n.env || {}).fog || {}, { far: Math.max(1, Number(e2.target.value) || 40) }) }); }) })),
15258
15418
  ] : []),
15419
+ // ── IK / 约束 / 动作层(AI 能力对应的编辑面板)──
15420
+ ...(sel && sel.kind === 'model' ? (() => {
15421
+ const asset = E.gltfAssetOf(sel.assetId);
15422
+ const bones = asset ? E.boneListOf(asset) : [];
15423
+ if (!bones.length) return [];
15424
+ const r = (label, node) => envRow(label, node);
15425
+ const boneSel = (val, onChange) => h('select', { className: 'dshic-dir-input', value: String(val || ''), onChange },
15426
+ [h('option', { key: '', value: '' }, '—')].concat(bones.map((b) => h('option', { key: b.index, value: b.name }, b.name))));
15427
+ const num = (v, on, step) => h('input', { className: 'dshic-dir-input', type: 'number', step: step || 0.01, value: String(Number(v) || 0), onChange: (e2) => on(Number(e2.target.value) || 0) });
15428
+ const writeObj = (fn) => patch((n) => { const o2 = n.objects.find((x) => x.id === sel.id); if (o2) fn(o2); });
15429
+ const ikList = Array.isArray(sel.ik) ? sel.ik : [];
15430
+ const conList = Array.isArray(sel.constraints) ? sel.constraints : [];
15431
+ const clipList = Array.isArray(sel.clips) ? sel.clips : [];
15432
+ const ikPos = (t) => {
15433
+ if (Array.isArray(t)) return t.map(Number);
15434
+ if (t && Array.isArray(t.keys) && t.keys.length) return (t.keys[t.keys.length - 1].pos || [0, 0, 0]).map(Number);
15435
+ return t ? [Number(t.x) || 0, Number(t.y) || 0, Number(t.z) || 0] : [0, 0, 0];
15436
+ };
15437
+ const endPos = (chain) => {
15438
+ try {
15439
+ const local = E.applyBonePoseToLocal(asset, playT, sel.bonePose || null, null, null, 0, { ik: sel.ik, constraints: sel.constraints, clips: sel.clips });
15440
+ const wm = E.localWorldMatrices(asset, local);
15441
+ const bn = bones.find((b) => b.name === chain[2]);
15442
+ if (!bn) return null;
15443
+ const m = wm[bn.nodeIdx];
15444
+ return Number.isFinite(m[12]) ? [m[12], m[13], m[14]] : null;
15445
+ } catch { return null; }
15446
+ };
15447
+ const actionSelect = h('div', { className: 'dshic-dir-row' }, h('span', null, T('dirAction')), h('select', {
15448
+ className: 'dshic-dir-input', value: sel.userActionId ? ('ua:' + sel.userActionId) : ('clip:' + String(sel.clip || ((E.gltfAssetOf(sel.assetId) || {}).animations || [{}])[0].name || '')),
15449
+ 'data-action': 'dir-action-select',
15450
+ onChange: (e2) => patch((n) => { const v = String(e2.target.value || ''); const t2 = n.objects.find((x) => x.id === sel.id); if (t2) { if (v.indexOf('ua:') === 0) { const ua = E.loadUserActions(S.activeId).find((a) => a.id === v.slice(3)); if (ua) { E.applyUserAction(t2, ua); t2.userActionId = ua.id; t2.animTime = 0; } } else { const nm2 = v.slice(5); t2.clip = nm2; t2.animTime = 0; t2.userActionId = null; } } }),
15451
+ }, [].concat(((E.gltfAssetOf(sel.assetId) || {}).animations || []).map((a2) => h('option', { key: 'clip:' + a2.name, value: 'clip:' + a2.name }, clipLabel(a2.name))), E.loadUserActions(S.activeId).map((ua) => h('option', { key: 'ua:' + ua.id, value: 'ua:' + ua.id }, '★ ' + ua.name)))));
15452
+ return [
15453
+ h('div', { className: 'dshic-dir-subhead' }, T('dirAction')),
15454
+ actionSelect,
15455
+ h('div', { className: 'dshic-dir-btnrow' },
15456
+ h('button', { type: 'button', className: 'dshic-dir-mini', onClick: dupSel }, T('dirDup')),
15457
+ h('button', { type: 'button', className: 'dshic-dir-mini is-danger', onClick: delSel }, T('dirDel')),
15458
+ h('button', {
15459
+ type: 'button', className: 'dshic-dir-mini', 'data-action': 'dir-custom-action-toggle',
15460
+ onClick: () => setCustomActionOpen((v) => !v),
15461
+ }, (customActionOpen ? '▾ ' : '▸ ') + T('dirCustomAction'))),
15462
+ // ── 自定义动作(默认收起):IK / 约束 / 动作层 / 骨骼 / 保存 ──
15463
+ // ── 自定义动作(默认收起)──
15464
+ ...(customActionOpen ? [
15465
+ h('div', { className: 'dshic-dir-subhead' }, T('dirIk')),
15466
+ ...(ikList.length ? ikList.map((ik, ii) => {
15467
+ const chain = (ik.chain || []).slice(0, 3);
15468
+ const pos = ikPos(ik.target);
15469
+ const isKeys = !!(ik.target && !Array.isArray(ik.target) && Array.isArray(ik.target.keys));
15470
+ const upd = (fn) => writeObj((o2) => { o2.ik = JSON.parse(JSON.stringify(ikList)); fn(o2.ik[ii]); });
15471
+ return h('div', { key: 'ik-' + ii, className: 'dshic-dir-lightbox' },
15472
+ r(T('dirIkChain'), h('div', { className: 'dshic-dir-xyz' },
15473
+ [0, 1, 2].map((k2) => h('span', { key: k2, style: { flex: '1 1 0' } },
15474
+ boneSel(chain[k2], (e2) => upd((x) => { if (!Array.isArray(x.chain)) x.chain = []; x.chain[k2] = e2.target.value; })))))),
15475
+ r(T('dirIkTarget'), h('select', {
15476
+ className: 'dshic-dir-input',
15477
+ value: isKeys ? 'keys' : 'point',
15478
+ onChange: (e2) => upd((x) => {
15479
+ if (e2.target.value === 'keys') x.target = { keys: [{ t: Math.round(playT * 100) / 100, pos: pos.slice() }] };
15480
+ else x.target = pos.slice();
15481
+ }),
15482
+ }, [h('option', { key: 'point', value: 'point' }, T('dirIkTargetPoint')), h('option', { key: 'keys', value: 'keys' }, T('dirIkTargetKeys'))])),
15483
+ r('XYZ', h('div', { className: 'dshic-dir-xyz' },
15484
+ [0, 1, 2].map((k2) => h('span', { key: k2, style: { flex: '1 1 0' } }, num(pos[k2], (v) => {
15485
+ upd((x) => {
15486
+ const p2 = ikPos(x.target);
15487
+ p2[k2] = v;
15488
+ if (isKeys) x.target.keys = (x.target.keys || []).concat([{ t: Math.round(playT * 100) / 100, pos: p2 }]);
15489
+ else x.target = p2;
15490
+ });
15491
+ }, 0.05))))),
15492
+ r(T('dirIkPole'), h('div', { className: 'dshic-dir-xyz' },
15493
+ [0, 1, 2].map((k2) => h('span', { key: k2, style: { flex: '1 1 0' } }, num((ik.pole || [])[k2] || 0, (v) => upd((x) => {
15494
+ const p2 = Array.isArray(x.pole) ? x.pole.slice() : [0, 0, 0];
15495
+ p2[k2] = v; x.pole = p2;
15496
+ }), 0.05))))),
15497
+ h('div', { className: 'dshic-dir-keyrow' },
15498
+ h('button', { type: 'button', className: 'dshic-dir-mini', 'data-action': 'dir-ik-take-current', onClick: () => {
15499
+ const p2 = endPos(chain);
15500
+ if (!p2) { setMsg({ kind: 'err', text: T('dirIkEmpty') }); return; }
15501
+ upd((x) => {
15502
+ if (isKeys) x.target = { keys: (x.target.keys || []).concat([{ t: Math.round(playT * 100) / 100, pos: p2 }]) };
15503
+ else x.target = p2;
15504
+ });
15505
+ } }, '⌖ ' + T('dirIkTakeCurrent')),
15506
+ h('button', { type: 'button', className: 'dshic-dir-mini', 'data-action': 'dir-ik-key', onClick: () => {
15507
+ upd((x) => {
15508
+ if (!x.target || Array.isArray(x.target)) x.target = { keys: [{ t: 0, pos: Array.isArray(x.target) ? x.target : [0, 0, 0] }] };
15509
+ x.target.keys = (x.target.keys || []).concat([{ t: Math.round(playT * 100) / 100, pos: ikPos(x.target) }]);
15510
+ });
15511
+ } }, '◆ ' + T('dirIkKey')),
15512
+ h('button', { type: 'button', className: 'dshic-dir-mini is-danger', onClick: () => writeObj((o2) => {
15513
+ const arr = (o2.ik || []).slice(); arr.splice(ii, 1);
15514
+ if (arr.length) o2.ik = arr; else delete o2.ik;
15515
+ }) }, '✖')));
15516
+ }) : [h('div', { key: 'ik-empty', className: 'dshic-dir-time' }, T('dirIkEmpty'))]),
15517
+ h('div', { className: 'dshic-dir-row' },
15518
+ h('button', { type: 'button', className: 'dshic-dir-mini', 'data-action': 'dir-ik-add', onClick: () => writeObj((o2) => {
15519
+ const pick = (nm) => bones.find((b) => b.name === nm);
15520
+ const def = ['RightUpLeg', 'RightLeg', 'RightFoot'].map((nm) => (pick(nm) || { name: '' }).name);
15521
+ const chain2 = def.every((x2) => x2) ? def : bones.slice(0, 3).map((b) => b.name);
15522
+ const p2 = endPos(chain2) || [0, 0, 0];
15523
+ o2.ik = (o2.ik || []).concat([{ chain: chain2, target: p2, pole: [p2[0], p2[1] + 1, p2[2] + 1] }]);
15524
+ }) }, '+ ' + T('dirIkAdd'))),
15525
+
15526
+ h('div', { className: 'dshic-dir-subhead' }, T('dirConstraint')),
15527
+ ...(conList.length ? conList.map((c, ci) => {
15528
+ const upd = (fn) => writeObj((o2) => { o2.constraints = JSON.parse(JSON.stringify(conList)); fn(o2.constraints[ci]); });
15529
+ const type = String(c.type || 'damped_track');
15530
+ return h('div', { key: 'con-' + ci, className: 'dshic-dir-lightbox' },
15531
+ r(T('dirConstraintType'), h('select', { className: 'dshic-dir-input', value: type, onChange: (e2) => upd((x) => { x.type = e2.target.value; if (x.type === 'damped_track' && !x.target) x.target = [0, 1, 0]; if (x.type === 'copy_rotation' && !x.from) x.from = bones[0].name; if (x.type === 'limit_rotation') { x.min = x.min || [-0.5, -0.5, -0.5]; x.max = x.max || [0.5, 0.5, 0.5]; } }) },
15532
+ [h('option', { key: 'dt', value: 'damped_track' }, 'Damped Track'), h('option', { key: 'cr', value: 'copy_rotation' }, 'Copy Rotation'), h('option', { key: 'lr', value: 'limit_rotation' }, 'Limit Rotation')])),
15533
+ r(T('dirBone') || 'Bone', boneSel(c.bone, (e2) => upd((x) => { x.bone = e2.target.value; }))),
15534
+ type === 'copy_rotation' ? r(T('dirConstraintFrom'), boneSel(c.from, (e2) => upd((x) => { x.from = e2.target.value; }))) : null,
15535
+ type === 'copy_rotation' ? r(T('dirConstraintMix'), num(c.mix === undefined ? 1 : c.mix, (v) => upd((x) => { x.mix = v; }), 0.05)) : null,
15536
+ type === 'damped_track' ? r(T('dirConstraintTarget'), h('div', { className: 'dshic-dir-xyz' },
15537
+ [0, 1, 2].map((k2) => h('span', { key: k2, style: { flex: '1 1 0' } }, num((Array.isArray(c.target) ? c.target : [0, 1, 0])[k2] || 0, (v) => upd((x) => {
15538
+ const p2 = Array.isArray(x.target) ? x.target.slice() : [0, 1, 0]; p2[k2] = v; x.target = p2;
15539
+ }), 0.05))))) : null,
15540
+ type === 'limit_rotation' ? r(T('dirConstraintLimit'), h('div', { className: 'dshic-dir-xyz' },
15541
+ [0, 1, 2].map((k2) => h('span', { key: k2, style: { flex: '1 1 0' } }, num(Math.round(((c.min || [])[k2] || 0) * 180 / Math.PI), (v) => upd((x) => {
15542
+ const mn = (x.min || [-0.5, -0.5, -0.5]).slice(); mn[k2] = (v * Math.PI) / 180; x.min = mn;
15543
+ }), 5))))) : null,
15544
+ type === 'limit_rotation' ? r('MAX', h('div', { className: 'dshic-dir-xyz' },
15545
+ [0, 1, 2].map((k2) => h('span', { key: k2, style: { flex: '1 1 0' } }, num(Math.round(((c.max || [])[k2] || 0) * 180 / Math.PI), (v) => upd((x) => {
15546
+ const mx = (x.max || [0.5, 0.5, 0.5]).slice(); mx[k2] = (v * Math.PI) / 180; x.max = mx;
15547
+ }), 5))))) : null,
15548
+ h('div', { className: 'dshic-dir-keyrow' },
15549
+ h('button', { type: 'button', className: 'dshic-dir-mini is-danger', onClick: () => writeObj((o2) => {
15550
+ const arr = (o2.constraints || []).slice(); arr.splice(ci, 1);
15551
+ if (arr.length) o2.constraints = arr; else delete o2.constraints;
15552
+ }) }, '✖')));
15553
+ }) : [h('div', { key: 'con-empty', className: 'dshic-dir-time' }, T('dirConstraintEmpty'))]),
15554
+ h('div', { className: 'dshic-dir-row' },
15555
+ h('button', { type: 'button', className: 'dshic-dir-mini', 'data-action': 'dir-constraint-add', onClick: () => writeObj((o2) => {
15556
+ o2.constraints = (o2.constraints || []).concat([{ type: 'damped_track', bone: (bones[0] || {}).name || '', target: [0, 1, 0] }]);
15557
+ }) }, '+ ' + T('dirConstraintAdd'))),
15558
+
15559
+ h('div', { className: 'dshic-dir-subhead' }, T('dirClips')),
15560
+ ...(clipList.length ? clipList.map((cp, ci) => {
15561
+ const upd = (fn) => writeObj((o2) => { o2.clips = JSON.parse(JSON.stringify(clipList)); fn(o2.clips[ci]); });
15562
+ const move = (d2) => writeObj((o2) => { const arr = (o2.clips || []).slice(); const j = ci + d2; if (j < 0 || j >= arr.length) return; const t2 = arr[ci]; arr[ci] = arr[j]; arr[j] = t2; o2.clips = arr; });
15563
+ return h('div', { key: 'clip-' + ci, className: 'dshic-dir-lightbox' },
15564
+ r(T('dirClipName'), h('input', { className: 'dshic-dir-input', value: String(cp.name || ''), onChange: (e2) => upd((x) => { x.name = e2.target.value; }) })),
15565
+ r(T('dirClipBlend'), h('select', { className: 'dshic-dir-input', value: String(cp.blend || 'replace'), onChange: (e2) => upd((x) => { x.blend = e2.target.value; }) },
15566
+ [h('option', { key: 'r', value: 'replace' }, 'replace'), h('option', { key: 'a', value: 'add' }, 'add'), h('option', { key: 'm', value: 'multiply' }, 'multiply')])),
15567
+ r(T('dirClipInfluence'), h('input', { className: 'dshic-dir-input', type: 'range', min: 0, max: 1, step: 0.05, value: String(cp.influence === undefined ? 1 : cp.influence), onChange: (e2) => upd((x) => { x.influence = Number(e2.target.value); }) })),
15568
+ r(T('dirClipLoop'), h('input', { type: 'checkbox', checked: !!cp.loop, onChange: (e2) => upd((x) => { x.loop = e2.target.checked; }) })),
15569
+ h('div', { className: 'dshic-dir-time' }, T('dirClipKeys') + ' ' + ((cp.keys || []).length)),
15570
+ h('div', { className: 'dshic-dir-keyrow' },
15571
+ h('button', { type: 'button', className: 'dshic-dir-mini', onClick: () => move(-1) }, '↑'),
15572
+ h('button', { type: 'button', className: 'dshic-dir-mini', onClick: () => move(1) }, '↓'),
15573
+ h('button', { type: 'button', className: 'dshic-dir-mini is-danger', onClick: () => writeObj((o2) => {
15574
+ const arr = (o2.clips || []).slice(); arr.splice(ci, 1);
15575
+ if (arr.length) o2.clips = arr; else delete o2.clips;
15576
+ }) }, '✖')));
15577
+ }) : [h('div', { key: 'clip-empty', className: 'dshic-dir-time' }, T('dirClipEmpty'))]),
15578
+ h('div', { className: 'dshic-dir-keyrow' },
15579
+ h('button', { type: 'button', className: 'dshic-dir-mini', 'data-action': 'dir-clip-add', onClick: () => writeObj((o2) => {
15580
+ o2.clips = (o2.clips || []).concat([{ name: T('dirClips') + ' ' + ((o2.clips || []).length + 1), keys: [], blend: 'replace', influence: 1 }]);
15581
+ }) }, '+ ' + T('dirClipAdd')),
15582
+ h('button', { type: 'button', className: 'dshic-dir-mini', 'data-action': 'dir-clip-bake', onClick: () => {
15583
+ const keys = JSON.parse(JSON.stringify(sel.poseKeys || []));
15584
+ if (!keys.length) { setMsg({ kind: 'err', text: T('dirClipEmpty') }); return; }
15585
+ writeObj((o2) => { o2.clips = (o2.clips || []).concat([{ name: 'baked ' + ((o2.clips || []).length + 1), keys, blend: 'replace', influence: 1 }]); });
15586
+ setMsg({ kind: 'ok', text: T('dirClipBaked') });
15587
+ } }, '⇪ ' + T('dirClipBake')),
15588
+ h('button', { type: 'button', className: 'dshic-dir-mini', 'data-action': 'dir-bvh-import', onClick: () => setBvhOpen(!bvhOpen) }, '⚄ ' + T('dirClipBvh'))),
15589
+ bvhOpen ? h('div', { className: 'dshic-dir-lightbox' },
15590
+ h('textarea', { className: 'dshic-dir-input', style: { width: '100%', minHeight: '70px' }, placeholder: T('dirClipBvhPh'), value: bvhText, onChange: (e2) => setBvhText(e2.target.value) }),
15591
+ h('div', { className: 'dshic-dir-keyrow' },
15592
+ h('button', { type: 'button', className: 'dshic-dir-mini', onClick: () => {
15593
+ try {
15594
+ const bvh = E.parseBVH(bvhText);
15595
+ const rt = E.retargetBVH(asset, bvh, { sampleStep: 2 });
15596
+ if (!rt.poseKeys.length) throw new Error('empty');
15597
+ writeObj((o2) => { o2.clips = (o2.clips || []).concat([{ name: 'bvh ' + ((o2.clips || []).length + 1), keys: rt.poseKeys, blend: 'replace', influence: 1, loop: true, t0: 0, t1: rt.duration }]); });
15598
+ setMsg({ kind: 'ok', text: T('dirClipBvhImported') });
15599
+ setBvhOpen(false); setBvhText('');
15600
+ } catch { setMsg({ kind: 'err', text: T('dirClipBvhFail') }); }
15601
+ } }, '✓ ' + T('dirClipBvh')),
15602
+ h('button', { type: 'button', className: 'dshic-dir-mini', onClick: () => { setBvhOpen(false); setBvhText(''); } }, '✖'))) : null,
15603
+ ] : []),
15604
+ ];
15605
+ })() : []),
15259
15606
  // ── 景深 ──
15260
15607
  ...(!sel && previzMode !== 'render' ? [
15261
15608
  envRow(T('dirDof'), h('label', null, h('input', { type: 'checkbox', 'data-action': 'dir-dof-on', checked: !!(((scene.env || {}).dof) || {}).enabled,
@@ -15308,7 +15655,7 @@ const DIRECTOR_KEY_HELP = [
15308
15655
  });
15309
15656
  setMsg({ kind: 'ok', text: T('dirPathPointAdded') });
15310
15657
  },
15311
- }, '+ ' + T('dirPathAddPoint')),
15658
+ }, T('dirPathAddPoint')),
15312
15659
  h('button', {
15313
15660
  type: 'button', className: 'dshic-dir-mini', 'data-action': 'dir-path-draw',
15314
15661
  onClick: () => { S.directorPathPick = !S.directorPathPick; setMsg({ kind: 'ok', text: T('dirPathDrawHint') }); bumpView(); },
@@ -15316,7 +15663,7 @@ const DIRECTOR_KEY_HELP = [
15316
15663
  h('button', {
15317
15664
  type: 'button', className: 'dshic-dir-mini is-danger', 'data-action': 'dir-path-clear',
15318
15665
  onClick: () => { patch((n2) => { const o2 = n2.objects.find((x) => x.id === sel.id); if (!o2) return; if (o2.pathOrigin) o2.pos = { x: o2.pathOrigin.x, y: o2.pathOrigin.y, z: o2.pathOrigin.z }; o2.path = []; o2.pathMode = 'none'; }); setMsg({ kind: 'ok', text: T('dirPathCleared') }); },
15319
- }, '✖ ' + T('dirPathClear')),
15666
+ }, T('dirPathClear')),
15320
15667
  h('button', {
15321
15668
  type: 'button', className: 'dshic-dir-mini is-danger', 'data-action': 'dir-path-del-point',
15322
15669
  onClick: () => {
@@ -15329,7 +15676,7 @@ const DIRECTOR_KEY_HELP = [
15329
15676
  S.directorPathSel = null;
15330
15677
  setMsg({ kind: 'ok', text: T('dirPathPointDeleted') });
15331
15678
  },
15332
- }, '- ' + T('dirPathPointDel'))),
15679
+ }, T('dirPathPointDel'))),
15333
15680
  h('div', { className: 'dshic-dir-time' }, T('dirPathCount') + ' ' + ((sel.path || []).length) + ' · ' + T('dirPathHint')),
15334
15681
  // ── 移动驱动:让动作跟着路径速度走(避免滑步)。
15335
15682
  // 动作只在一处选:跟随上方「动作」下拉;这里只调步频参数或改用自建动作。──
@@ -15419,10 +15766,6 @@ const DIRECTOR_KEY_HELP = [
15419
15766
  type: 'button', className: 'dshic-dir-mini', 'data-action': 'dir-obj-color-reset',
15420
15767
  onClick: () => patch((n2) => { const o2 = n2.objects.find((x) => x.id === sel.id); if (o2) o2.color = ''; }),
15421
15768
  }, T('dirReset'))),
15422
- sel.kind === 'model' ? h('div', { className: 'dshic-dir-row' }, h('span', null, T('dirAction')), h('select', {
15423
- className: 'dshic-dir-input', value: sel.userActionId ? ('ua:' + sel.userActionId) : ('clip:' + String(sel.clip || (E.gltfAssetOf(sel.assetId) || {}).animations && ((E.gltfAssetOf(sel.assetId) || {}).animations[0] || {}).name || '')),
15424
- onChange: (e2) => patch((n) => { const v = String(e2.target.value || ''); const t2 = n.objects.find((x) => x.id === sel.id); if (t2) { if (v.indexOf('ua:') === 0) { const ua = E.loadUserActions(S.activeId).find((a) => a.id === v.slice(3)); if (ua) { E.applyUserAction(t2, ua); t2.userActionId = ua.id; } } else { t2.clip = v.replace(/^clip:/, ''); t2.userActionId = null; } }; }),
15425
- }, [].concat(((E.gltfAssetOf(sel.assetId) || {}).animations || []).map((a2) => h('option', { key: 'clip:' + a2.name, value: 'clip:' + a2.name }, clipLabel(a2.name))), E.loadUserActions(S.activeId).map((ua) => h('option', { key: 'ua:' + ua.id, value: 'ua:' + ua.id }, '★ ' + ua.name))))) : null,
15426
15769
  h('div', { className: 'dshic-dir-btnrow' },
15427
15770
  h('button', { type: 'button', className: 'dshic-dir-mini', onClick: dupSel }, T('dirDup')),
15428
15771
  h('button', { type: 'button', className: 'dshic-dir-mini is-danger', onClick: delSel }, T('dirDel')))) : null,
@@ -15432,7 +15775,7 @@ const DIRECTOR_KEY_HELP = [
15432
15775
  if (!bones.length) return null;
15433
15776
  const poseMap = sel.bonePose || {};
15434
15777
  const activeBone = Number(sel.boneIndex) >= 0 ? Number(sel.boneIndex) : -1;
15435
- return h('div', null,
15778
+ return customActionOpen ? h('div', null,
15436
15779
  h('div', { className: 'dshic-dir-subhead' }, T('dirSkeleton') + ' (' + bones.length + ')'),
15437
15780
  h('div', { className: 'dshic-dir-row' },
15438
15781
  h('span', null, T('dirBone')),
@@ -15457,23 +15800,6 @@ const DIRECTOR_KEY_HELP = [
15457
15800
  o2.bonePose[activeBone] = cur;
15458
15801
  }),
15459
15802
  })))),
15460
- // ── 基础姿态(骨架级,方向式)──
15461
- h('div', { className: 'dshic-dir-subhead' }, T('dirPoseLib')),
15462
- h('div', { className: 'dshic-dir-posegrid' },
15463
- E.BONE_POSES.filter((p) => p.id !== 'none').map((p) => h('button', {
15464
- key: p.id, type: 'button',
15465
- className: cls('dshic-dir-posebtn', sel.poseId === p.id && 'is-on'),
15466
- 'data-pose': p.id,
15467
- onClick: () => {
15468
- patch((n) => {
15469
- const o2 = n.objects.find((x) => x.id === sel.id);
15470
- if (!o2) return;
15471
- o2.bonePose = {}; // 姿态由 poseId 在蒙皮时按当前动画姿势求解
15472
- o2.poseId = p.id;
15473
- });
15474
- setMsg({ kind: 'ok', text: T('dirPoseApplied') + ' · ' + (effLang() === 'en' ? p.en : p.zh) });
15475
- },
15476
- }, effLang() === 'en' ? p.en : p.zh))),
15477
15803
  // ── 用户自建动作:骨骼 + 姿态调好后保存,保存后出现在上方「动作」下拉 ──
15478
15804
  h('div', { className: 'dshic-dir-row' },
15479
15805
  h('input', {
@@ -15509,7 +15835,7 @@ const DIRECTOR_KEY_HELP = [
15509
15835
  const o2 = n.objects.find((x) => x.id === sel.id);
15510
15836
  if (o2) { o2.bonePose = {}; o2.poseId = null; o2.poseAnim = []; o2.poseKeys = []; o2.userActionId = null; }
15511
15837
  }),
15512
- }, T('dirPoseClear'))));
15838
+ }, T('dirPoseClear')))) : null;
15513
15839
  })() : null,
15514
15840
  ...(!sel && previzMode !== 'render' ? [
15515
15841
  h('div', { className: 'dshic-dir-subhead' }, T('dirShot')),
@@ -17297,7 +17623,7 @@ const DIRECTOR_KEY_HELP = [
17297
17623
  await refreshDataRoot();
17298
17624
  toast(T('dataRootSaved') + (S.dataRoot ? ':' + S.dataRoot : ''));
17299
17625
  if (same) { setBusy(false); return; } // 同目录重复保存:不重复迁移
17300
- autoMigrateAssetsToRoot(); // 换了目录:把本地遗留 blob 搬进新目录(v1 旧布局由 Host 侧自动迁移)
17626
+ migrateLegacyAssets('data-root'); // 换了目录:一次性迁移旧 blob(v1 旧布局由 Host 侧自动迁移)
17301
17627
  assetMem.clear();
17302
17628
  update((s) => { s.assetsTick++; });
17303
17629
  } catch (e) { toast('设置失败:' + ((e && e.message) || e)); }
@@ -17310,7 +17636,7 @@ const DIRECTOR_KEY_HELP = [
17310
17636
  setVal(S.dataRoot || root);
17311
17637
  toast(T('dataRootSaved') + ':' + S.dataRoot);
17312
17638
  if (same) return; // 同目录:不重复迁移
17313
- autoMigrateAssetsToRoot(); // 换了目录:把本地遗留 blob 搬进新目录
17639
+ migrateLegacyAssets('data-root'); // 换了目录:一次性迁移旧 blob
17314
17640
  assetMem.clear();
17315
17641
  update((s) => { s.assetsTick++; });
17316
17642
  };
@@ -18338,7 +18664,7 @@ const DIRECTOR_KEY_HELP = [
18338
18664
  // 检查更新(Host RPC;Host 旧版未带此方法时静默)
18339
18665
  checkUpdate(true);
18340
18666
  // 数据根目录(Host 旧版静默降级为 IndexedDB 模式);就绪后把本地遗留 blob 搬进服务端(v1 旧布局由 Host 侧自动迁移)
18341
- refreshDataRoot().then(() => autoMigrateAssetsToRoot()).catch(() => { /* 静默 */ });
18667
+ refreshDataRoot().then(() => migrateLegacyAssets('boot')).catch(() => { /* 静默 */ });
18342
18668
  // 对话 ↔ 画布同步(启动一次 + 3s 轮询;旧 Host 自动停轮询)
18343
18669
  hostSyncTick().then(() => pushAllProjectsToHost());
18344
18670
  const hostSyncTimer = setInterval(() => hostSyncTick(), 3000);
@@ -18463,6 +18789,16 @@ const DIRECTOR_KEY_HELP = [
18463
18789
  setCanvasElForTest: (el) => { canvasEl = el; },
18464
18790
  edgePath,
18465
18791
  };
18792
+ // 旧 IDB 资产一次性迁移的测试缝(供 scripts/test-asset-migration.mjs 驱动真实迁移逻辑)
18793
+ window.__DSH_IC_MIGRATE__ = {
18794
+ migrateLegacyAssets,
18795
+ ensureThumbForAsset,
18796
+ state: () => S.assetMigration || null,
18797
+ setIdbAllForTest: (fn) => { idbAllImpl = fn || null; },
18798
+ setIdbDeleteForTest: (fn) => { idbDeleteImpl = fn || null; },
18799
+ setDataRootForTest: (v) => { S.dataRoot = v || null; },
18800
+ thumbCache, thumbFailed,
18801
+ };
18466
18802
  // 排版纯函数/常量出口:预览区比例、节点外框公式、等比缩放、面板宽度(供 scripts/test-node-layout.mjs 断言)
18467
18803
  window.__DSH_IC_LAYOUT__ = {
18468
18804
  NODE_W, NODE_H, NODE_HDR_H, NODE_FOOT_H, MEDIA_PREVIEW_SHORT, MEDIA_MAX_LONG,