@ninkch/dsh-infinite-canvas 0.18.3 → 0.18.5
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 +12 -0
- package/README.zh-CN.md +12 -0
- package/assets/canvas-craft/SKILL.md +78 -4
- package/assets/canvas-studio/SKILL.md +76 -19
- package/client.js +1814 -270
- package/docs/artcraft-handoff.md +59 -36
- package/docs/guide.en.md +12 -0
- package/docs/guide.zh-CN.md +12 -0
- package/docs/images/harness-right-pane.png +0 -0
- package/docs/images/overview-en.png +0 -0
- package/docs/images/overview-zh.png +0 -0
- package/docs/images/panel-assets-en.png +0 -0
- package/docs/images/panel-assets-zh.png +0 -0
- package/docs/images/panel-bible-en.png +0 -0
- package/docs/images/panel-bible-zh.png +0 -0
- package/docs/images/panel-projects-en.png +0 -0
- package/docs/images/panel-projects-zh.png +0 -0
- package/docs/images/panel-settings-en.png +0 -0
- package/docs/images/panel-settings-zh.png +0 -0
- package/docs/images/pipeline-zh.png +0 -0
- package/docs/images/provider-detail.png +0 -0
- package/docs/images/scene1-zh.png +0 -0
- package/docs/images/video-node-refs.png +0 -0
- package/docs/migration-0.18.md +25 -8
- package/docs/release.md +28 -14
- package/index.js +462 -111
- package/package.json +3 -2
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
|
-
|
|
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)+
|
|
187
|
+
// 存储引擎 v2(0.18.0):SQLite(node:sqlite)+ 项目文件夹落盘
|
|
188
188
|
// <dataRoot>/canvas.db 元数据 + 项目文档 = 唯一事实源
|
|
189
|
-
// <dataRoot>/
|
|
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
|
|
508
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1178
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1703
|
-
|
|
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
|
-
|
|
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:
|
|
1841
|
-
status:
|
|
1842
|
-
aiStatus:
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
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:
|
|
1849
|
-
taskQueryUrl:
|
|
1944
|
+
taskId: d.taskId || null,
|
|
1945
|
+
taskQueryUrl: d.taskQueryUrl || null,
|
|
1850
1946
|
// 分镜表:一致性告警随状态返回——对话侧据此逐条补全设定集(缺失则要自动补充,绝不忽略)
|
|
1851
|
-
warnings: Array.isArray(
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
2950
|
-
|
|
2951
|
-
|
|
2952
|
-
|
|
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.
|
|
3334
|
+
res.setHeader('allow', 'GET');
|
|
3335
|
+
res.end(JSON.stringify({ error: '对象库写路径已退役(0.18.5 起新上传走 /api/asset 落项目文件夹);本路由仅支持 GET 读取历史对象' }));
|
|
2982
3336
|
return;
|
|
2983
3337
|
}
|
|
2984
|
-
|
|
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');
|