@ninkch/dsh-infinite-canvas 0.19.0 → 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': '姿态帧已记录',
@@ -654,6 +655,8 @@ window.__ModuleLoader__.load({
654
655
  'dirActionNone': '当前没有选中自建动作',
655
656
  'dirLens': '焦段(mm)',
656
657
  'dirSensor': '传感器',
658
+ 'mediaMissing': '素材缺失',
659
+ 'retry': '重试',
657
660
  'dirKeys': '快捷键',
658
661
  'dirRenderLive': '实时渲染中 · 所见即成片',
659
662
  'dirSun': '太阳光(物理日光)',
@@ -680,6 +683,46 @@ window.__ModuleLoader__.load({
680
683
  'dirCurFill': '当前构图',
681
684
  'dirAddShot': '添加镜头',
682
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': '姿态关键帧',
683
726
  'dirPoseKeyCount': '姿态帧',
684
727
  'dirPoseKeyClear2': '清除姿态帧',
685
728
  'dirPoseKeyAdded': '姿态关键帧已记录',
@@ -699,11 +742,11 @@ window.__ModuleLoader__.load({
699
742
  'dirPathNone': '无',
700
743
  'dirPathAddPoint': '加点',
701
744
  'dirPathPointAdded': '路径点已添加',
702
- 'dirPathDraw': '画路径',
745
+ 'dirPathDraw': '画线',
703
746
  'dirPathDrawing': '正在画(点地面)',
704
747
  'dirPathDrawHint': '点地面添加路径点,再点按钮结束',
705
- 'dirPathClear': '清空路径',
706
- 'dirPathPointDel': '删除路径点',
748
+ 'dirPathClear': '清空',
749
+ 'dirPathPointDel': '删点',
707
750
  'dirSpecTitle': 'AI 预演规格',
708
751
  'dirSpecShow': '规格 JSON',
709
752
  'dirSpecCopy': '生成规格',
@@ -719,7 +762,7 @@ window.__ModuleLoader__.load({
719
762
  'dirPathPointNone': '请先点选一个路径点',
720
763
  'dirPathCleared': '路径已清除,物体已复位',
721
764
  'dirPathCount': '路径点',
722
- 'dirLocomotion': '移动驱动(自动步频)',
765
+ 'dirLocomotion': '移动驱动',
723
766
  'dirLocoSource': '动作来源',
724
767
  'dirLocoNone': '不驱动',
725
768
  'dirLocoClip': '自动步频(跟随动作)',
@@ -730,7 +773,7 @@ window.__ModuleLoader__.load({
730
773
  'dirLocoCycle': '循环周期(s)',
731
774
  'dirLocoReadout': '当前',
732
775
  'dirLocoStepsPerSec': '步/秒',
733
- 'dirPathHint': '物体沿路径运动',
776
+ 'dirPathHint': '沿路径运动',
734
777
  'dirPathLine': '折线',
735
778
  'dirPathCurve': '曲线(跟随朝向)',
736
779
  'dirLookAhead': '前瞻距离',
@@ -1472,13 +1515,13 @@ window.__ModuleLoader__.load({
1472
1515
  'dirCamAdded': 'Camera added (current view)',
1473
1516
  'dirCamDeleted': 'Camera deleted',
1474
1517
  'dirPathNone': 'None',
1475
- 'dirPathAddPoint': 'Add point',
1518
+ 'dirPathAddPoint': 'Add',
1476
1519
  'dirPathPointAdded': 'Path point added',
1477
- 'dirPathDraw': 'Draw path',
1520
+ 'dirPathDraw': 'Draw',
1478
1521
  'dirPathDrawing': 'Drawing (click ground)',
1479
1522
  'dirPathDrawHint': 'Click ground to add points, click again to finish',
1480
- 'dirPathClear': 'Clear path',
1481
- 'dirPathPointDel': 'Delete point',
1523
+ 'dirPathClear': 'Clear',
1524
+ 'dirPathPointDel': 'Del pt',
1482
1525
  'dirSpecTitle': 'AI stage spec',
1483
1526
  'dirSpecShow': 'Spec JSON',
1484
1527
  'dirSpecCopy': 'Generate spec',
@@ -1505,7 +1548,7 @@ window.__ModuleLoader__.load({
1505
1548
  'dirLocoCycle': 'Cycle (s)',
1506
1549
  'dirLocoReadout': 'Current',
1507
1550
  'dirLocoStepsPerSec': 'steps/s',
1508
- 'dirPathHint': 'Object follows path',
1551
+ 'dirPathHint': 'Follows path',
1509
1552
  'dirShot': 'Shot',
1510
1553
  'dirSkeleton': 'Skeleton',
1511
1554
  'dirMaterial': 'Material',
@@ -1520,6 +1563,7 @@ window.__ModuleLoader__.load({
1520
1563
  'dirBone': 'Bone',
1521
1564
  'dirBoneRot': 'Bone rotation',
1522
1565
  'dirPoseLib': 'Pose library',
1566
+ 'dirCustomAction': 'Custom action',
1523
1567
  'dirPoseAnimAdd': 'Record pose key',
1524
1568
  'dirPoseAnimClear': 'Clear pose anim',
1525
1569
  'dirPoseAnimAdded': 'Pose key recorded',
@@ -1548,6 +1592,8 @@ window.__ModuleLoader__.load({
1548
1592
  'dirExportCancelled': 'Export cancelled',
1549
1593
  'dirLookAhead': 'Look ahead',
1550
1594
  'dirSensor': 'Sensor',
1595
+ 'mediaMissing': 'Asset file missing',
1596
+ 'retry': 'Retry',
1551
1597
  'dirKeys': 'Shortcuts',
1552
1598
  'dirRenderLive': 'Live render · what you see is the final',
1553
1599
  'dirSun': 'Sun (physical daylight)',
@@ -1571,6 +1617,46 @@ window.__ModuleLoader__.load({
1571
1617
  'dirShotApplied': 'Shot applied',
1572
1618
  'dirCurFill': 'Framing',
1573
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',
1574
1660
  'dirPoseKey': 'Pose key',
1575
1661
  'dirPoseKeyCount': 'Pose keys',
1576
1662
  'dirPoseKeyClear2': 'Clear pose keys',
@@ -2414,8 +2500,8 @@ body[data-ds-dark-theme] .dshic-studio-modal{border-color:var(--dsw-alias-border
2414
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;}
2415
2501
  .dshic-dir-treeitem:hover{background:var(--dsw-alias-interactive-bg-hover);}
2416
2502
  .dshic-dir-treeitem.is-on{background:color-mix(in srgb,var(--dsw-alias-brand-primary) 14%,transparent);color:var(--dsw-alias-brand-primary);}
2417
- .dshic-dir-keyrow{display:flex;gap:6px;margin-top:6px;}
2418
- .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;}
2419
2505
  .dshic-dir-mini:hover{border-color:var(--dsw-alias-brand-primary);color:var(--dsw-alias-brand-primary);}
2420
2506
  .dshic-dir-mini.is-danger:hover{border-color:var(--dsw-alias-state-error-primary);color:var(--dsw-alias-state-error-primary);}
2421
2507
  .dshic-dir-props{flex:1;padding:0 12px 12px;display:flex;flex-direction:column;gap:6px;min-height:0;}
@@ -2427,7 +2513,9 @@ body[data-ds-dark-theme] .dshic-studio-modal{border-color:var(--dsw-alias-border
2427
2513
  .dshic-dir-row{display:flex;align-items:center;gap:6px;font-size:11.5px;color:var(--dsw-alias-label-secondary);}
2428
2514
  .dshic-dir-row>span{flex:none;width:44px;}
2429
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;}
2430
- .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;}
2431
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;}
2432
2520
  .dshic-dir-docktitle{font-size:11.5px;font-weight:600;color:var(--dsw-alias-label-secondary);}
2433
2521
  .dshic-dir-time{font-size:11px;color:var(--dsw-alias-label-secondary);font-variant-numeric:tabular-nums;white-space:nowrap;}
@@ -2489,7 +2577,7 @@ body[data-ds-dark-theme] .dshic-studio-modal{border-color:var(--dsw-alias-border
2489
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;}
2490
2578
  .dshic-dir-posebtn:hover{border-color:var(--dsw-alias-brand-primary);color:var(--dsw-alias-brand-primary);}
2491
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);}
2492
- .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;}
2493
2581
  .dshic-dir-btnrow .dshic-mini{flex:1;justify-content:center;display:inline-flex;align-items:center;}
2494
2582
  .dshic-dir-envrow{display:flex;align-items:center;gap:6px;font-size:11.5px;color:var(--dsw-alias-label-secondary);margin-bottom:5px;}
2495
2583
  .dshic-dir-envrow>span{flex:none;width:52px;}
@@ -2498,7 +2586,7 @@ body[data-ds-dark-theme] .dshic-studio-modal{border-color:var(--dsw-alias-border
2498
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%;}
2499
2587
  .dshic-dir-empty{font-size:11.5px;color:var(--dsw-alias-label-secondary);line-height:1.7;padding:8px 0;}
2500
2588
  .dshic-dir-msg.is-err{background:color-mix(in srgb,var(--dsw-alias-state-error-primary) 88%,#000);}
2501
- .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;}
2502
2590
  .dshic-dir-lbl{width:52px;flex:none;font-size:11.5px;color:var(--dsw-alias-label-secondary);}
2503
2591
  .dshic-dir-num{flex:1;min-width:0;height:24px;border-radius:6px;padding:0 6px;font:inherit;font-size:12px;
2504
2592
  border:1px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);}
@@ -2562,7 +2650,9 @@ body[data-ds-dark-theme] .dshic-inline-input{border-color:var(--dsw-alias-border
2562
2650
  .dshic-canvas{flex:1;position:relative;overflow:hidden;background-color:var(--dsw-alias-bg-base);
2563
2651
  background-image:radial-gradient(color-mix(in srgb,var(--dsw-alias-border-l2) 55%,transparent) 1px,transparent 1px);
2564
2652
  outline:none;}
2565
- .dshic-world{position:absolute;left:0;top:0;transform-origin:0 0;will-change:transform;}
2653
+ .dshic-world{position:absolute;left:0;top:0;transform-origin:0 0;}
2654
+ /* 仅在交互中提升图层:常驻 will-change 会把整张画布变成一张巨大纹理,页面缩放/重排时先白一片 */
2655
+ .dshic-world.is-interacting{will-change:transform;}
2566
2656
  .dshic-edges{position:absolute;left:0;top:0;width:1px;height:1px;overflow:visible;pointer-events:none;}
2567
2657
  .dshic-edge{fill:none;stroke-width:2;pointer-events:none;}
2568
2658
  .dshic-edge.kind-text{stroke:var(--dsw-alias-label-secondary);}
@@ -2990,7 +3080,7 @@ body[data-ds-dark-theme] .dshic-storage-details{border-color:var(--dsw-alias-bor
2990
3080
  .dshic-canvas.is-node-dragging .dshic-media-wrap.is-running .dshic-media-fill{animation:none !important;}
2991
3081
  /* 视频 poster 生成失败:中性占位(不闪、不破图) */
2992
3082
  .dshic-media-placeholder{position:absolute;inset:0;background:var(--dshic-preview-bg);display:block;}
2993
- @keyframes dshic-sheen{0%{filter:brightness(.92)}50%{filter:brightness(1.06)}100%{filter:brightness(.92)}}
3083
+ @keyframes dshic-sheen{0%{opacity:.82}50%{opacity:1}100%{opacity:.82}}
2994
3084
  /* 操作栏(第三段,原型 .foot):固定高度 NODE_FOOT_H = 7 + 24 + 7 + 1px border-top;
2995
3085
  border-top + 上下内距保证按钮与画面之间有明显间距(不贴边),且 flex:none 不参与画面比例 */
2996
3086
  .dshic-media-foot{flex:none;height:var(--dshic-foot-h);box-sizing:border-box;display:flex;align-items:center;gap:6px;
@@ -4639,7 +4729,11 @@ body[data-ds-dark-theme] .dshic-pick-item.is-on{background:color-mix(in srgb,var
4639
4729
  // 服务端不可用 → 回退 IDB;服务端可用但无记录 → 迁移期只查一次(miss 后永不再查)
4640
4730
  if (serverOk && idbMiss.has(id)) return null;
4641
4731
  const local = await idbGetRaw(id).catch(() => null);
4642
- 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
+ }
4643
4737
  if (serverOk) idbMiss.add(id);
4644
4738
  return null;
4645
4739
  }
@@ -4654,6 +4748,7 @@ body[data-ds-dark-theme] .dshic-pick-item.is-on{background:color-mix(in srgb,var
4654
4748
  } catch { return null; }
4655
4749
  }
4656
4750
  async function idbDelete(id) {
4751
+ if (idbDeleteImpl) return idbDeleteImpl(id);
4657
4752
  assetNameCache.delete(id);
4658
4753
  // 释放该资产的 objectURL:否则 Blob(视频可达几十 MB)会被 URL 钉在内存里直到刷新
4659
4754
  if (assetUrlCache.has(id)) {
@@ -4734,7 +4829,9 @@ body[data-ds-dark-theme] .dshic-pick-item.is-on{background:color-mix(in srgb,var
4734
4829
  } catch { /* 忽略 */ }
4735
4830
  return out.sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0));
4736
4831
  }
4832
+ let idbAllImpl = null; // 测试注入(迁移逻辑单测)
4737
4833
  async function idbAll() {
4834
+ if (idbAllImpl) return idbAllImpl();
4738
4835
  const db = await openDb();
4739
4836
  return new Promise((resolve, reject) => {
4740
4837
  const rq = db.transaction(DB_STORE, 'readonly').objectStore(DB_STORE).getAll();
@@ -4856,6 +4953,7 @@ body[data-ds-dark-theme] .dshic-pick-item.is-on{background:color-mix(in srgb,var
4856
4953
  rec.rel = rel;
4857
4954
  rec.size = blob.size;
4858
4955
  rec.stored = 'disk';
4956
+ if (kind === 'image') { try { queueThumb(rec.id); } catch { /* 忽略 */ } }
4859
4957
  const proj = S.projects.find((p) => p.id === projectId);
4860
4958
  if (S.store === 'sqlite') {
4861
4959
  // 主路径:文件落项目文件夹 + 元数据进 canvas.db(任意浏览器/远程立即可见)
@@ -4893,36 +4991,66 @@ body[data-ds-dark-theme] .dshic-pick-item.is-on{background:color-mix(in srgb,var
4893
4991
  projectId: rec.projectId, projectName: (proj && proj.name) || rec.projectId, asset: assetIndexRecord(rec),
4894
4992
  }).catch(() => { /* 索引失败不影响资产本身 */ });
4895
4993
  }
4896
- /** 数据根目录就绪后:把浏览器 IndexedDB 里的存量 blob 资产搬到数据目录的项目文件夹
4897
- * (projects/<项目>/<类型>/<文件>),搬完从 IndexedDB 移除——**IndexedDB 只减不增,最终清零**。 */
4898
- async function autoMigrateAssetsToRoot() {
4899
- if (!S.dataRoot) return;
4900
- try {
4901
- const all = await idbAll();
4902
- const pending = all.filter((r) => r && r.blob && !r.rel && !r.sha);
4903
- if (!pending.length) return;
4904
- let done = 0;
4905
- for (const rec of pending) {
4906
- try {
4907
- const mime = rec.blob.type || rec.mime || (rec.kind === 'image' ? 'image/png' : 'video/mp4');
4908
- const rel = `projects/${rec.projectId}/${rec.kind}/${assetFileBase(rec)}.${extFromMime(mime)}`;
4909
- const r = await fetch('/' + NS + '/api/asset?rel=' + encodeURIComponent(rel), { method: 'POST', body: rec.blob });
4910
- if (!r.ok) continue;
4911
- const meta = { ...rec, rel, size: rec.size || rec.blob.size };
4912
- delete meta.blob;
4913
- meta.stored = 'disk';
4914
- const proj = S.projects.find((p) => p.id === rec.projectId);
4915
- await hostRpc('assetCreate', { asset: assetIndexRecord(meta), projectName: (proj && proj.name) || '' });
4916
- try { await idbDelete(rec.id); } catch { /* 本地清理失败不阻塞迁移 */ }
4917
- done++;
4918
- } catch { /* 单个失败继续下一个 */ }
4919
- }
4920
- if (done) {
4921
- toast(T('dataRootMigrated').replace('{n}', String(done)));
4922
- 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;
4923
5047
  }
4924
- } catch { /* 静默:迁移失败不影响主流程,下次设置数据根目录时再试 */ }
5048
+ })();
5049
+ return migrateBusy;
4925
5050
  }
5051
+ /** 兼容旧调用名(外部/测试可能引用)。 */
5052
+ function autoMigrateAssetsToRoot() { return migrateLegacyAssets('legacy-alias'); }
5053
+
4926
5054
  function useAssetUrl(id) {
4927
5055
  const [url, setUrl] = useState(null);
4928
5056
  useEffect(() => {
@@ -5016,7 +5144,12 @@ body[data-ds-dark-theme] .dshic-pick-item.is-on{background:color-mix(in srgb,var
5016
5144
  cv.width = w; cv.height = h;
5017
5145
  const ctx = cv.getContext && cv.getContext('2d');
5018
5146
  if (!ctx) return finish(null);
5019
- ctx.drawImage(v, 0, 0, w, h);
5147
+ const k2 = Math.min(1, 512 / Math.max(w, h));
5148
+ const w2 = Math.max(1, Math.round(w * k2));
5149
+ const h2 = Math.max(1, Math.round(h * k2));
5150
+ cv.width = w2; cv.height = h2;
5151
+ ctx.fillStyle = '#14181f'; ctx.fillRect(0, 0, w2, h2);
5152
+ ctx.drawImage(v, 0, 0, w2, h2);
5020
5153
  finish(cv.toDataURL('image/jpeg', 0.72));
5021
5154
  } catch { finish(null); }
5022
5155
  };
@@ -5093,6 +5226,119 @@ body[data-ds-dark-theme] .dshic-pick-item.is-on{background:color-mix(in srgb,var
5093
5226
  return { poster, state };
5094
5227
  }
5095
5228
 
5229
+ // ============================================================
5230
+ // 节点缩略图(系统派生文件,**不是资产**)
5231
+ // <rel>.thumb.jpg:最长边 512、JPEG 0.72;不进 assets.json / 资产库 / 导入扫描。
5232
+ // 节点只挂它(几十 KB);点开看原图 / 点播放才按需加载原文件(看哪个取哪个)。
5233
+ // ============================================================
5234
+ const THUMB_MAX = 512, THUMB_QUALITY = 0.72;
5235
+ const thumbCache = new Map();
5236
+ const thumbInflight = new Map();
5237
+ const thumbFailed = new Set(); // 负缓存:每个资产每会话只尝试一次
5238
+ const thumbUrlForRel = (rel) => (rel ? fileUrlFor(rel + '.thumb.jpg') : null);
5239
+ function thumbIsFailed(assetId) { return !!assetId && thumbFailed.has(assetId); }
5240
+ async function probeHeadUrl(url) {
5241
+ if (!url) return false;
5242
+ try { const r = await fetch(url, { method: 'HEAD', cache: 'no-store' }); return !!(r && r.ok); } catch { return false; }
5243
+ }
5244
+ async function makeThumbBlob(blob) {
5245
+ if (!blob) return null;
5246
+ const draw = (src, w0, h0) => {
5247
+ const k = Math.min(1, THUMB_MAX / Math.max(1, Math.max(w0, h0)));
5248
+ const w = Math.max(1, Math.round(w0 * k));
5249
+ const h = Math.max(1, Math.round(h0 * k));
5250
+ const cv = document.createElement('canvas');
5251
+ cv.width = w; cv.height = h;
5252
+ const g = cv.getContext && cv.getContext('2d');
5253
+ if (!g) return Promise.resolve(null);
5254
+ g.fillStyle = '#14181f'; g.fillRect(0, 0, w, h);
5255
+ g.drawImage(src, 0, 0, w, h);
5256
+ return new Promise((res) => { try { cv.toBlob((b) => res(b || null), 'image/jpeg', THUMB_QUALITY); } catch { res(null); } });
5257
+ };
5258
+ // createImageBitmap 在部分环境(软件渲染 / 异常 GPU)会**挂起而不抛错**:
5259
+ // 必须加超时竞速,否则 thumbInflight 永不清除、节点永远停在占位("一直加载")。
5260
+ try {
5261
+ if (typeof createImageBitmap === 'function') {
5262
+ const bmp = await Promise.race([
5263
+ createImageBitmap(blob),
5264
+ new Promise((_, rej) => setTimeout(() => rej(new Error('bitmap timeout')), 1500)),
5265
+ ]);
5266
+ try { return await draw(bmp, bmp.width || 1, bmp.height || 1); }
5267
+ finally { try { if (bmp.close) bmp.close(); } catch { /* 忽略 */ } }
5268
+ }
5269
+ } catch { /* 超时/不支持 → 走 <img> 回退 */ }
5270
+ const url = URL.createObjectURL(blob);
5271
+ try {
5272
+ const img = await new Promise((res, rej) => {
5273
+ const im = new Image();
5274
+ im.onload = () => res(im); im.onerror = () => rej(new Error('decode failed')); im.src = url;
5275
+ });
5276
+ return await draw(img, img.naturalWidth || 1, img.naturalHeight || 1);
5277
+ } catch { return null; } finally { try { URL.revokeObjectURL(url); } catch { /* 忽略 */ } }
5278
+ }
5279
+ async function ensureThumbForAsset(assetId) {
5280
+ if (!assetId) return null;
5281
+ if (thumbCache.has(assetId)) return thumbCache.get(assetId);
5282
+ if (thumbInflight.has(assetId)) return thumbInflight.get(assetId);
5283
+ const task = (async () => {
5284
+ const rec = await idbGet(assetId).catch(() => null);
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
+ }
5297
+ const url = thumbUrlForRel(rec.rel);
5298
+ if (await probeHeadUrl(url)) { thumbCache.set(assetId, url); return url; }
5299
+ if (thumbFailed.has(assetId)) return null;
5300
+ const blob = await assetBlob(assetId).catch(() => null);
5301
+ const thumb = await makeThumbBlob(blob);
5302
+ if (!thumb) { thumbFailed.add(assetId); return null; }
5303
+ try {
5304
+ const r = await fetch('/' + NS + '/api/thumb?rel=' + encodeURIComponent(rec.rel), {
5305
+ method: 'POST', headers: { 'content-type': 'image/jpeg' }, body: thumb,
5306
+ });
5307
+ if (r && r.ok) { thumbCache.set(assetId, url); return url; }
5308
+ } catch { /* 落盘失败 → 内存兜底 */ }
5309
+ const objUrl = URL.createObjectURL(thumb);
5310
+ thumbCache.set(assetId, objUrl);
5311
+ return objUrl;
5312
+ })();
5313
+ thumbInflight.set(assetId, task);
5314
+ task.then(() => thumbInflight.delete(assetId), () => thumbInflight.delete(assetId));
5315
+ return task;
5316
+ }
5317
+ function useNodeThumb(assetId) {
5318
+ const [url, setUrl] = useState(null);
5319
+ useEffect(() => {
5320
+ if (!assetId) { setUrl(null); return undefined; }
5321
+ let ok = true;
5322
+ ensureThumbForAsset(assetId).then((u) => { if (ok) setUrl(u); }).catch(() => { if (ok) setUrl(null); });
5323
+ return () => { ok = false; };
5324
+ }, [assetId]);
5325
+ return url;
5326
+ }
5327
+ let thumbQueueActive = 0;
5328
+ const thumbQueue = [];
5329
+ function queueThumb(assetId) {
5330
+ if (!assetId) return;
5331
+ thumbQueue.push(assetId);
5332
+ const pump = () => {
5333
+ while (thumbQueueActive < 2 && thumbQueue.length) {
5334
+ const id = thumbQueue.shift();
5335
+ thumbQueueActive++;
5336
+ ensureThumbForAsset(id).catch(() => null).finally(() => { thumbQueueActive--; pump(); });
5337
+ }
5338
+ };
5339
+ pump();
5340
+ }
5341
+
5096
5342
  // ============================================================
5097
5343
  // 生成能力:演示模式(本地合成)+ 自定义 API
5098
5344
  // ============================================================
@@ -7406,12 +7652,15 @@ body[data-ds-dark-theme] .dshic-pick-item.is-on{background:color-mix(in srgb,var
7406
7652
  }
7407
7653
  /** 从 Host 拉取数据根目录配置(Host 旧版无此 RPC → 标记不支持)。 */
7408
7654
  async function refreshDataRoot() {
7655
+ const prevRoot = S.dataRoot;
7409
7656
  try {
7410
7657
  const r = await hostRpc('dataRootGet');
7411
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)); });
7412
7659
  } catch (e) {
7413
7660
  update((s) => { s.dataRoot = null; s.store = null; s.dataRootSupported = !/未知操作/.test(String((e && e.message) || e)); });
7414
7661
  }
7662
+ // 数据根从「空」变为「有」:自动补跑一次性迁移(此前因未配置被跳过)
7663
+ if (S.dataRoot && !prevRoot) migrateLegacyAssets('root-ready').catch(() => { /* 静默 */ });
7415
7664
  }
7416
7665
  // ---------- 对话 ↔ 画布同步:轮询 Host 项目索引 + 跳转请求 ----------
7417
7666
  let hostSyncStopped = false;
@@ -8404,6 +8653,7 @@ body[data-ds-dark-theme] .dshic-pick-item.is-on{background:color-mix(in srgb,var
8404
8653
  rec.rel = rel;
8405
8654
  rec.size = blob.size;
8406
8655
  rec.stored = 'disk';
8656
+ if (kind === 'image') { try { queueThumb(rec.id); } catch { /* 忽略 */ } }
8407
8657
  const proj = S.projects.find((p) => p.id === projectId);
8408
8658
  if (S.store === 'sqlite') {
8409
8659
  const saved = await hostRpc('assetCreate', { asset: assetIndexRecord(rec), projectName: (proj && proj.name) || '' });
@@ -9837,6 +10087,7 @@ body[data-ds-dark-theme] .dshic-pick-item.is-on{background:color-mix(in srgb,var
9837
10087
  }
9838
10088
 
9839
10089
  function onCanvasPointerDown(e) {
10090
+ try { if (worldEl) worldEl.classList.add('is-interacting'); } catch { /* 忽略 */ }
9840
10091
  const target = e.target;
9841
10092
  // 生成面板是浮层(不在 world 内):面板内的点击不能触发画布平移/清空选择
9842
10093
  if (target.closest && target.closest('.dshic-composer')) return;
@@ -10061,6 +10312,7 @@ body[data-ds-dark-theme] .dshic-pick-item.is-on{background:color-mix(in srgb,var
10061
10312
  }
10062
10313
  }
10063
10314
  function onCanvasPointerUp(e) {
10315
+ try { if (worldEl) worldEl.classList.remove('is-interacting'); } catch { /* 忽略 */ }
10064
10316
  if (!drag.mode) return;
10065
10317
  const mode = drag.mode;
10066
10318
  // 收尾:先补上最后一帧拖拽(rAF 尚未执行的 pointermove),清掉视觉 class 与 Alt 标记
@@ -11641,7 +11893,9 @@ body[data-ds-dark-theme] .dshic-pick-item.is-on{background:color-mix(in srgb,var
11641
11893
  })();
11642
11894
  return () => { ok = false; };
11643
11895
  }, [n.id, S.assetsTick, S.store]);
11644
- const assetUrlV = useAssetUrl(n.data.assetId);
11896
+ // 节点只挂派生缩略图(几十 KB);原图/原视频仅在「点开看 / 点播放」时按需取,且一次只取一个
11897
+ const thumbUrlV = useNodeThumb(kind === 'image' ? n.data.assetId : null);
11898
+ const [playUrl, setPlayUrl] = useState(null); // 视频原文件 URL:只在播放期间存在
11645
11899
  // 视频默认只画静态 poster;<video> 只在播放时挂载(见下方播放状态机)
11646
11900
  const videoPoster = useVideoPoster(kind === 'video' ? n.data.assetId : null);
11647
11901
  const posterUrl = videoPoster.poster;
@@ -11667,7 +11921,7 @@ body[data-ds-dark-theme] .dshic-pick-item.is-on{background:color-mix(in srgb,var
11667
11921
  const refEdges = S.graph.edges.filter((x) => x.toNode === n.id && x.toPort === 'ref');
11668
11922
  const firstRefSrc = refEdges.length ? S.graph.nodes[refEdges[0].fromNode] : null;
11669
11923
  const firstRefAid = firstRefSrc ? servedAssetId(firstRefSrc, refEdges[0].fromPort) : null;
11670
- const refThumbUrl = useAssetUrl(firstRefAid);
11924
+ const refThumbUrl = useNodeThumb(firstRefAid);
11671
11925
  const running = n.data.status === 'running';
11672
11926
  const done = !!n.data.assetId;
11673
11927
  const statusLabel = running ? T('genRunning') : done ? T('generated') : (n.data.status === 'error' ? T('genFailed') : '');
@@ -11691,7 +11945,8 @@ body[data-ds-dark-theme] .dshic-pick-item.is-on{background:color-mix(in srgb,var
11691
11945
  normalizeMediaNodeSize(nd);
11692
11946
  });
11693
11947
  };
11694
- const imgShown = done && !!assetUrlV && kind === 'image' && mediaState !== 'error';
11948
+ const imgShown = done && !!thumbUrlV && kind === 'image' && mediaState !== 'error';
11949
+ const imgMissing = done && kind === 'image' && !thumbUrlV && thumbIsFailed(n.data.assetId);
11695
11950
  const videoPlaying = done && kind === 'video' && playing;
11696
11951
  // 视频:有 poster 即视为就绪(mediaState ready 由 poster 的 onLoad 设置,不再依赖 <video> 元数据)
11697
11952
  const posterLoading = done && kind === 'video' && !posterUrl && (posterState === 'idle' || posterState === 'loading');
@@ -11721,7 +11976,14 @@ body[data-ds-dark-theme] .dshic-pick-item.is-on{background:color-mix(in srgb,var
11721
11976
  const v = videoRef.current;
11722
11977
  if (!v) return;
11723
11978
  try { const p = v.play(); if (p && typeof p.catch === 'function') p.catch(() => { /* 保留播放键 */ }); } catch { /* 忽略 */ }
11724
- }, [playing, n.data.assetId]);
11979
+ }, [playing, n.data.assetId, playUrl]);
11980
+ // 播放时才去取原视频 URL(懒加载:看哪个取哪个;暂停/结束立即释放)
11981
+ useEffect(() => {
11982
+ if (!videoPlaying) { setPlayUrl(null); return undefined; }
11983
+ let ok = true;
11984
+ assetUrl(n.data.assetId).then((u) => { if (ok) setPlayUrl(u); }).catch(() => { if (ok) setPlayUrl(null); });
11985
+ return () => { ok = false; };
11986
+ }, [videoPlaying, n.data.assetId]);
11725
11987
  /** 进度条拖动 seek(控件条只在播放中出现,拖动期间不打断播放)。 */
11726
11988
  const seekVideo = (t) => {
11727
11989
  const v = videoRef.current;
@@ -11781,19 +12043,39 @@ body[data-ds-dark-theme] .dshic-pick-item.is-on{background:color-mix(in srgb,var
11781
12043
  posterFailed
11782
12044
  ? h('div', { key: 'poster-fail', className: 'dshic-media-placeholder', 'aria-hidden': 'true' })
11783
12045
  : null,
12046
+ // 原文件缺失(404):稳定占位 + 重试,不再每次渲染都请求
12047
+ imgMissing
12048
+ ? h('div', { key: 'missing', className: 'dshic-media-placeholder is-missing' },
12049
+ h('span', null, T('mediaMissing')),
12050
+ h('button', {
12051
+ type: 'button', className: 'dshic-mini is-ghost',
12052
+ onPointerDown: (e) => e.stopPropagation(),
12053
+ onClick: (e) => {
12054
+ e.stopPropagation();
12055
+ thumbFailed.delete(n.data.assetId);
12056
+ setMediaState('idle');
12057
+ update((s2) => { s2.assetsTick++; });
12058
+ },
12059
+ }, T('retry')))
12060
+ : null,
11784
12061
  imgShown
11785
12062
  ? h('img', {
11786
- key: 'img', className: 'dshic-media-fill', src: assetUrlV, draggable: false,
12063
+ key: 'img', className: 'dshic-media-fill', src: thumbUrlV, draggable: false, decoding: 'async',
11787
12064
  onLoad: (e) => { setMediaState('ready'); const el = e && e.currentTarget; recordMediaDims(el && el.naturalWidth, el && el.naturalHeight); },
11788
12065
  onError: () => setMediaState('error'),
11789
- onDoubleClick: (e) => { e.stopPropagation(); openLightbox('image', assetUrlV, n.data.label); },
12066
+ onDoubleClick: async (e) => {
12067
+ e.stopPropagation();
12068
+ // 双击放大:**此时才**取原图(单张、按需;关闭灯箱即卸载)
12069
+ const u = await assetUrl(n.data.assetId).catch(() => null);
12070
+ if (u) openLightbox('image', u, n.data.label);
12071
+ },
11790
12072
  })
11791
12073
  : null,
11792
12074
  // 播放时才挂真实 <video>(poster 作为 poster 属性兜底,挂载瞬间也不闪)
11793
12075
  videoPlaying
11794
12076
  ? h('video', {
11795
- key: 'vid', ref: videoRef, className: 'dshic-media-fill', src: assetUrlV || undefined,
11796
- poster: posterUrl || undefined, preload: 'metadata', autoPlay: true,
12077
+ key: 'vid', ref: videoRef, className: 'dshic-media-fill', src: playUrl || undefined,
12078
+ poster: posterUrl || undefined, preload: 'auto', autoPlay: true,
11797
12079
  // 视频控件保留 stopPropagation(拖动/选择不被视频吃掉),单击画面打开生成面板;
11798
12080
  // 双击画面 = 播放/暂停(原型行为);播放键自身另行 stopPropagation。
11799
12081
  onPointerDown: (e) => { e.stopPropagation(); },
@@ -11866,9 +12148,12 @@ body[data-ds-dark-theme] .dshic-pick-item.is-on{background:color-mix(in srgb,var
11866
12148
  done
11867
12149
  ? h('div', { key: 'acts', className: 'dshic-media-foot', 'data-node-foot': '1' },
11868
12150
  h('button', {
11869
- type: 'button', className: 'dshic-mini is-ghost', disabled: !assetUrlV,
12151
+ type: 'button', className: 'dshic-mini is-ghost', disabled: !done,
11870
12152
  onPointerDown: (e) => e.stopPropagation(),
11871
- onClick: async () => downloadBlobFromUrl(assetUrlV, await downloadNameForAsset(n.data.assetId, (kind === 'image' ? 'image' : 'video') + '-' + n.id.slice(-5), kind)),
12153
+ onClick: async () => {
12154
+ const u = await assetUrl(n.data.assetId).catch(() => null);
12155
+ if (u) downloadBlobFromUrl(u, await downloadNameForAsset(n.data.assetId, (kind === 'image' ? 'image' : 'video') + '-' + n.id.slice(-5), kind));
12156
+ },
11872
12157
  }, Ic('download', 11), T('download')),
11873
12158
  h('button', {
11874
12159
  type: 'button', className: 'dshic-mini is-ghost', disabled: running,
@@ -13257,6 +13542,26 @@ body[data-ds-dark-theme] .dshic-pick-item.is-on{background:color-mix(in srgb,var
13257
13542
  MODEL_INDEX = (j && j.models) || [];
13258
13543
  return MODEL_INDEX;
13259
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
+ }
13260
13565
  /** 按需加载一个模型并注册进引擎;返回 asset。 */
13261
13566
  async function loadModel(id) {
13262
13567
  const E2 = directorEngine();
@@ -13265,7 +13570,8 @@ body[data-ds-dark-theme] .dshic-pick-item.is-on{background:color-mix(in srgb,var
13265
13570
  if (cached) return cached;
13266
13571
  const r = await fetch(pluginAssetUrl(MODEL_DIR + id + '.json'));
13267
13572
  if (!r.ok) throw new Error('模型加载失败 ' + id);
13268
- const asset = E2.decodeModelPack(await r.json());
13573
+ const clips = await loadActionClips().catch(() => []);
13574
+ const asset = E2.decodeModelPack(await r.json(), clips);
13269
13575
  MODEL_CACHE.add(id);
13270
13576
  return E2.registerGltfAsset(id, asset);
13271
13577
  }
@@ -13280,6 +13586,7 @@ body[data-ds-dark-theme] .dshic-pick-item.is-on{background:color-mix(in srgb,var
13280
13586
  idle: { zh: '待机', en: 'Idle' }, run: { zh: '奔跑', en: 'Run' },
13281
13587
  walk: { zh: '行走', en: 'Walk' }, sneak_pose: { zh: '潜行', en: 'Sneak' },
13282
13588
  jump: { zh: '跳跃', en: 'Jump' }, dance: { zh: '舞蹈', en: 'Dance' },
13589
+ wave: { zh: '挥手', en: 'Wave' },
13283
13590
  death: { zh: '倒地', en: 'Death' }, hit: { zh: '受击', en: 'Hit' },
13284
13591
  };
13285
13592
  const clipLabel = (name) => {
@@ -13393,6 +13700,9 @@ const DIRECTOR_KEY_HELP = [
13393
13700
  // 任何「不影响 scene 但影响画面」的变化(切模式/选中/悬停/显示开关)都必须 bumpView(),
13394
13701
  // 渲染 effect 只认这个 revision → 不再出现「切了模式要等点击画面才刷新」。
13395
13702
  const canvasRef = useRef(null);
13703
+ const dirWheelHandlerRef = useRef(null); // 最新的滚轮处理器(原生监听转发用)
13704
+ const dirViewportElRef = useRef(null);
13705
+ const dirWheelListenerRef = useRef(null);
13396
13706
  const pipRef = useRef(null);
13397
13707
  const pipGlRef = useRef(null);
13398
13708
  const glRef = useRef(null);
@@ -13405,6 +13715,9 @@ const DIRECTOR_KEY_HELP = [
13405
13715
  durRef.current = Math.max(0.5,
13406
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]));
13407
13717
  const dragRef = useRef(null);
13718
+ const [bvhOpen, setBvhOpen] = useState(false);
13719
+ const [customActionOpen, setCustomActionOpen] = useState(false);
13720
+ const [bvhText, setBvhText] = useState('');
13408
13721
  const fileRef = useRef(null);
13409
13722
  const recRef = useRef(null);
13410
13723
  const undoRef = useRef([]);
@@ -14032,7 +14345,7 @@ const DIRECTOR_KEY_HELP = [
14032
14345
  patch((n) => {
14033
14346
  const id = 'obj_' + Date.now().toString(36);
14034
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 };
14035
- 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 || '' });
14036
14349
  else if (kind === 'model') Object.assign(base, { assetId: p.id, h: p.height || 1.7, clip: '', animTime: 0, color: p.color || '' });
14037
14350
  else if (kind === 'actor') Object.assign(base, { preset: p.id, h: 1, pose: 'stand' });
14038
14351
  else if (kind === 'building') Object.assign(base, { preset: p.id, s: 1 });
@@ -14910,7 +15223,23 @@ const DIRECTOR_KEY_HELP = [
14910
15223
  h('main', { className: 'dshic-dir-stage' },
14911
15224
  h('div', {
14912
15225
  className: cls('dshic-dir-viewport', S.directorPendingPlace && 'is-placing', S.directorDragging && 'is-dragging'), 'data-role': 'dir-viewport',
14913
- onPointerDown: onDown, onPointerMove: onMove, onPointerUp: onUp, onPointerLeave: onUp, onWheel: onWheel,
15226
+ onPointerDown: onDown, onPointerMove: onMove, onPointerUp: onUp, onPointerLeave: onUp,
15227
+ // 原生 non-passive 滚轮:React 的 onWheel 无法 preventDefault(页面会一起缩放)
15228
+ ref: (el) => {
15229
+ dirWheelHandlerRef.current = onWheel;
15230
+ const prev = dirViewportElRef.current;
15231
+ if (prev && prev !== el && dirWheelListenerRef.current) {
15232
+ try { prev.removeEventListener('wheel', dirWheelListenerRef.current); } catch { /* 忽略 */ }
15233
+ }
15234
+ dirViewportElRef.current = el;
15235
+ if (!dirWheelListenerRef.current) {
15236
+ dirWheelListenerRef.current = (ev) => { const fn = dirWheelHandlerRef.current; if (fn) fn(ev); };
15237
+ }
15238
+ if (el) {
15239
+ try { el.removeEventListener('wheel', dirWheelListenerRef.current); } catch { /* 忽略 */ }
15240
+ try { el.addEventListener('wheel', dirWheelListenerRef.current, { passive: false }); } catch { /* 忽略 */ }
15241
+ }
15242
+ },
14914
15243
  onContextMenu: (e2) => { if (e2 && e2.preventDefault) e2.preventDefault(); },
14915
15244
  },
14916
15245
  h('canvas', { ref: canvasRef, className: 'dshic-dir-canvas' }),
@@ -15087,6 +15416,193 @@ const DIRECTOR_KEY_HELP = [
15087
15416
  value: String(((((scene.env || {}).fog) || {}).far !== undefined ? ((scene.env || {}).fog).far : 40)),
15088
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) }) }); }) })),
15089
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
+ })() : []),
15090
15606
  // ── 景深 ──
15091
15607
  ...(!sel && previzMode !== 'render' ? [
15092
15608
  envRow(T('dirDof'), h('label', null, h('input', { type: 'checkbox', 'data-action': 'dir-dof-on', checked: !!(((scene.env || {}).dof) || {}).enabled,
@@ -15139,7 +15655,7 @@ const DIRECTOR_KEY_HELP = [
15139
15655
  });
15140
15656
  setMsg({ kind: 'ok', text: T('dirPathPointAdded') });
15141
15657
  },
15142
- }, '+ ' + T('dirPathAddPoint')),
15658
+ }, T('dirPathAddPoint')),
15143
15659
  h('button', {
15144
15660
  type: 'button', className: 'dshic-dir-mini', 'data-action': 'dir-path-draw',
15145
15661
  onClick: () => { S.directorPathPick = !S.directorPathPick; setMsg({ kind: 'ok', text: T('dirPathDrawHint') }); bumpView(); },
@@ -15147,7 +15663,7 @@ const DIRECTOR_KEY_HELP = [
15147
15663
  h('button', {
15148
15664
  type: 'button', className: 'dshic-dir-mini is-danger', 'data-action': 'dir-path-clear',
15149
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') }); },
15150
- }, '✖ ' + T('dirPathClear')),
15666
+ }, T('dirPathClear')),
15151
15667
  h('button', {
15152
15668
  type: 'button', className: 'dshic-dir-mini is-danger', 'data-action': 'dir-path-del-point',
15153
15669
  onClick: () => {
@@ -15160,7 +15676,7 @@ const DIRECTOR_KEY_HELP = [
15160
15676
  S.directorPathSel = null;
15161
15677
  setMsg({ kind: 'ok', text: T('dirPathPointDeleted') });
15162
15678
  },
15163
- }, '- ' + T('dirPathPointDel'))),
15679
+ }, T('dirPathPointDel'))),
15164
15680
  h('div', { className: 'dshic-dir-time' }, T('dirPathCount') + ' ' + ((sel.path || []).length) + ' · ' + T('dirPathHint')),
15165
15681
  // ── 移动驱动:让动作跟着路径速度走(避免滑步)。
15166
15682
  // 动作只在一处选:跟随上方「动作」下拉;这里只调步频参数或改用自建动作。──
@@ -15250,10 +15766,6 @@ const DIRECTOR_KEY_HELP = [
15250
15766
  type: 'button', className: 'dshic-dir-mini', 'data-action': 'dir-obj-color-reset',
15251
15767
  onClick: () => patch((n2) => { const o2 = n2.objects.find((x) => x.id === sel.id); if (o2) o2.color = ''; }),
15252
15768
  }, T('dirReset'))),
15253
- sel.kind === 'model' ? h('div', { className: 'dshic-dir-row' }, h('span', null, T('dirAction')), h('select', {
15254
- 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 || '')),
15255
- 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; } }; }),
15256
- }, [].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,
15257
15769
  h('div', { className: 'dshic-dir-btnrow' },
15258
15770
  h('button', { type: 'button', className: 'dshic-dir-mini', onClick: dupSel }, T('dirDup')),
15259
15771
  h('button', { type: 'button', className: 'dshic-dir-mini is-danger', onClick: delSel }, T('dirDel')))) : null,
@@ -15263,7 +15775,7 @@ const DIRECTOR_KEY_HELP = [
15263
15775
  if (!bones.length) return null;
15264
15776
  const poseMap = sel.bonePose || {};
15265
15777
  const activeBone = Number(sel.boneIndex) >= 0 ? Number(sel.boneIndex) : -1;
15266
- return h('div', null,
15778
+ return customActionOpen ? h('div', null,
15267
15779
  h('div', { className: 'dshic-dir-subhead' }, T('dirSkeleton') + ' (' + bones.length + ')'),
15268
15780
  h('div', { className: 'dshic-dir-row' },
15269
15781
  h('span', null, T('dirBone')),
@@ -15288,23 +15800,6 @@ const DIRECTOR_KEY_HELP = [
15288
15800
  o2.bonePose[activeBone] = cur;
15289
15801
  }),
15290
15802
  })))),
15291
- // ── 基础姿态(骨架级,方向式)──
15292
- h('div', { className: 'dshic-dir-subhead' }, T('dirPoseLib')),
15293
- h('div', { className: 'dshic-dir-posegrid' },
15294
- E.BONE_POSES.filter((p) => p.id !== 'none').map((p) => h('button', {
15295
- key: p.id, type: 'button',
15296
- className: cls('dshic-dir-posebtn', sel.poseId === p.id && 'is-on'),
15297
- 'data-pose': p.id,
15298
- onClick: () => {
15299
- patch((n) => {
15300
- const o2 = n.objects.find((x) => x.id === sel.id);
15301
- if (!o2) return;
15302
- o2.bonePose = {}; // 姿态由 poseId 在蒙皮时按当前动画姿势求解
15303
- o2.poseId = p.id;
15304
- });
15305
- setMsg({ kind: 'ok', text: T('dirPoseApplied') + ' · ' + (effLang() === 'en' ? p.en : p.zh) });
15306
- },
15307
- }, effLang() === 'en' ? p.en : p.zh))),
15308
15803
  // ── 用户自建动作:骨骼 + 姿态调好后保存,保存后出现在上方「动作」下拉 ──
15309
15804
  h('div', { className: 'dshic-dir-row' },
15310
15805
  h('input', {
@@ -15340,7 +15835,7 @@ const DIRECTOR_KEY_HELP = [
15340
15835
  const o2 = n.objects.find((x) => x.id === sel.id);
15341
15836
  if (o2) { o2.bonePose = {}; o2.poseId = null; o2.poseAnim = []; o2.poseKeys = []; o2.userActionId = null; }
15342
15837
  }),
15343
- }, T('dirPoseClear'))));
15838
+ }, T('dirPoseClear')))) : null;
15344
15839
  })() : null,
15345
15840
  ...(!sel && previzMode !== 'render' ? [
15346
15841
  h('div', { className: 'dshic-dir-subhead' }, T('dirShot')),
@@ -17128,7 +17623,7 @@ const DIRECTOR_KEY_HELP = [
17128
17623
  await refreshDataRoot();
17129
17624
  toast(T('dataRootSaved') + (S.dataRoot ? ':' + S.dataRoot : ''));
17130
17625
  if (same) { setBusy(false); return; } // 同目录重复保存:不重复迁移
17131
- autoMigrateAssetsToRoot(); // 换了目录:把本地遗留 blob 搬进新目录(v1 旧布局由 Host 侧自动迁移)
17626
+ migrateLegacyAssets('data-root'); // 换了目录:一次性迁移旧 blob(v1 旧布局由 Host 侧自动迁移)
17132
17627
  assetMem.clear();
17133
17628
  update((s) => { s.assetsTick++; });
17134
17629
  } catch (e) { toast('设置失败:' + ((e && e.message) || e)); }
@@ -17141,7 +17636,7 @@ const DIRECTOR_KEY_HELP = [
17141
17636
  setVal(S.dataRoot || root);
17142
17637
  toast(T('dataRootSaved') + ':' + S.dataRoot);
17143
17638
  if (same) return; // 同目录:不重复迁移
17144
- autoMigrateAssetsToRoot(); // 换了目录:把本地遗留 blob 搬进新目录
17639
+ migrateLegacyAssets('data-root'); // 换了目录:一次性迁移旧 blob
17145
17640
  assetMem.clear();
17146
17641
  update((s) => { s.assetsTick++; });
17147
17642
  };
@@ -18169,7 +18664,7 @@ const DIRECTOR_KEY_HELP = [
18169
18664
  // 检查更新(Host RPC;Host 旧版未带此方法时静默)
18170
18665
  checkUpdate(true);
18171
18666
  // 数据根目录(Host 旧版静默降级为 IndexedDB 模式);就绪后把本地遗留 blob 搬进服务端(v1 旧布局由 Host 侧自动迁移)
18172
- refreshDataRoot().then(() => autoMigrateAssetsToRoot()).catch(() => { /* 静默 */ });
18667
+ refreshDataRoot().then(() => migrateLegacyAssets('boot')).catch(() => { /* 静默 */ });
18173
18668
  // 对话 ↔ 画布同步(启动一次 + 3s 轮询;旧 Host 自动停轮询)
18174
18669
  hostSyncTick().then(() => pushAllProjectsToHost());
18175
18670
  const hostSyncTimer = setInterval(() => hostSyncTick(), 3000);
@@ -18294,6 +18789,16 @@ const DIRECTOR_KEY_HELP = [
18294
18789
  setCanvasElForTest: (el) => { canvasEl = el; },
18295
18790
  edgePath,
18296
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
+ };
18297
18802
  // 排版纯函数/常量出口:预览区比例、节点外框公式、等比缩放、面板宽度(供 scripts/test-node-layout.mjs 断言)
18298
18803
  window.__DSH_IC_LAYOUT__ = {
18299
18804
  NODE_W, NODE_H, NODE_HDR_H, NODE_FOOT_H, MEDIA_PREVIEW_SHORT, MEDIA_MAX_LONG,