@ninkch/dsh-infinite-canvas 0.18.4 → 0.18.6

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/index.js CHANGED
@@ -12,7 +12,6 @@ import { createReadStream, createWriteStream, existsSync, lstatSync, mkdirSync,
12
12
  import os from 'node:os';
13
13
  import path from 'node:path';
14
14
  import { fileURLToPath } from 'node:url';
15
- import { createHash } from 'node:crypto';
16
15
  import { createRequire } from 'node:module';
17
16
 
18
17
  const NAME = 'infinite-canvas';
@@ -179,14 +178,19 @@ function writeDataRootMeta(meta) {
179
178
  } catch { /* 清单写失败不影响主存储 */ }
180
179
  }
181
180
  function sanitizeFolderName(name) {
182
- const s = String(name || '').replace(/[\\/:*?"<>|\u0000-\u001f]/g, '_').replace(/\s+/g, ' ').trim().slice(0, 60);
183
- return s || '未命名项目';
181
+ const s = String(name || '').replace(/[\\/:*?"<>|\u0000-\u001f]/g, '_').replace(/\s+/g, ' ').trim().slice(0, 60)
182
+ .replace(/[.\s]+$/, ''); // Windows 尾随点/空格会被文件系统吞掉;纯 ".." 会让 path.join 上跳一级
183
+ return (!s || s === '.' || s === '..') ? '未命名项目' : s;
184
184
  }
185
185
 
186
186
  // ============================================================
187
- // 存储引擎 v2(0.18.0):SQLite(node:sqlite)+ CAS 对象库
187
+ // 存储引擎 v2(0.18.0):SQLite(node:sqlite)+ 项目文件夹落盘
188
188
  // <dataRoot>/canvas.db 元数据 + 项目文档 = 唯一事实源
189
- // <dataRoot>/objects/<sha2>/<sha256> 媒体文件(内容寻址、去重、引用计数)
189
+ // <dataRoot>/projects/<项目>/<类型>/ 媒体文件(0.18.5 起新上传一律落这里)
190
+ // <dataRoot>/objects/<sha2>/<sha256> 历史 CAS 对象库:**只读保留**
191
+ // !0.18.0–0.18.4 上传的媒体在这里,删掉老用户的图/视频会 404
192
+ // !IC-08 已退役写路径:不再有 POST /api/object,新上传不再产生对象
193
+ // !删资产时仍按引用计数回收(gcObjectIfUnreferenced),避免只增不减
190
194
  // node:sqlite 需要 Node ≥22.5(DSH 捆绑 Node 24 满足);不可用时整站回退 v1 JSON 存储。
191
195
  // ============================================================
192
196
  const require_ = createRequire(import.meta.url);
@@ -504,36 +508,8 @@ function dbAssetUpdate(id, patch) {
504
508
  return { ok: true, asset: rowToAsset(db.prepare('SELECT * FROM assets WHERE id = ?').get(row.id)) };
505
509
  }
506
510
 
507
- /** CAS 对象库 GC:扫描 objects/,删除无任何资产行引用的对象(mtime 超过 24h 才删,避免与并发上传竞态)。 */
508
- function dbObjectsGc() {
509
- const root = dataRoot();
510
- const db = getDb();
511
- if (!root || !db) throw new Error('SQL 存储不可用');
512
- const odir = objectsDirFor(root);
513
- let scanned = 0;
514
- let removed = 0;
515
- const grace = Date.now() - 24 * 3600 * 1000;
516
- try {
517
- for (const bucket of readdirSync(odir)) {
518
- const bdir = path.join(odir, bucket);
519
- let stats = null;
520
- try { stats = statSync(bdir); } catch { continue; }
521
- if (!stats.isDirectory()) continue;
522
- for (const f of readdirSync(bdir)) {
523
- if (!/^[0-9a-f]{64}$/i.test(f)) continue;
524
- scanned++;
525
- const abs = path.join(bdir, f);
526
- let st = null;
527
- try { st = statSync(abs); } catch { continue; }
528
- if (st.mtimeMs > grace) continue; // 近期写入:可能尚未登记资产行
529
- if (objectRefCount(db, f) === 0) {
530
- try { unlinkSync(abs); removed++; } catch { /* 忽略 */ }
531
- }
532
- }
533
- }
534
- } catch { /* 目录不存在 = 无对象 */ }
535
- return { ok: true, scanned, removed };
536
- }
511
+ /** CAS 对象库 GC 已随写路径退役(IC-08):新上传不再产生 objects/,历史对象由删资产时的
512
+ * 引用计数回收(gcObjectIfUnreferenced)兜住,不需要独立的定期扫描入口。 */
537
513
 
538
514
  function readDocOrNull(id) {
539
515
  if (sqlActive()) {
@@ -580,6 +556,28 @@ function jsonAssetsList(args) {
580
556
  const nextCursor = start + limit < total ? Buffer.from(String(start + limit)).toString('base64') : null;
581
557
  return Promise.resolve({ ok: true, total, items: page, nextCursor });
582
558
  }
559
+ /** IC-B4:FTS 存量孤儿清理(一次性维护,幂等)。assets_fts 里 id 不在 assets 表的行 = 孤儿
560
+ * (0.18.0 存储重写期 DELETE FROM assets 绕过 dbAssetDelete 留下的,QA 实测 58 vs 56)。
561
+ * 只读判断 + 显式计数返回;顺带把"在 assets 却不在 fts"的漏登行补齐(反向孤儿,同样幂等)。 */
562
+ function ftsReconcile(db) {
563
+ const orphans = db.prepare('SELECT f.id AS id FROM assets_fts f LEFT JOIN assets a ON a.id = f.id WHERE a.id IS NULL').all().map((r) => r.id);
564
+ const missing = db.prepare('SELECT a.id AS id FROM assets a LEFT JOIN assets_fts f ON f.id = a.id WHERE f.id IS NULL').all().map((r) => r.id);
565
+ const del = db.prepare('DELETE FROM assets_fts WHERE id = ?');
566
+ let removed = 0;
567
+ const errors = [];
568
+ for (const id of orphans) {
569
+ try { del.run(id); removed++; } catch (e) { errors.push(id + ': ' + String((e && e.message) || e)); }
570
+ }
571
+ let repaired = 0;
572
+ if (missing.length) {
573
+ const rows = db.prepare('SELECT id, name, prompt FROM assets WHERE id IN (' + missing.map(() => '?').join(',') + ')').all(...missing);
574
+ for (const r of rows) {
575
+ try { syncFts(db, r.id, r.name, r.prompt); repaired++; } catch (e) { errors.push(r.id + ': ' + String((e && e.message) || e)); }
576
+ }
577
+ }
578
+ return { ok: errors.length === 0, removed, repaired, fts: db.prepare('SELECT COUNT(*) AS n FROM assets_fts').get().n, assets: db.prepare('SELECT COUNT(*) AS n FROM assets').get().n, errors };
579
+ }
580
+
583
581
  /** 确保项目的物理文件夹(按项目名)与清单条目存在;改名时尽力重命名目录(被占用/冲突则保留旧文件夹)。返回文件夹名。 */
584
582
  function ensureProjectFolder(pid, name) {
585
583
  const root = dataRoot();
@@ -693,15 +691,82 @@ function writeAssetIndex(pid, projectName, assets, opts) {
693
691
  renameSync(tmp, f);
694
692
  return { ok: true, folder, count: list.length };
695
693
  }
696
- /** 删除项目的数据目录镜像(项目被删除时调用)。 */
694
+ /** 删除项目的数据目录镜像(项目被删除时调用)。
695
+ * BLK-01:SQL 模式下目录名可能没有 meta 条目(实测就是项目 id),旧实现此时静默 return、
696
+ * 永远删不掉目录。现在:优先用 meta 的 folder;没有条目就回退 <pid>;两个候选都尝试删;
697
+ * 删除失败不再吞掉,返回值里带 removed/errors 供调用方暴露。
698
+ * 路径安全:folder/pid 可能来自调用方(canvas_delete_project 的 projectId 是外部输入),
699
+ * 含 `..` 或绝对路径时 path.join 会逃出数据根 → rmrf 删掉无关目录。与 IC-B2 的 craft 清理
700
+ * 同一防护:resolve 后必须仍落在 <dataRoot>/projects 之内,否则拒绝并记录。 */
697
701
  function removeDataRootProject(pid) {
702
+ const out = { removed: false, dirs: [], errors: [] };
698
703
  const root = dataRoot();
699
- if (!root || !pid) return;
704
+ if (!root || !pid) return out;
700
705
  const meta = readDataRootMeta();
701
- const entry = meta.projects.find((p) => p.id === pid);
706
+ const entry = meta.projects.find((p) => p.id === pid) || null;
707
+ const candidates = [];
708
+ if (entry && entry.folder) candidates.push(entry.folder);
709
+ if (!candidates.includes(pid)) candidates.push(pid);
702
710
  meta.projects = meta.projects.filter((p) => p.id !== pid);
703
- writeDataRootMeta(meta);
704
- if (entry) rmrf(path.join(root, 'projects', entry.folder));
711
+ try { writeDataRootMeta(meta); } catch (e) { out.errors.push('meta: ' + String((e && e.message) || e)); }
712
+ const projectsBase = path.resolve(path.join(root, 'projects'));
713
+ for (const folder of candidates) {
714
+ let dir = null;
715
+ try {
716
+ const cand = path.resolve(path.join(root, 'projects', String(folder)));
717
+ // 必须是 projects 的**子目录**(含 projects 本身在内的任何异常形态都拒绝:
718
+ // cand === projectsBase 时 rmrf 会清掉全部项目的媒体,比越界删除更糟)
719
+ if (cand === projectsBase || !cand.startsWith(projectsBase + path.sep)) {
720
+ out.errors.push('拒绝删除数据根之外的目录: ' + folder);
721
+ continue;
722
+ }
723
+ dir = cand;
724
+ } catch (e) { out.errors.push('路径异常: ' + String((e && e.message) || e)); continue; }
725
+ if (!existsSync(dir)) { out.dirs.push({ dir, existed: false }); continue; }
726
+ try { rmrf(dir); out.dirs.push({ dir, existed: true, removed: !existsSync(dir) }); if (!existsSync(dir)) out.removed = true; }
727
+ catch (e) { out.dirs.push({ dir, existed: true, removed: false }); out.errors.push(dir + ': ' + String((e && e.message) || e)); }
728
+ }
729
+ return out;
730
+ }
731
+
732
+ /** BLK-01:SQL 模式删除项目时的统一清理(两条删除入口共用,禁止各写一份)。
733
+ * DB 行 → FTS 行 → 资产文件(rel/sha)→ 项目镜像目录 → meta 条目,每一处失败都返回可见。
734
+ * 不碰 craft/ 目录(归 IC-B2)。 */
735
+ function sqlDeleteProjectRows(db, id, root) {
736
+ const result = { ok: true, projectId: id, fts: 0, assets: 0, files: 0, dir: null, errors: [] };
737
+ // 1) 资产行与它们占用的文件:先取快照,再删行
738
+ let rows = [];
739
+ try { rows = db.prepare('SELECT id, rel, sha FROM assets WHERE project_id = ?').all(id); } catch (e) { result.errors.push('select assets: ' + String((e && e.message) || e)); }
740
+ result.assets = rows.length;
741
+ // 2) FTS:DELETE FROM assets 绕过了 dbAssetDelete(那里才同步删 FTS)→ 这里逐条清
742
+ try {
743
+ const delFts = db.prepare('DELETE FROM assets_fts WHERE id = ?');
744
+ for (const r of rows) { try { delFts.run(r.id); result.fts++; } catch { /* 单条失败继续 */ } }
745
+ } catch (e) { result.errors.push('fts: ' + String((e && e.message) || e)); }
746
+ // 3) 资产文件(rel 镜像 / sha 对象):删行前先按引用清理
747
+ try {
748
+ db.prepare('DELETE FROM assets WHERE project_id = ?').run(id);
749
+ db.prepare('DELETE FROM projects WHERE id = ?').run(id);
750
+ } catch (e) { result.errors.push('delete rows: ' + String((e && e.message) || e)); }
751
+ if (root) {
752
+ const seen = new Set();
753
+ for (const r of rows) {
754
+ if (r.rel) {
755
+ try {
756
+ const mapped = physicalRel(r.rel) || r.rel;
757
+ const { abs } = safeJoin(root, mapped);
758
+ if (seen.has(abs)) continue;
759
+ seen.add(abs);
760
+ if (existsSync(abs)) { unlinkSync(abs); result.files++; }
761
+ } catch { /* 已不存在视为成功 */ }
762
+ }
763
+ if (r.sha) { try { gcObjectIfUnreferenced(db, root, r.sha); } catch { /* 对象清理失败不阻塞 */ } }
764
+ }
765
+ }
766
+ // 4) 项目镜像目录(含 meta 条目缺失时的 id 回退)
767
+ try { result.dir = removeDataRootProject(id); if (result.dir.errors.length) result.errors.push(...result.dir.errors); } catch (e) { result.errors.push('dir: ' + String((e && e.message) || e)); }
768
+ if (result.errors.length) result.ok = false;
769
+ return result;
705
770
  }
706
771
  /** 一次性迁移/对齐数据目录布局:旧 pid 文件夹 → 项目名文件夹,并镜像全部项目文档。 */
707
772
  function dataRootMigrate() {
@@ -1173,20 +1238,17 @@ function setDataRoot(raw) {
1173
1238
  }
1174
1239
  async function projectsDelete(args) {
1175
1240
  const id = String((args && args.id) || '');
1241
+ let cleanup = null;
1176
1242
  if (sqlActive()) {
1177
- const db = getDb();
1178
- const root = dataRoot();
1179
- // 先收集对象引用:项目资产行删除后,仅本项目引用的 CAS 对象一并回收
1180
- const shas = db.prepare('SELECT DISTINCT sha FROM assets WHERE project_id = ? AND sha IS NOT NULL').all(id).map((r) => r.sha);
1181
- db.prepare('DELETE FROM assets WHERE project_id = ?').run(id);
1182
- db.prepare('DELETE FROM projects WHERE id = ?').run(id);
1183
- if (root) for (const sha of shas) gcObjectIfUnreferenced(db, root, sha);
1243
+ // BLK-01:行 + FTS + 资产文件 + 镜像目录,一处收口(与 canvasDeleteProject 共用同一实现)
1244
+ cleanup = sqlDeleteProjectRows(getDb(), id, dataRoot());
1184
1245
  } else {
1185
1246
  try { unlinkSync(projectFile(id)); } catch { /* 已不存在视为成功 */ }
1186
1247
  try { removeDataRootProject(id); } catch { /* 数据目录镜像清理失败不影响删除 */ }
1187
1248
  }
1188
1249
  try { addDeletedProjectId(id); } catch { /* 墓碑写失败不影响删除 */ }
1189
- return { ok: true };
1250
+ // 清理失败必须可见:ok 保留删除语义,disk 字段给出磁盘侧实际结果
1251
+ return { ok: true, disk: cleanup };
1190
1252
  }
1191
1253
  async function projectsSync() {
1192
1254
  const cfg = readIcConfig();
@@ -1480,6 +1542,9 @@ function writeIcDoc(id, doc) {
1480
1542
  id, String(doc.name || id).slice(0, 80), doc.createdAt || Date.now(), doc.updatedAt || Date.now(),
1481
1543
  doc.bibleUpdatedAt || 0, JSON.stringify(docSummary(doc)), JSON.stringify(doc.tombstones || []), JSON.stringify(doc),
1482
1544
  );
1545
+ // SQL 模式也要保证数据目录里有 meta 映射(craft 清理/物理落盘按 folder 定位):
1546
+ // 目录与 project.json 镜像不再是主存储,但 folder 登记是 /api/asset 落盘与 ArtCraft 基准的前提。
1547
+ try { ensureProjectFolder(id, (doc && doc.name) || id); } catch { /* meta 写失败不阻塞主存储 */ }
1483
1548
  return;
1484
1549
  }
1485
1550
  const f = projectFile(id);
@@ -1698,13 +1763,10 @@ async function canvasDeleteProject(args) {
1698
1763
  let name = id;
1699
1764
  const prevDoc = readDocOrNull(id);
1700
1765
  if (prevDoc && prevDoc.name) name = prevDoc.name;
1766
+ let cleanup = null;
1701
1767
  if (sqlActive()) {
1702
- const db = getDb();
1703
- const root = dataRoot();
1704
- const shas = db.prepare('SELECT DISTINCT sha FROM assets WHERE project_id = ? AND sha IS NOT NULL').all(id).map((r) => r.sha);
1705
- db.prepare('DELETE FROM assets WHERE project_id = ?').run(id);
1706
- db.prepare('DELETE FROM projects WHERE id = ?').run(id);
1707
- if (root) for (const sha of shas) gcObjectIfUnreferenced(db, root, sha);
1768
+ // BLK-01:与 projectsDelete 共用同一清理实现(行 + FTS + 资产文件 + 镜像目录)
1769
+ cleanup = sqlDeleteProjectRows(getDb(), id, dataRoot());
1708
1770
  } else {
1709
1771
  try { unlinkSync(projectFile(id)); } catch { /* 已删除 */ }
1710
1772
  try { removeDataRootProject(id); } catch { /* 数据目录镜像清理失败不影响删除 */ }
@@ -1712,14 +1774,15 @@ async function canvasDeleteProject(args) {
1712
1774
  // 通知客户端把这个项目也从本地删掉,否则下次推送会把它复活 + 长效墓碑:拒收迟到的推送
1713
1775
  const cfg = readIcConfig();
1714
1776
  const prev = (cfg.ui && cfg.ui.deletedProjects) || [];
1715
- const prevTomb = Array.isArray(cfg.ui.deletedProjectIds) ? cfg.ui.deletedProjectIds : [];
1777
+ // 防御:config 里可能根本没有 ui 段(新装/被手工清空)——直接解引用 cfg.ui 会 TypeError 打断删除
1778
+ const prevTomb = Array.isArray(cfg.ui && cfg.ui.deletedProjectIds) ? cfg.ui.deletedProjectIds : [];
1716
1779
  cfg.ui = {
1717
1780
  ...(cfg.ui || {}),
1718
1781
  deletedProjects: [...new Set([...prev, id])].slice(-50),
1719
1782
  deletedProjectIds: [...new Set([...prevTomb, id])].slice(-200),
1720
1783
  };
1721
1784
  writeIcConfig(cfg);
1722
- return { ok: true, projectId: id, name };
1785
+ return { ok: true, projectId: id, name, disk: cleanup };
1723
1786
  }
1724
1787
  async function canvasDeleteNode(args) {
1725
1788
  const id = String((args && args.projectId) || '');
@@ -1834,24 +1897,169 @@ async function canvasNodeStatus(args) {
1834
1897
  : [],
1835
1898
  // 音效/环境音:声音设计提示,随镜头透出给对话侧与视频提示词
1836
1899
  sfx: String((s && s.sfx) || '').slice(0, 120),
1900
+ // 分镜衔接(分段生成的接戏依据):接点尾态/运镜延续/起始态/段落类型——对话侧改写 shots 时必须一起带回
1901
+ tail: String((s && s.tail) || '').slice(0, 160),
1902
+ cam: String((s && s.cam) || '').slice(0, 120),
1903
+ start: String((s && s.start) || '').slice(0, 160),
1904
+ seg: String((s && s.seg) || '').slice(0, 24),
1837
1905
  }))
1838
1906
  : undefined;
1907
+ const d = n.data || {};
1839
1908
  return {
1840
- projectId: pid, nodeId, type: n.type, label: (n.data && n.data.label) || '',
1841
- status: (n.data && n.data.status) || 'idle',
1842
- aiStatus: (n.data && n.data.aiStatus) || 'idle',
1843
- error: (n.data && (n.data.error || n.data.aiError)) || null,
1844
- output: String((n.data && n.data.output) || '').slice(0, 6000),
1845
- idea: String((n.data && n.data.idea) || '').slice(0, 2000),
1846
- assetId: (n.data && n.data.assetId) || null,
1909
+ projectId: pid, nodeId, type: n.type, label: d.label || '',
1910
+ status: d.status || 'idle',
1911
+ aiStatus: d.aiStatus || 'idle',
1912
+ // 文档级时间戳:canvas_generate_node 的等待循环用它区分「本轮真的跑过」与「上一次的残留终态」
1913
+ docUpdatedAt: Number(doc.updatedAt) || 0,
1914
+ error: d.error || d.aiError || null,
1915
+ output: String(d.output || '').slice(0, 6000),
1916
+ idea: String(d.idea || '').slice(0, 2000),
1917
+ // 生成节点的对话框提示词与生成参数:对话侧读取后可精准改写(配合 canvas_update_node),不必删节点重建
1918
+ prompt: String(d.prompt || '').slice(0, 4000),
1919
+ text: String(d.text || '').slice(0, 4000),
1920
+ script: String(d.script || '').slice(0, 2000),
1921
+ requirement: String(d.requirement || '').slice(0, 1000),
1922
+ ratio: d.ratio || null,
1923
+ size: d.size || null,
1924
+ duration: d.duration == null ? null : Number(d.duration),
1925
+ resolution: d.resolution || null,
1926
+ frameMode: d.frameMode || null,
1927
+ count: d.count == null ? null : Number(d.count),
1928
+ imageModel: d.imageModel || null,
1929
+ videoModel: d.videoModel || null,
1930
+ model: d.model || null,
1931
+ stopNote: d.stopNote || null,
1932
+ genWarn: d.genWarn || null,
1933
+ lastAssetId: d.lastAssetId || null,
1934
+ // 尾帧「最佳选帧」(续帧接力首选:动作进行中/清晰/不抖)
1935
+ tailPickAssetId: d.lastPickAssetId || null,
1936
+ tailPick: d.tailPickMeta || null,
1937
+ // 续帧来源:'wired'=首帧来自 frame 连线;节点 id=自动取上一段视频尾帧
1938
+ chainFrom: d.chainFrom || null,
1939
+ // 接缝校验:{ ok, sim, thr, note }——同场续帧阈值 0.55 / 跨场 0.35 / 特写插入 0.15
1940
+ seam: d.seam || null,
1941
+ historyAssetIds: Array.isArray(d.history) ? d.history.map((x) => x && x.assetId).filter(Boolean).slice(-20) : [],
1942
+ assetId: d.assetId || null,
1847
1943
  // 视频任务凭据:中断/超时后对话侧可凭 id 回查结果(钱花了就必须能找回)
1848
- taskId: (n.data && n.data.taskId) || null,
1849
- taskQueryUrl: (n.data && n.data.taskQueryUrl) || null,
1944
+ taskId: d.taskId || null,
1945
+ taskQueryUrl: d.taskQueryUrl || null,
1850
1946
  // 分镜表:一致性告警随状态返回——对话侧据此逐条补全设定集(缺失则要自动补充,绝不忽略)
1851
- warnings: Array.isArray(n.data && n.data.warnings) ? n.data.warnings : [],
1947
+ warnings: Array.isArray(d.warnings) ? d.warnings : [],
1852
1948
  shots,
1853
1949
  };
1854
1950
  }
1951
+ /** 可被 canvas_update_node 修改的节点字段白名单(结构字段 id/type/x/y/w/h/data 一律不可写)。 */
1952
+ const NODE_PATCH_FIELDS = ['prompt', 'idea', 'output', 'text', 'script', 'requirement', 'label', 'ratio', 'size', 'duration', 'resolution', 'frameMode', 'count', 'imageModel', 'videoModel', 'model', 'cont'];
1953
+ /** 更新已有节点的字段:对话侧"改提示词 / 改参数"的唯一入口(此前只能删节点重建)。 */
1954
+ function canvasUpdateNode(args) {
1955
+ const pid = String((args && args.projectId) || '');
1956
+ const nodeId = String((args && args.nodeId) || '');
1957
+ const doc = readDoc(pid);
1958
+ const n = doc.nodes[nodeId];
1959
+ if (!n) throw new Error('节点不存在: ' + nodeId + '(先用 canvas_list_projects 查看节点 id)');
1960
+ const patch = (args && args.patch) || {};
1961
+ const keys = Object.keys(patch);
1962
+ if (!keys.length && patch.shots === undefined) throw new Error('patch 为空:请给出要修改的字段,如 { "prompt": "……" }');
1963
+ const applied = [];
1964
+ const skipped = [];
1965
+ for (const k of keys) {
1966
+ if (k === 'shots') continue; // 单独处理
1967
+ if (!NODE_PATCH_FIELDS.includes(k)) { skipped.push(k); continue; }
1968
+ let v = patch[k];
1969
+ if (k === 'duration' || k === 'count') {
1970
+ const num = Number(v);
1971
+ // count 允许 0(=自动按内容节拍,与 canvasCreateNode 的语义一致);duration 必须正数
1972
+ if (!Number.isFinite(num) || num < 0 || (k === 'duration' && num <= 0)) {
1973
+ throw new Error(k + (k === 'count' ? ' 需要非负数(0=自动按内容节拍)' : ' 需要正数') + ',收到:' + JSON.stringify(v));
1974
+ }
1975
+ v = k === 'count' ? Math.round(num) : num;
1976
+ } else if (k === 'label') {
1977
+ v = String(v == null ? '' : v).slice(0, 40);
1978
+ } else if (k === 'cont') {
1979
+ v = !(v === false || v === 'false' || v === 0 || v === '0'); // 分镜衔接开关:默认开
1980
+ } else {
1981
+ v = String(v == null ? '' : v);
1982
+ }
1983
+ n.data[k] = v;
1984
+ applied.push(k);
1985
+ }
1986
+ if (patch.shots !== undefined) {
1987
+ let arr = patch.shots;
1988
+ if (typeof arr === 'string') {
1989
+ try { arr = JSON.parse(arr); } catch { throw new Error('shots 需要 JSON 数组或 JSON 字符串'); }
1990
+ }
1991
+ if (!Array.isArray(arr)) throw new Error('shots 需要 JSON 数组');
1992
+ // shots 是**整表替换**:只改一镜也必须先把全量读出来再传回(否则其它镜的 tail/cam 会被冲掉)。
1993
+ // 这里做两层兜底:① 合法的 seg 取值归一化 ② 旧表里有衔接字段而新值没给时,按镜序保留旧值。
1994
+ const oldShots = Array.isArray(n.data.shots) ? n.data.shots : [];
1995
+ const SEG_OK = new Set(['opening', 'continuation', 'new_character', 'scene_change', 'closeup', 'cut', '']);
1996
+ const SEG_NORM = { cut: 'closeup', close: 'closeup', insert: 'closeup', cont: 'continuation', open: 'opening', scene: 'scene_change' };
1997
+ arr = arr.map((sh, i) => {
1998
+ if (!sh || typeof sh !== 'object') return sh;
1999
+ const out = { ...sh };
2000
+ const segRaw = String(out.seg || '').trim().toLowerCase();
2001
+ out.seg = SEG_NORM[segRaw] || (SEG_OK.has(segRaw) ? segRaw : '');
2002
+ const old = oldShots[i] || {};
2003
+ // 只补「本来是空的 + 旧值有」的衔接字段,绝不覆盖调用方明确给的新值
2004
+ for (const k2 of ['tail', 'cam', 'start', 'seg']) {
2005
+ if (!out[k2] && old[k2]) out[k2] = old[k2];
2006
+ }
2007
+ return out;
2008
+ });
2009
+ n.data.shots = arr;
2010
+ applied.push('shots');
2011
+ }
2012
+ if (!applied.length) {
2013
+ throw new Error('没有可应用的字段(允许:' + NODE_PATCH_FIELDS.join(' / ') + ' / shots);收到:' + keys.join(' / '));
2014
+ }
2015
+ doc.updatedAt = Date.now();
2016
+ writeIcDoc(pid, doc);
2017
+ markDocChanged(pid);
2018
+ return { projectId: pid, nodeId, type: n.type, label: n.data.label || '', applied, skipped, prompt: String(n.data.prompt || '').slice(0, 400) };
2019
+ }
2020
+ /** 触发节点执行并(可选)等待到终态:对话侧"改完提示词 → 点生成 → 拿结果"的一站式入口。
2021
+ * 执行发生在画布侧(需要浏览器开着画布页面消费运行请求),等待超时不代表失败。 */
2022
+ async function canvasGenerateNode(args) {
2023
+ const pid = String((args && args.projectId) || '');
2024
+ const nodeId = String((args && args.nodeId) || '');
2025
+ const waitSec = Math.max(0, Math.min(600, Number((args && args.waitSeconds) || 0)));
2026
+ // 触发前快照:canvasRunNode 只写一次性 runRequest,真正的执行要等画布下一个 3s 同步 tick
2027
+ // 才开始(状态才翻成 running);而下面的首轮轮询在 2.5s 就发生——对**曾经跑过**的节点,
2028
+ // 首轮会读到上一次的 done/error 并误当本次结果(页面关闭 100%、页开约 17% 触发)。
2029
+ // 因此终态只有在「本轮确实跑过」时才可信:轮询中见过 running,或文档在触发后被写过
2030
+ //(快进完成的短任务可能整体落在两次轮询之间,用文档时间戳兜底)。
2031
+ const baseline = await canvasNodeStatus({ projectId: pid, nodeId }).catch(() => null);
2032
+ const baseUpdatedAt = Number(baseline && baseline.docUpdatedAt) || 0;
2033
+ const run = await canvasRunNode({ projectId: pid, nodeId });
2034
+ if (!waitSec) {
2035
+ return { ok: true, triggered: true, finished: false, projectId: pid, nodeId, type: run.type, label: run.label, status: 'running', note: '已触发执行;画布需处于打开状态才会真正执行,可用 canvas_node_status 查询进度' };
2036
+ }
2037
+ const deadline = Date.now() + waitSec * 1000;
2038
+ let last = null;
2039
+ let sawRunning = false;
2040
+ while (Date.now() < deadline) {
2041
+ await new Promise((r) => setTimeout(r, 2500));
2042
+ try { last = await canvasNodeStatus({ projectId: pid, nodeId }); } catch { continue; }
2043
+ const st = String(last.status || '');
2044
+ const ai = String(last.aiStatus || '');
2045
+ if (st === 'running' || ai === 'running') { sawRunning = true; continue; }
2046
+ // 终态可信门:没见过 running 且文档也没动过 → 这是触发前就挂着的旧终态,继续等
2047
+ const confirmed = sawRunning || (baseUpdatedAt > 0 && (Number(last.docUpdatedAt) || 0) > baseUpdatedAt);
2048
+ if (!confirmed) continue;
2049
+ const isAiNode = last.type === 'script' || last.type === 'storyboard';
2050
+ if (isAiNode) {
2051
+ if (ai === 'error') return { ok: false, triggered: true, finished: true, projectId: pid, nodeId, type: last.type, label: last.label, status: 'error', error: last.error || 'AI 生成失败' };
2052
+ if (ai !== 'running' && (String(last.output || '').trim() || (Array.isArray(last.shots) && last.shots.length))) {
2053
+ return { ok: true, triggered: true, finished: true, projectId: pid, nodeId, type: last.type, label: last.label, status: 'done' };
2054
+ }
2055
+ } else if (st === 'done' || st === 'error') {
2056
+ return { ok: st === 'done', triggered: true, finished: true, projectId: pid, nodeId, type: last.type, label: last.label, status: st, assetId: last.assetId || null, error: last.error || null };
2057
+ }
2058
+ }
2059
+ const started = sawRunning || (last && (Number(last.docUpdatedAt) || 0) > baseUpdatedAt && baseUpdatedAt > 0);
2060
+ return { ok: true, triggered: true, finished: false, projectId: pid, nodeId, type: last ? last.type : run.type, label: last ? last.label : run.label, status: last ? last.status : 'running',
2061
+ note: started ? '等待超时,任务仍在进行;可继续用 canvas_node_status 查询' : '等待超时且未见本轮开始执行——画布页面很可能未打开(运行请求未被消费);打开画布后会自动继续' };
2062
+ }
1855
2063
  async function canvasOpen(args) {
1856
2064
  const pid = String((args && args.projectId) || '');
1857
2065
  if (pid) {
@@ -1871,9 +2079,9 @@ const PORT_TABLE = {
1871
2079
  'script': { inputs: [], outputs: [{ id: 'out', kind: 'text' }] },
1872
2080
  'storyboard': { inputs: [{ id: 'prev', kind: 'text' }, { id: 'script', kind: 'text' }], outputs: [{ id: 'out', kind: 'text' }, { id: 'tail', kind: 'text' }] },
1873
2081
  'image-gen': { inputs: [{ id: 'prompt', kind: 'text' }, { id: 'ref', kind: 'image' }], outputs: [{ id: 'out', kind: 'image' }] },
1874
- 'video-gen': { inputs: [{ id: 'prompt', kind: 'text' }, { id: 'frame', kind: 'image' }, { id: 'ref', kind: 'image' }], outputs: [{ id: 'out', kind: 'video' }, { id: 'last', kind: 'image' }] },
2082
+ 'video-gen': { inputs: [{ id: 'prompt', kind: 'text' }, { id: 'frame', kind: 'image' }, { id: 'ref', kind: 'image' }], outputs: [{ id: 'out', kind: 'video' }, { id: 'tailpick', kind: 'image' }, { id: 'last', kind: 'image' }] },
1875
2083
  'image-asset': { inputs: [{ id: 'in', kind: 'image' }], outputs: [{ id: 'out', kind: 'image' }] },
1876
- 'video-asset': { inputs: [{ id: 'in', kind: 'video' }], outputs: [{ id: 'out', kind: 'video' }, { id: 'last', kind: 'image' }] },
2084
+ 'video-asset': { inputs: [{ id: 'in', kind: 'video' }], outputs: [{ id: 'out', kind: 'video' }, { id: 'tailpick', kind: 'image' }, { id: 'last', kind: 'image' }] },
1877
2085
  'finalcut': { inputs: [{ id: 'in', kind: 'video' }], outputs: [] },
1878
2086
  'group': { inputs: [], outputs: [] },
1879
2087
  };
@@ -2061,7 +2269,7 @@ async function updateInstall() {
2061
2269
  export const inject = ['webServer', 'tools'];
2062
2270
 
2063
2271
  // 仅供诊断 / 自动化测试(不属于 RPC 面,运行时行为不受影响)
2064
- 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, sqliteAvailable: () => !!SQLITE, getDb, closeDb, dbAssetsList, dbAssetsGet, dbAssetNames, dbAssetCreate, dbAssetUpdate, dbAssetDelete, dbObjectsGc, objectAbsFor, readDocOrNull, docExists, sqlActive, rpcMethods: () => _rpcMethods };
2272
+ export const _internals = { updateCheck, updateInstall, npmLatestVersion, cmpVer, findPnpm, getProfileDir: () => PROFILE_DIR, getVersion: () => VERSION, dataRoot, setDataRoot, dataRootMigrate, dataRootImport, ensureProjectFolder, physicalRel, mirrorProjectToDataRoot, writeAssetIndex, removeAssetIndexEntry, readDataRootMeta, removeDataRootProject, sqlDeleteProjectRows, 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, sqliteAvailable: () => !!SQLITE, getDb, closeDb, dbAssetsList, dbAssetsGet, dbAssetNames, dbAssetCreate, dbAssetUpdate, dbAssetDelete, objectAbsFor, ftsReconcile, readDocOrNull, docExists, sqlActive, rpcMethods: () => _rpcMethods, canvasUpdateNode, canvasGenerateNode };
2065
2273
 
2066
2274
  function resolveModel(providers, want) {
2067
2275
  const byProvider = want && want.provider
@@ -2223,6 +2431,74 @@ export function apply(ctx) {
2223
2431
  if (root) { try { exists = statSync(root).isDirectory(); } catch { /* 不存在 */ } }
2224
2432
  return { dataRoot: root, exists, configPath: CONFIG_PATH, sql: !!SQLITE, store: sqlActive() ? 'sqlite' : 'json' };
2225
2433
  },
2434
+ /** BLK-02:项目在数据根下的**绝对目录** = <dataRoot>/projects/<物理文件夹>。
2435
+ * 画布调 ArtCraft 时以它为 base(ArtCraft 0.3.x 无数据根概念、/status 也不再返回 dataRoot),
2436
+ * 避免「无 base 的相对路径」被按插件进程 cwd 解析而静默写错目录。 */
2437
+ async projectDirGet(args) {
2438
+ const root = dataRoot();
2439
+ const pid = String((args && args.projectId) || '');
2440
+ if (!root) return { ok: false, error: '未设置数据根目录', projectId: pid };
2441
+ if (!pid) return { ok: false, error: '缺少 projectId', projectId: pid };
2442
+ const meta = readDataRootMeta();
2443
+ const entry = meta.projects.find((p) => p.id === pid) || null;
2444
+ const folder = entry ? entry.folder : sanitizeFolderName(pid);
2445
+ const abs = path.join(root, 'projects', folder);
2446
+ try { mkdirSync(abs, { recursive: true }); } catch { /* 目录建不出来交给调用方报错 */ }
2447
+ return { ok: true, projectId: pid, folder, abs, dataRoot: root, legacy: !entry };
2448
+ },
2449
+ // IC-B4:FTS 存量孤儿一次性清理(幂等,可重复调;无 SQLite 时明确说不适用)
2450
+ async ftsReconcile() {
2451
+ if (!sqlActive()) return { ok: false, error: '当前不是 SQLite 存储模式,无 FTS 需要清理', applicable: false };
2452
+ return ftsReconcile(getDb());
2453
+ },
2454
+ /** IC-B2:删除项目时清理其目录下的 craft/(ArtCraft 后期产物)。
2455
+ * 客户端知道自己要删哪个项目,Host 按项目物理文件夹定位(与 projectDirGet 同一基准),
2456
+ * 只删 projects/<folder>/craft 这一个子目录——绝不碰项目镜像、其他项目、数据根其他内容。
2457
+ * 幂等:目录不存在也是成功;失败可见(removed=false + reason),不静默吞。 */
2458
+ async projectCraftRemove(args) {
2459
+ const root = dataRoot();
2460
+ const pid = String((args && args.projectId) || '');
2461
+ if (!root) return { ok: false, error: '未设置数据根目录', projectId: pid };
2462
+ if (!pid) return { ok: false, error: '缺少 projectId', projectId: pid };
2463
+ const meta = readDataRootMeta();
2464
+ const entry = meta.projects.find((p) => p.id === pid) || null;
2465
+ const folder = entry ? entry.folder : sanitizeFolderName(pid);
2466
+ const craftDir = path.join(root, 'projects', folder, 'craft');
2467
+ // 防御:拼出来的必须是 <root>/projects/<folder>/craft,任何异常形态都拒绝执行
2468
+ if (path.resolve(craftDir).toLowerCase() !== craftDir.toLowerCase() || !craftDir.toLowerCase().includes((path.join(root, 'projects')).toLowerCase())) {
2469
+ return { ok: false, error: '拒绝执行:目标目录形态异常', projectId: pid, craftDir };
2470
+ }
2471
+ if (!existsSync(craftDir)) return { ok: true, removed: false, existed: false, craftDir, projectId: pid };
2472
+ try {
2473
+ rmrf(craftDir);
2474
+ const gone = !existsSync(craftDir);
2475
+ return { ok: gone, removed: gone, existed: true, craftDir, projectId: pid, error: gone ? null : '目录仍存在(可能被占用)' };
2476
+ } catch (e) {
2477
+ return { ok: false, removed: false, existed: true, craftDir, projectId: pid, error: String((e && e.message) || e) };
2478
+ }
2479
+ },
2480
+ // 存储实况:一眼看清"是否真在用 SQLite 库、库在哪、多少数据"(客户端设置页显示)
2481
+ async dbStats() {
2482
+ const root = dataRoot();
2483
+ const dbPath = root ? path.join(root, 'canvas.db') : null;
2484
+ const out = {
2485
+ ok: true, sqlite: !!SQLITE, store: sqlActive() ? 'sqlite' : (root ? 'json' : 'none'),
2486
+ dataRoot: root, dbPath, dbExists: false, dbBytes: 0, walBytes: 0, projects: 0, assets: 0, fts: null,
2487
+ };
2488
+ if (dbPath) {
2489
+ try { const st = statSync(dbPath); out.dbExists = st.isFile(); out.dbBytes = st.size; } catch { /* 无库 */ }
2490
+ try { const st2 = statSync(dbPath + '-wal'); out.walBytes = st2.size; } catch { /* 无 WAL */ }
2491
+ }
2492
+ if (sqlActive()) {
2493
+ try {
2494
+ const db = getDb();
2495
+ out.projects = db.prepare('SELECT COUNT(*) AS n FROM projects').get().n;
2496
+ out.assets = db.prepare('SELECT COUNT(*) AS n FROM assets').get().n;
2497
+ out.fts = metaGet(db, 'fts_tokenizer');
2498
+ } catch (e) { out.error = String((e && e.message) || e); }
2499
+ }
2500
+ return out;
2501
+ },
2226
2502
  async dataRootSet(args) {
2227
2503
  return setDataRoot((args && args.root) ?? '');
2228
2504
  },
@@ -2318,7 +2594,7 @@ export function apply(ctx) {
2318
2594
  const next = { ...cur, ...(patch.name !== undefined ? { name: String(patch.name).slice(0, 80) } : {}), ...(patch.category !== undefined ? { category: String(patch.category || 'general') } : {}) };
2319
2595
  return writeAssetIndex(String(next.projectId), '', [next]);
2320
2596
  },
2321
- async objectsGc() { return dbObjectsGc(); },
2597
+ // CAS 对象库 GC 已随写路径退役(IC-08):见 dbObjectsGc 位置的说明。
2322
2598
  // 一键迁移/对齐:旧 pid 文件夹 → 项目名文件夹 + 全部项目文档镜像(v1 JSON 模式专用)
2323
2599
  async dataRootMigrate() {
2324
2600
  if (sqlActive()) return { ok: true, skipped: 'sqlite(数据目录即主存储,无需布局迁移)' };
@@ -2542,7 +2818,7 @@ export function apply(ctx) {
2542
2818
  additionalProperties: false,
2543
2819
  },
2544
2820
  output: {
2545
- schema: { type: 'object', properties: { ok: { type: 'boolean' }, text: { type: 'string' }, status: { type: 'string' }, assetId: { type: 'string' }, warnings: { type: 'array', items: { type: 'string' } }, shots: { type: 'array', items: { type: 'object', properties: { index: { type: 'number' }, title: { type: 'string' }, duration: { type: 'number' }, visual: { type: 'string' }, motion: { type: 'string' }, characters: { type: 'array', items: { type: 'string' } }, scene: { type: 'string' }, props: { type: 'array', items: { type: 'string' } } } } } }, required: ['ok', 'text'] },
2821
+ schema: { type: 'object', properties: { ok: { type: 'boolean' }, text: { type: 'string' }, status: { type: 'string' }, assetId: { type: 'string' }, prompt: { type: 'string' }, ratio: { type: 'string' }, size: { type: 'string' }, duration: { type: 'number' }, resolution: { type: 'string' }, frameMode: { type: 'string' }, imageModel: { type: 'string' }, videoModel: { type: 'string' }, genWarn: { type: 'string' }, chainFrom: { type: 'string' }, seam: { type: 'object', properties: { ok: { type: 'boolean' }, sim: { type: 'number' }, thr: { type: 'number' }, note: { type: 'string' } } }, tailPickAssetId: { type: 'string' }, historyAssetIds: { type: 'array', items: { type: 'string' } }, warnings: { type: 'array', items: { type: 'string' } }, shots: { type: 'array', items: { type: 'object', properties: { index: { type: 'number' }, title: { type: 'string' }, duration: { type: 'number' }, visual: { type: 'string' }, motion: { type: 'string' }, tail: { type: 'string' }, cam: { type: 'string' }, start: { type: 'string' }, seg: { type: 'string' }, characters: { type: 'array', items: { type: 'string' } }, scene: { type: 'string' }, props: { type: 'array', items: { type: 'string' } } } } } }, required: ['ok', 'text'] },
2546
2822
  render: (args, value) => [{ type: 'text', text: String(value.text || '') }],
2547
2823
  },
2548
2824
  timeoutMs: 30000,
@@ -2560,8 +2836,11 @@ export function apply(ctx) {
2560
2836
  lines.push('镜头清单(' + shots.length + ' 镜,总时长 ' + shots.reduce((a, x) => a + (Number(x.duration) || 0), 0) + 's)——视频提示词的镜头序列以此为准:');
2561
2837
  shots.forEach((sh) => {
2562
2838
  const dl = (sh.dialogue || []).map((d) => d.role + ':' + d.line).join(';');
2563
- lines.push(sh.index + '. ' + sh.title + '(' + sh.duration + 's)角色:' + (sh.characters.join('/') || '-') + '|场景:' + (sh.scene || '-') + '|道具:' + (sh.props.join('/') || '-')
2839
+ lines.push(sh.index + '. ' + sh.title + '(' + sh.duration + 's)' + (sh.seg ? '[' + sh.seg + '] ' : '') + '角色:' + (sh.characters.join('/') || '-') + '|场景:' + (sh.scene || '-') + '|道具:' + (sh.props.join('/') || '-')
2564
2840
  + '\n 画面: ' + sh.visual + '\n 动作: ' + sh.motion
2841
+ + (sh.tail ? '\n 接点尾态(应写进下一镜起始态): ' + sh.tail : '')
2842
+ + (sh.cam ? '\n 运镜延续: ' + sh.cam : '')
2843
+ + (sh.start ? '\n 起始态: ' + sh.start : '')
2565
2844
  + (dl ? '\n 台词: ' + dl : '')
2566
2845
  + (sh.sfx ? '\n 音效: ' + sh.sfx : ''));
2567
2846
  });
@@ -2572,7 +2851,104 @@ export function apply(ctx) {
2572
2851
  lines.push('一致性告警(' + warnings.length + ' 条,须逐条 canvas_update_bible 补全,名称逐字一致):');
2573
2852
  warnings.forEach((w) => lines.push(' · ' + w));
2574
2853
  }
2575
- return { ok: true, status: s.status, assetId: s.assetId || undefined, warnings: warnings.length ? warnings : undefined, shots: shots.length ? shots : undefined, text: lines.join('\n') };
2854
+ // 生成节点的对话框提示词与参数:对话侧据此精准改写(canvas_update_node),不必删节点重建
2855
+ if (s.prompt) lines.push('对话框提示词:\n' + s.prompt);
2856
+ const params = [];
2857
+ if (s.ratio) params.push('比例 ' + s.ratio);
2858
+ if (s.size) params.push('清晰度 ' + s.size);
2859
+ if (s.duration != null) params.push('时长 ' + s.duration + 's');
2860
+ if (s.resolution) params.push('分辨率 ' + s.resolution);
2861
+ if (s.frameMode) params.push('衔接 ' + s.frameMode);
2862
+ if (s.count != null && s.count > 0) params.push('镜头数 ' + s.count);
2863
+ if (s.imageModel) params.push('图片模型 ' + s.imageModel);
2864
+ if (s.videoModel) params.push('视频模型 ' + s.videoModel);
2865
+ if (params.length) lines.push('生成参数: ' + params.join(' · '));
2866
+ if (s.historyAssetIds.length) lines.push('历史产物(' + s.historyAssetIds.length + ' 条,最新在最后): ' + s.historyAssetIds.join(', '));
2867
+ if (s.genWarn) lines.push('生成告警: ' + s.genWarn);
2868
+ // 分镜衔接状态:续帧来源 + 接缝校验 + 最佳选帧(对话侧汇报成片质量时要一并说明)
2869
+ if (s.chainFrom) lines.push('续帧: ' + (s.chainFrom === 'wired' ? '首帧来自 frame 连线(上一段尾帧)' : '自动取上一段视频的尾帧作首帧'));
2870
+ if (s.seam) lines.push('接缝校验: ' + (s.seam.ok ? '通过' : '偏跳') + (s.seam.sim != null ? '(相似度 ' + s.seam.sim + ',阈值 ' + s.seam.thr + ')' : '') + (s.seam.note ? ' — ' + s.seam.note : ''));
2871
+ if (s.tailPick && s.tailPickAssetId) lines.push('尾帧最佳选帧: ' + Number(s.tailPick.t || 0).toFixed(2) + 's 处(得分 ' + s.tailPick.score + ')资产 ' + s.tailPickAssetId);
2872
+ return {
2873
+ ok: true, status: s.status, assetId: s.assetId || undefined,
2874
+ prompt: s.prompt || undefined, ratio: s.ratio || undefined, size: s.size || undefined,
2875
+ duration: s.duration == null ? undefined : s.duration, resolution: s.resolution || undefined,
2876
+ frameMode: s.frameMode || undefined, imageModel: s.imageModel || undefined, videoModel: s.videoModel || undefined,
2877
+ genWarn: s.genWarn || undefined,
2878
+ chainFrom: s.chainFrom || undefined, seam: s.seam || undefined,
2879
+ tailPickAssetId: s.tailPickAssetId || undefined,
2880
+ historyAssetIds: s.historyAssetIds.length ? s.historyAssetIds : undefined,
2881
+ warnings: warnings.length ? warnings : undefined, shots: shots.length ? shots : undefined,
2882
+ text: lines.join('\n'),
2883
+ };
2884
+ },
2885
+ });
2886
+ reg({
2887
+ name: 'canvas_update_node',
2888
+ description: '修改无限画布上**已有节点**的字段(改提示词、改比例/清晰度/时长/分辨率等参数)——"改节点"的唯一入口,改完不必删节点重建。可改字段:prompt(图片/视频节点的对话框提示词)、idea/output(剧本节点)、text(文本节点的正文)、script/requirement(分镜表的剧本来源与额外要求)、shots(分镜镜头清单,JSON 数组)、label(节点名)、ratio、size、duration、resolution、frameMode、count、imageModel、videoModel、model、cont(分镜表「分镜衔接」开关,默认 true;关掉后拆镜不写衔接字段、视频不自动续帧)。结构字段(id/type/坐标/宽高)不可改。改完用 canvas_generate_node 触发生成。**前提**:先用 canvas_node_status 读到当前内容,再只传要改的字段。**注意 shots 是整表替换**:只改一镜也要先把 canvas_node_status 返回的全部 shots 取回、改完再整体传回;否则其它镜的 tail/cam/seg 会被冲掉(服务端会按镜序兜底保留,但不要依赖它)。',
2889
+ parameters: {
2890
+ type: 'object',
2891
+ properties: {
2892
+ projectId: { type: 'string', description: '项目 id' },
2893
+ nodeId: { type: 'string', description: '节点 id(来自 canvas_list_projects)' },
2894
+ patch: {
2895
+ type: 'object',
2896
+ description: '要修改的字段(只传需要改的,其余保持不动)',
2897
+ properties: {
2898
+ prompt: { type: 'string', description: '图片/视频生成节点的提示词(对话框内容)' },
2899
+ idea: { type: 'string', description: '剧本节点的创作意图' },
2900
+ output: { type: 'string', description: '剧本节点的成稿' },
2901
+ text: { type: 'string', description: '文本节点的正文' },
2902
+ script: { type: 'string', description: '分镜表的剧本来源' },
2903
+ requirement: { type: 'string', description: '分镜表的额外要求' },
2904
+ label: { type: 'string', description: '节点名(≤40 字)' },
2905
+ ratio: { type: 'string', description: '比例,如 16:9 / 9:16 / 1:1' },
2906
+ size: { type: 'string', description: '图片清晰度:1K / 2K / 4K' },
2907
+ duration: { type: 'number', description: '视频时长(秒,正数)' },
2908
+ resolution: { type: 'string', description: '视频分辨率,如 720P / 1080P / 2K' },
2909
+ frameMode: { type: 'string', description: '衔接模式:auto / hard / off' },
2910
+ count: { type: 'number', description: '分镜表镜头数(0=自动)' },
2911
+ imageModel: { type: 'string', description: '图片模型(供应商|模型引用)' },
2912
+ videoModel: { type: 'string', description: '视频模型(供应商|模型引用)' },
2913
+ model: { type: 'string', description: '文本模型(剧本/分镜节点)' },
2914
+ shots: { type: 'array', items: { type: 'object' }, description: '分镜镜头清单(整表替换)' },
2915
+ },
2916
+ additionalProperties: false,
2917
+ },
2918
+ },
2919
+ required: ['projectId', 'nodeId', 'patch'],
2920
+ additionalProperties: false,
2921
+ },
2922
+ output: textOutput,
2923
+ timeoutMs: 30000,
2924
+ async execute(args) {
2925
+ const r = canvasUpdateNode(args);
2926
+ const skipped = Array.isArray(r.skipped) && r.skipped.length ? '(已忽略不可改字段:' + r.skipped.join('、') + ')' : '';
2927
+ return { ok: true, projectId: r.projectId, nodeId: r.nodeId, label: r.label || undefined, text: '✅ 已更新节点[' + r.type + ']「' + (r.label || r.nodeId) + '」字段:' + r.applied.join('、') + skipped + '。需要重新生成请调用 canvas_generate_node。' };
2928
+ },
2929
+ });
2930
+ reg({
2931
+ name: 'canvas_generate_node',
2932
+ description: '触发节点执行并(可选)等到结束——"改完提示词 → 点生成 → 拿结果"的一站式入口(等价于点画布上的生成按钮)。执行由画布侧用节点上配置的模型真实运行,**画布页面必须处于打开状态**;等待超时不代表失败(任务仍在继续,可用 canvas_node_status 继续查)。waitSeconds=0 表示只触发不等待;图片通常 30–120s,视频数分钟。',
2933
+ parameters: {
2934
+ type: 'object',
2935
+ properties: {
2936
+ projectId: { type: 'string', description: '项目 id' },
2937
+ nodeId: { type: 'string', description: '节点 id' },
2938
+ waitSeconds: { type: 'number', description: '可选:等待秒数(0=只触发;上限 600)' },
2939
+ },
2940
+ required: ['projectId', 'nodeId'],
2941
+ additionalProperties: false,
2942
+ },
2943
+ output: textOutput,
2944
+ timeoutMs: 660000,
2945
+ async execute(args) {
2946
+ const r = await canvasGenerateNode(args);
2947
+ const head = '🚀 已触发[' + r.type + ']「' + (r.label || r.nodeId) + '」';
2948
+ const tail = r.finished
2949
+ ? (r.ok ? '✅ 已完成(状态 ' + r.status + (r.assetId ? ',产物资产 ' + r.assetId : '') + ')' : '❌ 失败:' + (r.error || r.status))
2950
+ : '⏳ ' + (r.note || '仍在进行');
2951
+ return { ok: r.ok !== false, projectId: r.projectId, nodeId: r.nodeId, status: r.status || undefined, assetId: r.assetId || undefined, text: head + '。' + tail };
2576
2952
  },
2577
2953
  });
2578
2954
  reg({
@@ -2615,7 +2991,10 @@ export function apply(ctx) {
2615
2991
  timeoutMs: 30000,
2616
2992
  async execute(args) {
2617
2993
  const r = await canvasDeleteProject(args);
2618
- return { ok: true, projectId: r.projectId, text: '🗑 已删除项目「' + r.name + '」(id=' + r.projectId + ')。' };
2994
+ // 磁盘清理失败必须可见:rmrf 部分失败(文件锁)时目录残留,同名重建会继承旧文件
2995
+ const diskErrs = r.disk && Array.isArray(r.disk.errors) ? r.disk.errors : [];
2996
+ const warn = diskErrs.length ? '⚠ 磁盘清理有 ' + diskErrs.length + ' 项失败:' + diskErrs.join(';') : '';
2997
+ return { ok: diskErrs.length === 0, warning: diskErrs.length ? diskErrs : undefined, projectId: r.projectId, text: '🗑 已删除项目「' + r.name + '」(id=' + r.projectId + ')。' + (warn ? '\n' + warn : '') };
2619
2998
  },
2620
2999
  });
2621
3000
  reg({
@@ -2938,7 +3317,8 @@ export function apply(ctx) {
2938
3317
  }
2939
3318
  },
2940
3319
  }),
2941
- // CAS 对象库(0.18):POST 上传(内容寻址去重)+ GET 读取(immutable + Range,远程访问与视频拖动友好)
3320
+ // CAS 对象库读路径(0.18):0.18.0–0.18.4 上传的媒体仍在 objects/ 里,必须继续可读。
3321
+ // IC-08 起写路径已退役(POST/PUT 一律 405),只有 GET。
2942
3322
  ctx.webServer.register({
2943
3323
  kind: 'exact',
2944
3324
  path: `/${NAME}/api/object`,
@@ -2946,42 +3326,16 @@ export function apply(ctx) {
2946
3326
  try {
2947
3327
  const root = dataRoot();
2948
3328
  if (!root) throw new Error('未设置数据根目录');
2949
- if (req.method === 'POST' || req.method === 'PUT') {
2950
- const odir = objectsDirFor(root);
2951
- mkdirSync(odir, { recursive: true });
2952
- const tmp = path.join(odir, '.tmp-' + process.pid + '-' + Date.now());
2953
- const hash = createHash('sha256');
2954
- let size = 0;
2955
- const MAX_UPLOAD = 512 * 1024 * 1024;
2956
- await new Promise((resolve, reject) => {
2957
- const ws = createWriteStream(tmp);
2958
- const fail = (err) => {
2959
- try { ws.destroy(); } catch { /* 忽略 */ }
2960
- try { unlinkSync(tmp); } catch { /* 忽略 */ }
2961
- reject(err);
2962
- };
2963
- req.on('data', (c) => { size += c.length; hash.update(c); if (size > MAX_UPLOAD) fail(new Error('文件过大(上限 512MB)')); });
2964
- req.on('error', fail);
2965
- req.on('aborted', () => fail(new Error('上传中断')));
2966
- ws.on('error', fail);
2967
- ws.on('finish', resolve);
2968
- req.pipe(ws);
2969
- });
2970
- const sha = hash.digest('hex');
2971
- const dest = objectAbsFor(root, sha);
2972
- let deduped = false;
2973
- if (existsSync(dest)) {
2974
- try { unlinkSync(tmp); } catch { /* 忽略 */ }
2975
- deduped = true; // 内容已存在:内容寻址天然去重
2976
- } else {
2977
- mkdirSync(path.dirname(dest), { recursive: true });
2978
- renameSync(tmp, dest);
2979
- }
3329
+ // IC-08:写路径已退役——新上传一律经 /api/asset 落项目文件夹。
3330
+ // 明确回 4xx 而不是静默接受:挂着旧版本 JS 的标签页 POST 过来时必须看得见失败。
3331
+ if (req.method !== 'GET') {
3332
+ res.statusCode = 405;
2980
3333
  res.setHeader('content-type', 'application/json; charset=utf-8');
2981
- res.end(JSON.stringify({ ok: true, sha, size, deduped }));
3334
+ res.setHeader('allow', 'GET');
3335
+ res.end(JSON.stringify({ error: '对象库写路径已退役(0.18.5 起新上传走 /api/asset 落项目文件夹);本路由仅支持 GET 读取历史对象' }));
2982
3336
  return;
2983
3337
  }
2984
- if (req.method === 'GET') {
3338
+ {
2985
3339
  const u = new URL(req.url, 'http://localhost');
2986
3340
  const sha = String(u.searchParams.get('sha') || '').toLowerCase();
2987
3341
  if (!/^[0-9a-f]{64}$/.test(sha)) { res.statusCode = 400; res.end('非法 sha256'); return; }
@@ -3014,9 +3368,6 @@ export function apply(ctx) {
3014
3368
  }
3015
3369
  return;
3016
3370
  }
3017
- res.statusCode = 405;
3018
- res.setHeader('content-type', 'application/json; charset=utf-8');
3019
- res.end(JSON.stringify({ error: '仅接受 GET/POST/PUT' }));
3020
3371
  } catch (e) {
3021
3372
  res.statusCode = 400;
3022
3373
  res.setHeader('content-type', 'application/json; charset=utf-8');