@ninkch/dsh-infinite-canvas 0.17.9 → 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 +20 -15
- package/assets/canvas-studio/SKILL.md +6 -1
- package/client.js +79 -28
- package/index.js +88 -5
- package/package.json +1 -1
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
|
|
21
|
-
C --> D[
|
|
22
|
-
D --> E[
|
|
23
|
-
E --> F[
|
|
24
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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.
|
|
74
|
-
-
|
|
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
|
|
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
|
|
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.
|
|
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,27 @@ 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
|
+
}
|
|
1613
1651
|
/** 数据根目录就绪后自动把浏览器 IndexedDB 里的存量资产搬到磁盘(替代原"迁移历史资产"按钮,全自动无需手动触发)。 */
|
|
1614
1652
|
async function autoMigrateAssetsToRoot() {
|
|
1615
1653
|
if (!S.dataRoot) return;
|
|
@@ -3059,34 +3097,46 @@ window.__ModuleLoader__.load({
|
|
|
3059
3097
|
duration: Number.isFinite(Number(s.duration)) ? clamp(Math.round(Number(s.duration)), 1, 15) : 3,
|
|
3060
3098
|
}));
|
|
3061
3099
|
}
|
|
3062
|
-
/**
|
|
3100
|
+
/** 本地一致性检查:分镜里出现的角色/场景/道具(含 visual/motion 文本里的 @提及)是否都在
|
|
3101
|
+
* 设定集/资产中登记;两级告警——「未登记」与「已登记但描述为空(待补全)」。 */
|
|
3063
3102
|
async function checkShotConsistency(shots) {
|
|
3064
3103
|
const bible = loadBible(S.activeId);
|
|
3065
|
-
const
|
|
3066
|
-
const
|
|
3067
|
-
|
|
3068
|
-
|
|
3069
|
-
|
|
3070
|
-
|
|
3071
|
-
|
|
3072
|
-
|
|
3073
|
-
|
|
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 { /* 忽略 */ }
|
|
3074
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
|
+
};
|
|
3075
3128
|
for (const sh of shots) {
|
|
3076
|
-
const
|
|
3077
|
-
|
|
3078
|
-
|
|
3079
|
-
|
|
3080
|
-
|
|
3081
|
-
|
|
3082
|
-
|
|
3083
|
-
|
|
3084
|
-
if (![...known].some((k) => k.includes(q) || q.includes(k))) {
|
|
3085
|
-
warnings.push(`${sh.title} · ${kind}「${nm}」${T('warnUnregistered')}`);
|
|
3086
|
-
}
|
|
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(/[))]+$/, ''));
|
|
3087
3137
|
}
|
|
3088
3138
|
}
|
|
3089
|
-
return warnings
|
|
3139
|
+
return warnings;
|
|
3090
3140
|
}
|
|
3091
3141
|
/** 分镜生成后:把设定集里没有的角色/场景/道具自动补进设定集(描述留空待用户补充)。 */
|
|
3092
3142
|
async function autoRegisterBibleEntries(shots) {
|
|
@@ -3710,14 +3760,14 @@ window.__ModuleLoader__.load({
|
|
|
3710
3760
|
flushSave();
|
|
3711
3761
|
try {
|
|
3712
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 { /* 补录失败不影响分镜 */ }
|
|
3713
3765
|
const warnings = await checkShotConsistency(shots);
|
|
3714
3766
|
update((s) => {
|
|
3715
3767
|
const nd = s.graph.nodes[n.id];
|
|
3716
3768
|
if (nd) { nd.data.shots = shots; nd.data.aiStatus = 'idle'; nd.data.warnings = warnings; nd.data.status = 'done'; nd.data.error = null; }
|
|
3717
3769
|
s.assetsTick++;
|
|
3718
3770
|
});
|
|
3719
|
-
// 分镜里出现的新角色/场景/道具 → 自动补进设定集(描述留空待补充),保证设定集始终是唯一事实源
|
|
3720
|
-
try { await autoRegisterBibleEntries(shots); } catch { /* 补录失败不影响分镜 */ }
|
|
3721
3771
|
await saveTextAssetRecord(S.activeId, {
|
|
3722
3772
|
name: (String(n.data.label || '').trim() || T('storyboardAssetPrefix')) + ' ' + nowStamp(),
|
|
3723
3773
|
text: storyboardAssetText(shots),
|
|
@@ -3824,7 +3874,8 @@ window.__ModuleLoader__.load({
|
|
|
3824
3874
|
entity: entities.length ? entities[0].name : undefined,
|
|
3825
3875
|
entities: entities.map((e) => e.name),
|
|
3826
3876
|
nodeLabel: (n.data && n.data.label) || '',
|
|
3827
|
-
|
|
3877
|
+
// 分类:@引用命中的实体品类优先;无引用时按命名规范前缀(角色-/场景-/道具-)兜底
|
|
3878
|
+
category: entities.length ? entityCategoryOf(entities[0].kind) : (categoryFromName(n.data.label) || 'general'),
|
|
3828
3879
|
});
|
|
3829
3880
|
commitGenResult(n.id, rec.id, demo, 'image');
|
|
3830
3881
|
} else {
|
|
@@ -3842,7 +3893,7 @@ window.__ModuleLoader__.load({
|
|
|
3842
3893
|
entity: entities.length ? entities[0].name : undefined,
|
|
3843
3894
|
entities: entities.map((e) => e.name),
|
|
3844
3895
|
nodeLabel: (n.data && n.data.label) || '',
|
|
3845
|
-
category: entities.length ? entityCategoryOf(entities[0].kind) : 'general',
|
|
3896
|
+
category: entities.length ? entityCategoryOf(entities[0].kind) : (categoryFromName(n.data.label) || 'general'),
|
|
3846
3897
|
});
|
|
3847
3898
|
commitGenResult(n.id, rec.id, demo, 'video');
|
|
3848
3899
|
// 尾帧提取(通用):供 last 端口输出与链式下一镜使用
|
|
@@ -7249,8 +7300,8 @@ window.__ModuleLoader__.load({
|
|
|
7249
7300
|
probeCraft();
|
|
7250
7301
|
// 检查更新(Host RPC;Host 旧版未带此方法时静默)
|
|
7251
7302
|
checkUpdate(true);
|
|
7252
|
-
// 数据根目录(Host 旧版静默降级为 IndexedDB
|
|
7253
|
-
refreshDataRoot().then(() => autoMigrateAssetsToRoot()).then(() => syncAssetIndexToHost()).catch(() => { /* 静默 */ });
|
|
7303
|
+
// 数据根目录(Host 旧版静默降级为 IndexedDB 模式);就绪后自动搬存量资产到磁盘、纠正分类,再对齐索引
|
|
7304
|
+
refreshDataRoot().then(() => autoMigrateAssetsToRoot()).then(() => fixGeneralCategories()).then(() => syncAssetIndexToHost()).catch(() => { /* 静默 */ });
|
|
7254
7305
|
// 对话 ↔ 画布同步(启动一次 + 3s 轮询;旧 Host 自动停轮询)
|
|
7255
7306
|
hostSyncTick().then(() => pushAllProjectsToHost());
|
|
7256
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
|
|
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
|
-
|
|
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.
|
|
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",
|