@ninkch/dsh-infinite-canvas 0.17.8 → 0.17.10

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/README.md CHANGED
@@ -17,11 +17,12 @@ Everything runs locally in your Harness. Bring your own generation APIs, or use
17
17
  ```mermaid
18
18
  flowchart LR
19
19
  A[Script node<br>AI screenplay] --> B[Storyboard node<br>AI shot list]
20
- B --> C[Expand to canvas<br>1 shot = 1 node group]
21
- C --> D[Image gen<br>multi-reference]
22
- D --> E[Video gen<br>first frame + refs]
23
- E --> F[Final cut<br>transitions · subtitles · voices]
24
- F --> G[Export<br>WebM preview / FilmCraft MP4]
20
+ B --> C[Expand to canvas]
21
+ C --> D[Multi-shot video gen<br>all shots in ONE prompt]
22
+ D --> E[Final cut<br>transitions · subtitles · voices]
23
+ E --> F[Export<br>WebM preview / FilmCraft MP4]
24
+ A --> G[Reference image gen<br>characters · scenes · props]
25
+ G --> D
25
26
  ```
26
27
 
27
28
  ## Highlights
@@ -33,13 +34,16 @@ A per-project **series bible** (style baseline + character/scene/prop cards) is
33
34
  Reference images connect as an ordered, visible list — `[图1] 角色:Lin Ning`, `[图2] 场景:The Old House` — and that exact order is what reaches the model. Multi-character frames stay unambiguous, and Volcano Engine **Seedance**'s native `[图N]` convention is supported end to end (first frame, up to 4 reference images, official task API).
34
35
 
35
36
  ### From shots to a watchable cut
36
- The **Final Cut node** stitches shot videos in order — crossfade or black transitions, burned-in subtitles, original audio and TTS voices mixed in — and exports a preview cut instantly. For real post-production, one click writes a **FCP7 XML timeline + SRT** to disk and hands the render to [ArtCraft](#artcraft-bridge) (FilmCraft) for a true MP4.
37
+ **One storyboard = one multi-shot video generation.** Expanding a storyboard wires it to a single video node: every shot (order + per-shot duration) is composed into one multi-shot prompt and your video model renders the whole clip in one call — the model makes the hard cuts, no per-shot stitching seams. The **Final Cut node** then stitches segments in order — crossfade or black transitions, burned-in subtitles, original audio and TTS voices mixed in — and exports a preview cut instantly. For real post-production, one click writes a **FCP7 XML timeline + SRT** to disk and hands the render to [ArtCraft](#artcraft-bridge) (FilmCraft) for a true MP4.
37
38
 
38
39
  ### An editor-grade canvas
39
40
  Pan, zoom, marquee select, alignment guides, groups that fold, a minimap, 60-step undo/redo, per-node media history (last 8 results), mask-based inpainting, and middle-click everything — the canvas part should feel like an editor, not a chat sidebar.
40
41
 
41
42
  ### Bring your own models
42
- Text models come from Harness itself (no extra keys). Image, video and TTS backends are **multi-config** — define as many endpoints as you want, mark a default, and pick per node. No backend configured? Demo mode synthesizes placeholder images and animated videos locally so the entire pipeline is testable offline.
43
+ Text models come from Harness itself (no extra keys, no per-plugin parameters). Image, video and TTS backends are **multi-config** — define as many endpoints as you want, mark a default, and pick per node. Every gateway call is relayed through the local Harness host process, so **any vendor works** (no CORS headaches). No backend configured? Demo mode synthesizes placeholder images and animated videos locally so the entire pipeline is testable offline.
44
+
45
+ ### A portable data root
46
+ Set a **data root folder** once and everything lands there, organized by **project name**: nodes and the series bible in `project.json`, the asset index in `assets.json`, media under `image/ video/ audio/ text/`, and Craft post-production under `craft/`. Move to a new machine by copying the folder and clicking **Import from data root**.
43
47
 
44
48
  ## Quick start
45
49
 
@@ -57,28 +61,29 @@ Text models come from Harness itself (no extra keys). Image, video and TTS backe
57
61
  |---|---|---|
58
62
  | Text | Harness LLM service | Script & storyboard writers; pick any configured model, or override per node |
59
63
  | Image | OpenAI-compatible `/images/generations`, multi-reference via `/images/edits` | Size (1K/2K/4K) × ratio; mask inpainting via `edits` + PNG mask |
60
- | Video | Generic submit+poll, or **Volcano Seedance official tasks** | duration 4–10s, 480P/720P/1080P, ratio, first frame, up to 4 reference images |
64
+ | Video | Generic submit+poll, or **Volcano Seedance official tasks** | duration up to 15 s, 480P/720P/1080P, ratio, first frame, up to 4 reference images |
61
65
  | TTS | OpenAI-compatible `/audio/speech` | Per-shot voices mixed into the final cut |
62
66
 
67
+ All gateway calls are relayed by the local Harness host process (no browser CORS limits) — any vendor endpoint with an OpenAI-style shape works.
68
+
63
69
  ## ArtCraft bridge
64
70
 
65
- When the companion [`dsh-artcraft`](../dsh-artcraft/README.md) plugin is installed, the canvas talks to the local **PhotoCraft / FilmCraft / EffectCraft** toolchain over same-origin HTTP: assets sync to a per-project data folder, images get headless PhotoCraft grading (single or batch), and final cuts export as FCP7 XML + SRT for FilmCraft to render as MP4. Status lights, graceful degradation and a full contract live in [`docs/artcraft-handoff.md`](./docs/artcraft-handoff.md).
71
+ When the companion [`dsh-artcraft`](../dsh-artcraft/README.md) plugin is installed, the canvas talks to the local **Craft family** (PhotoCraft, FilmCraft, EffectCraft, LightCraft, VectorCraft, DesignCraft, … — every bin the Craft suite reports, currently 12) over same-origin HTTP. Point ArtCraft's data root at the same folder as the canvas data root and all post-production lands in `projects/<project>/craft/` next to your other data: assets sync per project, images get headless PhotoCraft grading (single or batch), and final cuts export as FCP7 XML + SRT for FilmCraft to render as MP4. A status card in Settings shows one light per craft with an n/n ready summary.
66
72
 
67
73
  ## Publishing (maintainers)
68
74
 
69
- Testing funnel, beta tags, pack validation and the full release runbook live in [`docs/release.md`](docs/release.md).
75
+ The maintainer runbook (red lines, branch model, versioning) lives in [`RELEASING.md`](RELEASING.md); the operations detail in [`docs/release.md`](docs/release.md); released changes in [`CHANGELOG.md`](CHANGELOG.md) — **GitHub Release notes are written in English** and copied from the changelog.
70
76
 
71
77
  Publishing runs through **npm Trusted Publishing** (OIDC) — no npm token exists anywhere:
72
78
 
73
- - **Release flow**: create a GitHub Release (tag `v0.17.0`, …) → Actions publishes that version automatically.
74
- - **Manual flow**: repo → Actions → *publish* → Run workflow → type a version.
75
- - The `publish.yml` workflow resolves the version from the release tag (or your input) and publishes with a `provenance` badge.
79
+ - **Release flow**: dev tested & approved → merge to `main` → create a GitHub Release (tag `v0.17.9`, …) → Actions publishes that version automatically.
80
+ - The `publish.yml` workflow guards on the release commit being on `main` and the tag matching `package.json`, then publishes with a `provenance` badge.
76
81
 
77
82
  ## Data & privacy
78
83
 
79
- - Canvas documents, projects, settings and the series bible live in browser `localStorage` (`dsh-ic:*`); assets live in IndexedDB (`dsh-infinite-canvas`) — **or on disk** once a data root is set in Settings (per-project folders under the configured root, served by the plugin backend with HTTP Range support for video seeking).
84
+ - Canvas documents, projects and settings live in browser `localStorage` (`dsh-ic:*`); asset metadata lives in IndexedDB (`dsh-infinite-canvas`) while media files persist **on disk** once a data root is set (per-project folders by project name, served by the plugin backend with HTTP Range support for video seeking).
80
85
  - AI text generation runs through the Harness host process (your configured models, no extra keys).
81
- - Image/video/TTS calls go directly from the browser to the endpoints you configure — mind CORS and key handling.
86
+ - Image/video/TTS gateway calls are relayed by the local Harness host process (avoids browser CORS limits); your endpoints and keys stay local.
82
87
  - Nothing is uploaded anywhere by the plugin itself.
83
88
 
84
89
  ## Requirements & limitations
@@ -35,7 +35,12 @@ description: 用无限画布(Infinite Canvas)做短剧/视频创作时使用
35
35
  6. **删除是破坏性操作。** 先 `canvas_list_projects` 确认 id,且仅在用户明确要求时执行
36
36
  (`canvas_delete_node` / `canvas_delete_project`)。
37
37
  7. **完成后让用户看到结果**:调用 `canvas_open({ projectId })`,画布会在数秒内自动跳转。
38
- 8. **汇报固定要素**:项目名 + 项目 id + 节点名/id + 成稿摘要(若已生成);有错误如实说明。
38
+ 8. **一致性告警必须闭环。** 分镜节点生成/更新后,`canvas_node_status` 会返回 `warnings`——
39
+ 分镜中出现但**未在设定集登记**或**已登记但描述为空**的实体清单(含画面/动作文本里的 @提及)。
40
+ 只要 `warnings` 非空:**逐条** `canvas_update_bible` 补全(名称与告警**逐字一致**、描述写实可生成),
41
+ 再生成参考图;**绝不忽略、绝不跳过、绝不只补一部分**。设定集更新后告警会自动重算
42
+ (几秒内随文档同步刷新),以最新一次 `canvas_node_status` 为准。
43
+ 9. **汇报固定要素**:项目名 + 项目 id + 节点名/id + 成稿摘要(若已生成);有错误如实说明。
39
44
 
40
45
  ## 二、标准工作流(对话填提示词+连线+触发,生成由节点完成)
41
46
 
package/client.js CHANGED
@@ -299,6 +299,9 @@ window.__ModuleLoader__.load({
299
299
  'shotsUnit': '镜',
300
300
  'savedOk': '已存为资产',
301
301
  'warnUnregistered': '未在设定集/资产中登记',
302
+ 'warnEmptyDesc': '已登记但描述为空(待补全)',
303
+ 'catMention': '提及',
304
+ 'assetCategoriesFixed': '已按命名规范纠正 {n} 个资产的分类',
302
305
  'consistencyTitle': '一致性检查',
303
306
  'restartHint': '插件后台尚未加载最新版本:请重启一次 DeepSeek Harness 后重试(仅首次更新需要)',
304
307
  'rpcFail': '请求失败',
@@ -636,6 +639,9 @@ window.__ModuleLoader__.load({
636
639
  'shotsUnit': 'shots',
637
640
  'savedOk': 'Saved as asset',
638
641
  'warnUnregistered': 'not in bible/assets',
642
+ 'warnEmptyDesc': 'registered but description is empty (fill it in)',
643
+ 'catMention': 'Mention',
644
+ 'assetCategoriesFixed': 'Re-categorized {n} assets by naming convention',
639
645
  'consistencyTitle': 'Consistency check',
640
646
  'restartHint': 'The plugin backend is not loaded yet: restart DeepSeek Harness once and retry (first update only)',
641
647
  'rpcFail': 'Request failed',
@@ -1270,6 +1276,17 @@ window.__ModuleLoader__.load({
1270
1276
  function entityCategoryOf(kind) {
1271
1277
  return kind === 'character' ? 'character' : kind === 'scene' ? 'scene' : kind === 'prop' ? 'prop' : 'general';
1272
1278
  }
1279
+ /** 按命名规范前缀推导资产品类:角色-/场景-/道具-/剧本-/分镜- → 对应分类(无前缀返回 '')。
1280
+ * 用于 @引用未命中时(如对话侧直接写角色名)的兜底分类。 */
1281
+ function categoryFromName(name) {
1282
+ const n = String(name || '').trim();
1283
+ if (/^角色[-_//]/.test(n)) return 'character';
1284
+ if (/^场景[-_//]/.test(n)) return 'scene';
1285
+ if (/^道具[-_//]/.test(n)) return 'prop';
1286
+ if (/^剧本[-_//]/.test(n)) return 'script';
1287
+ if (/^分镜(表)?[-_//]/.test(n)) return 'storyboard';
1288
+ return '';
1289
+ }
1273
1290
  /** 在设定集里按名称找实体(角色/场景/道具),携带其链接的资产 id。 */
1274
1291
  function matchBibleEntity(name, bible) {
1275
1292
  const want = String(name || '').trim().toLowerCase();
@@ -1610,6 +1627,41 @@ window.__ModuleLoader__.load({
1610
1627
  }
1611
1628
  return { migrated: done, total: targets.length };
1612
1629
  }
1630
+ /** 启动自检:把分类为「通用」但命名符合规范前缀(角色-/场景-/道具-/剧本-/分镜-)的存量资产纠正分类。 */
1631
+ async function fixGeneralCategories() {
1632
+ try {
1633
+ const all = await idbAll();
1634
+ let fixed = 0;
1635
+ for (const rec of all) {
1636
+ if (!rec || !rec.id || (rec.kind !== 'image' && rec.kind !== 'video')) continue;
1637
+ if ((rec.category || 'general') !== 'general') continue;
1638
+ const c = categoryFromName(rec.name);
1639
+ if (!c) continue;
1640
+ const next = { ...rec, category: c };
1641
+ await idbPut(next);
1642
+ mirrorAssetIndex(next);
1643
+ fixed++;
1644
+ }
1645
+ if (fixed) {
1646
+ update((s) => { s.assetsTick++; });
1647
+ toast(T('assetCategoriesFixed').replace('{n}', String(fixed)));
1648
+ }
1649
+ } catch { /* 静默:失败不影响主流程,下次启动再试 */ }
1650
+ }
1651
+ /** 数据根目录就绪后自动把浏览器 IndexedDB 里的存量资产搬到磁盘(替代原"迁移历史资产"按钮,全自动无需手动触发)。 */
1652
+ async function autoMigrateAssetsToRoot() {
1653
+ if (!S.dataRoot) return;
1654
+ try {
1655
+ const all = await idbAll();
1656
+ const pending = all.some((r) => r && r.blob && !r.rel);
1657
+ if (!pending) return;
1658
+ const r = await migrateAssetsToDisk();
1659
+ if (r.migrated) {
1660
+ toast(T('dataRootMigrated').replace('{n}', String(r.migrated)));
1661
+ update((s) => { s.assetsTick++; });
1662
+ }
1663
+ } catch { /* 静默:迁移失败不影响主流程,下次设置数据根目录时再试 */ }
1664
+ }
1613
1665
  function useAssetUrl(id) {
1614
1666
  const [url, setUrl] = useState(null);
1615
1667
  useEffect(() => {
@@ -3045,34 +3097,46 @@ window.__ModuleLoader__.load({
3045
3097
  duration: Number.isFinite(Number(s.duration)) ? clamp(Math.round(Number(s.duration)), 1, 15) : 3,
3046
3098
  }));
3047
3099
  }
3048
- /** 本地一致性检查:分镜里出现的角色/场景/道具名是否都能在设定集或图片资产中找到。 */
3100
+ /** 本地一致性检查:分镜里出现的角色/场景/道具(含 visual/motion 文本里的 @提及)是否都在
3101
+ * 设定集/资产中登记;两级告警——「未登记」与「已登记但描述为空(待补全)」。 */
3049
3102
  async function checkShotConsistency(shots) {
3050
3103
  const bible = loadBible(S.activeId);
3051
- const known = new Set();
3052
- const addName = (n) => { const q = String(n || '').trim().toLowerCase(); if (q) known.add(q); };
3053
- (bible.characters || []).forEach((c) => addName(c.name));
3054
- (bible.scenes || []).forEach((c) => addName(c.name));
3055
- (bible.props || []).forEach((c) => addName(c.name));
3056
- try {
3057
- const recs = (await idbByProject(S.activeId)).concat(await idbByProject(GENERAL_PID));
3058
- recs.forEach((r) => addName(r.name));
3059
- } catch { /* 忽略 */ }
3104
+ const entries = [];
3105
+ const addEntry = (kind, name, desc) => {
3106
+ const nm = String(name || '').trim();
3107
+ if (nm) entries.push({ kind, name: nm, desc: String(desc || ''), lower: nm.toLowerCase() });
3108
+ };
3109
+ (bible.characters || []).forEach((c) => addEntry(T('catCharacter'), c && c.name, c && c.desc));
3110
+ (bible.scenes || []).forEach((c) => addEntry(T('catScene'), c && c.name, c && c.desc));
3111
+ (bible.props || []).forEach((c) => addEntry(T('catProp'), c && c.name, c && c.desc));
3112
+ const known = new Set(entries.map((e) => e.lower));
3113
+ try { ((await idbByProject(S.activeId)).concat(await idbByProject(GENERAL_PID))).forEach((r) => { const q = String(r && r.name || '').trim().toLowerCase(); if (q) known.add(q); }); } catch { /* 忽略 */ }
3060
3114
  const warnings = [];
3115
+ const seen = new Set();
3116
+ const warn = (line) => { if (!seen.has(line) && warnings.length < 12) { seen.add(line); warnings.push(line); } };
3117
+ const check = (title, kind, name) => {
3118
+ const q = String(name || '').trim().toLowerCase();
3119
+ if (!q) return;
3120
+ const exact = entries.find((e) => e.lower === q);
3121
+ if (exact) {
3122
+ if (!exact.desc.trim()) warn(`${title} · ${kind}「${exact.name}」${T('warnEmptyDesc')}`);
3123
+ return;
3124
+ }
3125
+ for (const k of known) { if (k.includes(q) || q.includes(k)) return; }
3126
+ warn(`${title} · ${kind}「${String(name).trim()}」${T('warnUnregistered')}`);
3127
+ };
3061
3128
  for (const sh of shots) {
3062
- const items = [
3063
- ...sh.characters.map((n) => [n, T('catCharacter')]),
3064
- [sh.scene, T('catScene')],
3065
- ...sh.props.map((n) => [n, T('catProp')]),
3066
- ];
3067
- for (const [nm, kind] of items) {
3068
- const q = String(nm || '').trim().toLowerCase();
3069
- if (!q) continue;
3070
- if (![...known].some((k) => k.includes(q) || q.includes(k))) {
3071
- warnings.push(`${sh.title} · ${kind}「${nm}」${T('warnUnregistered')}`);
3072
- }
3129
+ const title = String(sh.title || '').trim() || T('shotPrefix');
3130
+ for (const nm of (sh.characters || [])) check(title, T('catCharacter'), nm);
3131
+ check(title, T('catScene'), sh.scene);
3132
+ for (const nm of (sh.props || [])) check(title, T('catProp'), nm);
3133
+ // visual/motion 文本里的 @提及 同样纳入检查(数组外的实体也要登记,杜绝检查漏)
3134
+ const text = String(sh.visual || '') + ' ' + String(sh.motion || '');
3135
+ for (const m of (text.match(/@[^\s@,。;、!?::()"'))]+/g) || [])) {
3136
+ check(title, T('catMention'), m.slice(1).replace(/[))]+$/, ''));
3073
3137
  }
3074
3138
  }
3075
- return warnings.slice(0, 12);
3139
+ return warnings;
3076
3140
  }
3077
3141
  /** 分镜生成后:把设定集里没有的角色/场景/道具自动补进设定集(描述留空待用户补充)。 */
3078
3142
  async function autoRegisterBibleEntries(shots) {
@@ -3696,14 +3760,14 @@ window.__ModuleLoader__.load({
3696
3760
  flushSave();
3697
3761
  try {
3698
3762
  const shots = await aiStoryboardShots(st, n.data.requirement, n.data.count || 6, n.data.model, (kind, d) => aiStreamAppend(n.id, kind, d));
3763
+ // 先自动补录缺失条目(空描述),再算一致性告警:否则告警标志的条目其实马上就被自动补录了,"未登记"语义会误导用户与对话侧
3764
+ try { await autoRegisterBibleEntries(shots); } catch { /* 补录失败不影响分镜 */ }
3699
3765
  const warnings = await checkShotConsistency(shots);
3700
3766
  update((s) => {
3701
3767
  const nd = s.graph.nodes[n.id];
3702
3768
  if (nd) { nd.data.shots = shots; nd.data.aiStatus = 'idle'; nd.data.warnings = warnings; nd.data.status = 'done'; nd.data.error = null; }
3703
3769
  s.assetsTick++;
3704
3770
  });
3705
- // 分镜里出现的新角色/场景/道具 → 自动补进设定集(描述留空待补充),保证设定集始终是唯一事实源
3706
- try { await autoRegisterBibleEntries(shots); } catch { /* 补录失败不影响分镜 */ }
3707
3771
  await saveTextAssetRecord(S.activeId, {
3708
3772
  name: (String(n.data.label || '').trim() || T('storyboardAssetPrefix')) + ' ' + nowStamp(),
3709
3773
  text: storyboardAssetText(shots),
@@ -3810,7 +3874,8 @@ window.__ModuleLoader__.load({
3810
3874
  entity: entities.length ? entities[0].name : undefined,
3811
3875
  entities: entities.map((e) => e.name),
3812
3876
  nodeLabel: (n.data && n.data.label) || '',
3813
- category: entities.length ? entityCategoryOf(entities[0].kind) : 'general',
3877
+ // 分类:@引用命中的实体品类优先;无引用时按命名规范前缀(角色-/场景-/道具-)兜底
3878
+ category: entities.length ? entityCategoryOf(entities[0].kind) : (categoryFromName(n.data.label) || 'general'),
3814
3879
  });
3815
3880
  commitGenResult(n.id, rec.id, demo, 'image');
3816
3881
  } else {
@@ -3828,7 +3893,7 @@ window.__ModuleLoader__.load({
3828
3893
  entity: entities.length ? entities[0].name : undefined,
3829
3894
  entities: entities.map((e) => e.name),
3830
3895
  nodeLabel: (n.data && n.data.label) || '',
3831
- category: entities.length ? entityCategoryOf(entities[0].kind) : 'general',
3896
+ category: entities.length ? entityCategoryOf(entities[0].kind) : (categoryFromName(n.data.label) || 'general'),
3832
3897
  });
3833
3898
  commitGenResult(n.id, rec.id, demo, 'video');
3834
3899
  // 尾帧提取(通用):供 last 端口输出与链式下一镜使用
@@ -6546,7 +6611,6 @@ window.__ModuleLoader__.load({
6546
6611
  const [val, setVal] = useState('');
6547
6612
  const [busy, setBusy] = useState(false);
6548
6613
  const [picking, setPicking] = useState(false);
6549
- const [mig, setMig] = useState(null); // null | {done,total} | 'done'
6550
6614
  useEffect(() => { if (S.dataRoot) setVal(S.dataRoot); }, [S.dataRoot]);
6551
6615
  const save = async () => {
6552
6616
  if (busy) return;
@@ -6556,7 +6620,7 @@ window.__ModuleLoader__.load({
6556
6620
  await refreshDataRoot();
6557
6621
  toast(T('dataRootSaved') + (S.dataRoot ? ':' + S.dataRoot : ''));
6558
6622
  assetIndexSynced = false; // 换了目录:索引要往新目录重建
6559
- syncAssetIndexToHost();
6623
+ autoMigrateAssetsToRoot().then(() => syncAssetIndexToHost()); // 先搬存量资产,再对齐索引
6560
6624
  } catch (e) { toast('设置失败:' + ((e && e.message) || e)); }
6561
6625
  setBusy(false);
6562
6626
  };
@@ -6566,7 +6630,7 @@ window.__ModuleLoader__.load({
6566
6630
  setVal(S.dataRoot || root);
6567
6631
  toast(T('dataRootSaved') + ':' + S.dataRoot);
6568
6632
  assetIndexSynced = false; // 换了目录:索引要往新目录重建
6569
- syncAssetIndexToHost();
6633
+ autoMigrateAssetsToRoot().then(() => syncAssetIndexToHost());
6570
6634
  };
6571
6635
  const browse = async () => {
6572
6636
  if (busy || picking) return;
@@ -6608,16 +6672,6 @@ window.__ModuleLoader__.load({
6608
6672
  } catch (e) { toast('导入失败:' + ((e && e.message) || e)); }
6609
6673
  setBusy(false);
6610
6674
  };
6611
- const migrate = async () => {
6612
- if (busy || picking || mig) return;
6613
- setBusy(true); setMig({ done: 0, total: 0 });
6614
- try {
6615
- const r = await migrateAssetsToDisk((done, total) => setMig({ done, total }));
6616
- setMig('done');
6617
- toast(T('dataRootMigrated').replace('{n}', String(r.migrated)));
6618
- } catch (e) { setMig(null); toast('迁移失败:' + ((e && e.message) || e)); }
6619
- setBusy(false);
6620
- };
6621
6675
  return h('div', { key: 'dataroot', className: 'dshic-card' },
6622
6676
  h('div', { className: 'dshic-row' },
6623
6677
  Ic('image', 12),
@@ -6638,10 +6692,6 @@ window.__ModuleLoader__.load({
6638
6692
  picking ? h('div', { className: 'dshic-hint', key: 'picking' }, T('dataRootPicking')) : null,
6639
6693
  h('div', { className: 'dshic-row', key: 'row2' },
6640
6694
  S.dataRoot ? h('button', { type: 'button', className: 'dshic-mini', onPointerDown: (e) => e.stopPropagation(), onClick: openDir }, T('dataRootOpen')) : null,
6641
- S.dataRoot ? h('button', { type: 'button', className: 'dshic-mini', disabled: busy || picking || !!mig, onPointerDown: (e) => e.stopPropagation(), onClick: migrate }, mig === 'done' ? T('dataRootMigratedDone') : T('dataRootMigrate')) : null,
6642
- (mig && mig !== 'done') ? h('span', { style: { fontSize: 11, opacity: 0.85 } }, T('dataRootMigrating') + ' ' + mig.done + '/' + mig.total) : null,
6643
- ),
6644
- h('div', { className: 'dshic-row', key: 'row3' },
6645
6695
  S.dataRoot ? h('button', { type: 'button', className: 'dshic-mini is-ghost', disabled: busy || picking, onPointerDown: (e) => e.stopPropagation(), onClick: importFromRoot }, Ic('folder', 11), T('dataRootImportBtn')) : null,
6646
6696
  S.dataRoot ? InfoTip(T('dataRootImportHint')) : null,
6647
6697
  h('div', { className: 'dshic-flex' }),
@@ -7250,8 +7300,8 @@ window.__ModuleLoader__.load({
7250
7300
  probeCraft();
7251
7301
  // 检查更新(Host RPC;Host 旧版未带此方法时静默)
7252
7302
  checkUpdate(true);
7253
- // 数据根目录(Host 旧版静默降级为 IndexedDB 模式);就绪后把 IDB 资产元数据补齐进数据目录索引
7254
- refreshDataRoot().then(() => syncAssetIndexToHost()).catch(() => { /* 静默 */ });
7303
+ // 数据根目录(Host 旧版静默降级为 IndexedDB 模式);就绪后自动搬存量资产到磁盘、纠正分类,再对齐索引
7304
+ refreshDataRoot().then(() => autoMigrateAssetsToRoot()).then(() => fixGeneralCategories()).then(() => syncAssetIndexToHost()).catch(() => { /* 静默 */ });
7255
7305
  // 对话 ↔ 画布同步(启动一次 + 3s 轮询;旧 Host 自动停轮询)
7256
7306
  hostSyncTick().then(() => pushAllProjectsToHost());
7257
7307
  const hostSyncTimer = setInterval(() => hostSyncTick(), 3000);
package/index.js CHANGED
@@ -602,7 +602,7 @@ async function projectsPut(args) {
602
602
  tombstoneAt: pruneStamp(tombstoneAt, keptTomb),
603
603
  edgeTombstoneAt: pruneStamp(edgeTombstoneAt, keptEdgeTomb),
604
604
  };
605
- // 设定集(世界观/风格/角色/场景/道具):随文档一起同步,对话侧可读写
605
+ // 设定集随文档一起同步(世界观/风格/角色/场景/道具):对话侧可读写
606
606
  if (doc.bible !== undefined) {
607
607
  out.bible = normalizeBible(doc.bible);
608
608
  out.bibleUpdatedAt = Date.now();
@@ -610,6 +610,18 @@ async function projectsPut(args) {
610
610
  out.bible = prev.bible;
611
611
  out.bibleUpdatedAt = prev.bibleUpdatedAt || 0;
612
612
  }
613
+ // 设定集有更新时重算全部分镜表的一致性告警(客户端侧设定集编辑也走这条路径)
614
+ if (out.bible !== undefined) {
615
+ try {
616
+ const assetNames = readAssetIndexNames(id);
617
+ for (const nd of Object.values(merged)) {
618
+ if (nd && nd.type === 'storyboard' && Array.isArray(nd.data && nd.data.shots) && nd.data.shots.length) {
619
+ const nextW = storyboardConsistencyWarnings(out.bible, nd.data.shots, assetNames);
620
+ if (JSON.stringify(nextW) !== JSON.stringify(nd.data.warnings || [])) nd.data.warnings = nextW;
621
+ }
622
+ }
623
+ } catch { /* 重算失败不影响保存 */ }
624
+ }
613
625
  mkdirSync(PROJECTS_DIR, { recursive: true });
614
626
  writeIcDoc(id, out); // 统一走原子写 + 数据目录镜像(此前直写绕过了镜像与原子性)
615
627
  return { ok: true, id, updatedAt: out.updatedAt };
@@ -847,6 +859,8 @@ function canvasWriteNode(args) {
847
859
  }));
848
860
  node.data.aiStatus = 'idle';
849
861
  node.data.aiError = null;
862
+ // 写入分镜时同步计算一致性告警:node_status 能立刻返回 warnings(对话侧据此补全设定集)
863
+ try { node.data.warnings = storyboardConsistencyWarnings(doc.bible || {}, node.data.shots, readAssetIndexNames(id)); } catch { node.data.warnings = []; }
850
864
  } else {
851
865
  // 二选一强约束(不可用 overwrite 绕过):已连线到上游剧本的分镜表,内容只从连线取——
852
866
  // 两套来源并存会让生成结果不可预期。如需改用粘贴,请先删除该连线或重建分镜表节点。
@@ -983,6 +997,16 @@ async function canvasUpdateBible(args) {
983
997
  doc.bible = normalizeBible(next);
984
998
  doc.bibleUpdatedAt = Date.now();
985
999
  doc.updatedAt = Date.now();
1000
+ // 设定集更新后重算全部分镜表的一致性告警:否则节点上挂着过期告警,用户与对话侧都会被误导
1001
+ try {
1002
+ const assetNames = readAssetIndexNames(id);
1003
+ for (const nd of Object.values(doc.nodes || {})) {
1004
+ if (nd && nd.type === 'storyboard' && Array.isArray(nd.data && nd.data.shots) && nd.data.shots.length) {
1005
+ const nextW = storyboardConsistencyWarnings(doc.bible, nd.data.shots, assetNames);
1006
+ if (JSON.stringify(nextW) !== JSON.stringify(nd.data.warnings || [])) nd.data.warnings = nextW;
1007
+ }
1008
+ }
1009
+ } catch { /* 重算失败不影响设定集写入 */ }
986
1010
  writeIcDoc(id, doc);
987
1011
  markDocChanged(id);
988
1012
  return { projectId: id, bible: doc.bible, bibleUpdatedAt: doc.bibleUpdatedAt };
@@ -1108,6 +1132,57 @@ async function canvasRunNode(args) {
1108
1132
  return { ok: true, projectId: pid, nodeId, type: node.type, label: (node.data && node.data.label) || '' };
1109
1133
  }
1110
1134
  /** 读取节点运行状态与产物(画布执行完成后会把结果推回 Host 文档)。 */
1135
+ /** 分镜一致性检查(与客户端 checkShotConsistency 同规则,Host 供状态查询/设定集更新后重算):
1136
+ * 检查 shots 的角色/场景/道具数组 **以及 visual/motion 文本里的 @提及** 是否都在设定集登记;
1137
+ * 两级告警:「未登记」(设定集/资产都没有)与「已登记但描述为空」(待补全)。 */
1138
+ function storyboardConsistencyWarnings(bible, shots, extraNames) {
1139
+ const entries = [];
1140
+ const addEntry = (kind, name, desc) => {
1141
+ const nm = String(name || '').trim();
1142
+ if (nm) entries.push({ kind, name: nm, desc: String(desc || ''), lower: nm.toLowerCase() });
1143
+ };
1144
+ (bible.characters || []).forEach((c) => addEntry('角色', c && c.name, c && c.desc));
1145
+ (bible.scenes || []).forEach((c) => addEntry('场景', c && c.name, c && c.desc));
1146
+ (bible.props || []).forEach((c) => addEntry('道具', c && c.name, c && c.desc));
1147
+ const known = new Set(entries.map((e) => e.lower).concat((extraNames || []).map((n) => String(n).trim().toLowerCase()).filter(Boolean)));
1148
+ const warnings = [];
1149
+ const seen = new Set();
1150
+ const warn = (line) => { if (!seen.has(line) && warnings.length < 12) { seen.add(line); warnings.push(line); } };
1151
+ const check = (title, kind, name) => {
1152
+ const q = String(name || '').trim().toLowerCase();
1153
+ if (!q) return;
1154
+ const exact = entries.find((e) => e.lower === q);
1155
+ if (exact) {
1156
+ if (!exact.desc.trim()) warn(`${title} · ${kind}「${exact.name}」已登记但描述为空(待补全)`);
1157
+ return;
1158
+ }
1159
+ for (const k of known) { if (k.includes(q) || q.includes(k)) return; }
1160
+ warn(`${title} · ${kind}「${String(name).trim()}」未在设定集中登记`);
1161
+ };
1162
+ for (const sh of shots || []) {
1163
+ const title = String((sh && sh.title) || '').trim() || '分镜';
1164
+ for (const nm of (sh.characters || [])) check(title, '角色', nm);
1165
+ check(title, '场景', sh && sh.scene);
1166
+ for (const nm of (sh.props || [])) check(title, '道具', nm);
1167
+ // visual/motion 文本里的 @提及 同样纳入检查(数组外的实体也要登记,杜绝检查漏)
1168
+ const text = String((sh && sh.visual) || '') + ' ' + String((sh && sh.motion) || '');
1169
+ for (const m of (text.match(/@[^\s@,。;、!?::()"'))]+/g) || [])) {
1170
+ check(title, '提及', m.slice(1).replace(/[))]+$/, ''));
1171
+ }
1172
+ }
1173
+ return warnings;
1174
+ }
1175
+ /** 从数据目录的项目资产索引里取资产名(与客户端"设定集/资产中登记"的判定口径对齐)。 */
1176
+ function readAssetIndexNames(pid) {
1177
+ const root = dataRoot();
1178
+ if (!root || !pid) return [];
1179
+ try {
1180
+ const entry = readDataRootMeta().projects.find((p) => p.id === pid);
1181
+ if (!entry) return [];
1182
+ const list = JSON.parse(readFileSync(path.join(root, 'projects', entry.folder, 'assets.json'), 'utf8'));
1183
+ return Array.isArray(list) ? list.map((x) => x && x.name).filter(Boolean) : [];
1184
+ } catch { return []; }
1185
+ }
1111
1186
  async function canvasNodeStatus(args) {
1112
1187
  const pid = String((args && args.projectId) || '');
1113
1188
  const nodeId = String((args && args.nodeId) || '');
@@ -1122,6 +1197,8 @@ async function canvasNodeStatus(args) {
1122
1197
  output: String((n.data && n.data.output) || '').slice(0, 6000),
1123
1198
  idea: String((n.data && n.data.idea) || '').slice(0, 2000),
1124
1199
  assetId: (n.data && n.data.assetId) || null,
1200
+ // 分镜表:一致性告警随状态返回——对话侧据此逐条补全设定集(缺失则要自动补充,绝不忽略)
1201
+ warnings: Array.isArray(n.data && n.data.warnings) ? n.data.warnings : [],
1125
1202
  };
1126
1203
  }
1127
1204
  async function canvasOpen(args) {
@@ -1327,7 +1404,7 @@ async function updateInstall() {
1327
1404
  export const inject = ['webServer', 'tools'];
1328
1405
 
1329
1406
  // 仅供诊断 / 自动化测试(不属于 RPC 面,运行时行为不受影响)
1330
- export const _internals = { updateCheck, updateInstall, npmLatestVersion, cmpVer, findPnpm, getProfileDir: () => PROFILE_DIR, getVersion: () => VERSION, dataRoot, setDataRoot, dataRootMigrate, dataRootImport, ensureProjectFolder, physicalRel, mirrorProjectToDataRoot, writeAssetIndex, removeAssetIndexEntry, readDataRootMeta, removeDataRootProject, deletedProjectIds, addDeletedProjectId, clearDeletedProjectId, fetchProxyForward, safeJoin, mimeOf, pipeFile, getIcConfigPath: () => CONFIG_PATH, canvasCreateProject, canvasWriteNode, canvasConnect, canvasDeleteProject, canvasDeleteNode, canvasRunNode, canvasNodeStatus, canvasGetBible, canvasUpdateBible, canvasCreateNode, canvasOpen, projectsList, projectsPut, projectsGet, projectsDelete, projectsSync };
1407
+ export const _internals = { updateCheck, updateInstall, npmLatestVersion, cmpVer, findPnpm, getProfileDir: () => PROFILE_DIR, getVersion: () => VERSION, dataRoot, setDataRoot, dataRootMigrate, dataRootImport, ensureProjectFolder, physicalRel, mirrorProjectToDataRoot, writeAssetIndex, removeAssetIndexEntry, readDataRootMeta, removeDataRootProject, deletedProjectIds, addDeletedProjectId, clearDeletedProjectId, fetchProxyForward, storyboardConsistencyWarnings, readAssetIndexNames, safeJoin, mimeOf, pipeFile, getIcConfigPath: () => CONFIG_PATH, canvasCreateProject, canvasWriteNode, canvasConnect, canvasDeleteProject, canvasDeleteNode, canvasRunNode, canvasNodeStatus, canvasGetBible, canvasUpdateBible, canvasCreateNode, canvasOpen, projectsList, projectsPut, projectsGet, projectsDelete, projectsSync };
1331
1408
 
1332
1409
  function resolveModel(providers, want) {
1333
1410
  const byProvider = want && want.provider
@@ -1730,7 +1807,7 @@ export function apply(ctx) {
1730
1807
  });
1731
1808
  reg({
1732
1809
  name: 'canvas_node_status',
1733
- description: '查询画布节点的运行状态与产物(状态、错误、剧本成稿内容、生成出的资产 id)。用于确认 canvas_run_node 是否完成。',
1810
+ description: '查询画布节点的运行状态与产物(状态、错误、剧本成稿内容、生成出的资产 id)。分镜表节点还会返回 warnings(一致性告警:未登记/描述为空的实体清单)——非空时必须逐条 canvas_update_bible 补全,绝不忽略。用于确认 canvas_run_node 是否完成。',
1734
1811
  parameters: {
1735
1812
  type: 'object',
1736
1813
  properties: {
@@ -1741,7 +1818,7 @@ export function apply(ctx) {
1741
1818
  additionalProperties: false,
1742
1819
  },
1743
1820
  output: {
1744
- schema: { type: 'object', properties: { ok: { type: 'boolean' }, text: { type: 'string' }, status: { type: 'string' }, assetId: { type: 'string' } }, required: ['ok', 'text'] },
1821
+ schema: { type: 'object', properties: { ok: { type: 'boolean' }, text: { type: 'string' }, status: { type: 'string' }, assetId: { type: 'string' }, warnings: { type: 'array', items: { type: 'string' } } }, required: ['ok', 'text'] },
1745
1822
  render: (args, value) => [{ type: 'text', text: String(value.text || '') }],
1746
1823
  },
1747
1824
  timeoutMs: 30000,
@@ -1752,7 +1829,13 @@ export function apply(ctx) {
1752
1829
  if (s.assetId) lines.push('产物资产: ' + s.assetId);
1753
1830
  if (s.idea) lines.push('提示词: ' + s.idea.slice(0, 400));
1754
1831
  if (s.output) lines.push('成稿:\n' + s.output);
1755
- return { ok: true, status: s.status, assetId: s.assetId || undefined, text: lines.join('\n') };
1832
+ // 一致性告警:结构化返回 + 渲染进文本——对话侧据此逐条补全设定集,绝不忽略
1833
+ const warnings = Array.isArray(s.warnings) ? s.warnings : [];
1834
+ if (warnings.length) {
1835
+ lines.push('一致性告警(' + warnings.length + ' 条,须逐条 canvas_update_bible 补全,名称逐字一致):');
1836
+ warnings.forEach((w) => lines.push(' · ' + w));
1837
+ }
1838
+ return { ok: true, status: s.status, assetId: s.assetId || undefined, warnings: warnings.length ? warnings : undefined, text: lines.join('\n') };
1756
1839
  },
1757
1840
  });
1758
1841
  reg({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ninkch/dsh-infinite-canvas",
3
- "version": "0.17.8",
3
+ "version": "0.17.10",
4
4
  "description": "Node-based short-drama studio for DeepSeek Harness: script → storyboard → assets → shots → voiced, subtitled final cut on an infinite canvas.",
5
5
  "keywords": [
6
6
  "dsh",