@ninkch/dsh-infinite-canvas 0.18.6 → 0.18.7
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/assets/canvas-craft/SKILL.md +114 -12
- package/assets/canvas-layout/SKILL.md +6 -3
- package/assets/canvas-studio/SKILL.md +46 -11
- package/client.js +4069 -1147
- package/docs/UI-RULES.md +70 -0
- package/index.js +647 -119
- package/package.json +1 -1
package/index.js
CHANGED
|
@@ -276,12 +276,16 @@ function ensureSchema(db, root) {
|
|
|
276
276
|
}
|
|
277
277
|
}
|
|
278
278
|
|
|
279
|
-
/** 项目文档摘要(列表页用,避免每次列表都解析全量 doc
|
|
279
|
+
/** 项目文档摘要(列表页用,避免每次列表都解析全量 doc)。类型按合并类型输出。 */
|
|
280
280
|
function docSummary(d) {
|
|
281
281
|
const nodes = Object.values(d.nodes || {}).map((n) => ({
|
|
282
|
-
id: n.id, type: n.type, label: (n.data && n.data.label) || '', status: (n.data && n.data.status) || 'idle',
|
|
282
|
+
id: n.id, type: normalizeNodeType(n.type), label: (n.data && n.data.label) || '', status: (n.data && n.data.status) || 'idle',
|
|
283
283
|
}));
|
|
284
|
-
return {
|
|
284
|
+
return {
|
|
285
|
+
nodeIds: Object.keys(d.nodes || {}), nodes, source: d.source || 'app', canvases: canvasRegistry(d),
|
|
286
|
+
// 默认画布名(缺省 '默认画布',旧项目零迁移)与"默认画布已删除"标记(只剩子画布时)
|
|
287
|
+
defaultCanvasName: defaultCanvasNameForApi(d), defaultCanvasDeleted: !defaultCanvasExists(d),
|
|
288
|
+
};
|
|
285
289
|
}
|
|
286
290
|
|
|
287
291
|
/** v1 → v2 一次性迁移:文档按 updatedAt 新者胜合并;资产索引照抄(媒体文件原地保留,rel 指针不动——零风险迁移)。 */
|
|
@@ -747,6 +751,9 @@ function sqlDeleteProjectRows(db, id, root) {
|
|
|
747
751
|
try {
|
|
748
752
|
db.prepare('DELETE FROM assets WHERE project_id = ?').run(id);
|
|
749
753
|
db.prepare('DELETE FROM projects WHERE id = ?').run(id);
|
|
754
|
+
// 多画布:项目下的子画布行随项目一起清掉(否则删项目后画布文档残留成孤行)
|
|
755
|
+
const canvasPattern = String(id).replace(/[\\%_]/g, '\\$&') + CANVAS_ID_SEP + '%';
|
|
756
|
+
db.prepare("DELETE FROM projects WHERE id LIKE ? ESCAPE '\\'").run(canvasPattern);
|
|
750
757
|
} catch (e) { result.errors.push('delete rows: ' + String((e && e.message) || e)); }
|
|
751
758
|
if (root) {
|
|
752
759
|
const seen = new Set();
|
|
@@ -768,6 +775,24 @@ function sqlDeleteProjectRows(db, id, root) {
|
|
|
768
775
|
if (result.errors.length) result.ok = false;
|
|
769
776
|
return result;
|
|
770
777
|
}
|
|
778
|
+
/** 子画布文档镜像:数据目录 projects/<项目名文件夹>/canvases/<canvasId>.json。
|
|
779
|
+
* 子画布共用项目文件夹(资产/设定集按项目维度);失败不影响主存储。 */
|
|
780
|
+
function mirrorCanvasDocToDataRoot(projectId, canvasId, doc) {
|
|
781
|
+
const root = dataRoot();
|
|
782
|
+
if (!root) return;
|
|
783
|
+
const projectDoc = readDocOrNull(projectId) || {};
|
|
784
|
+
let folder = null;
|
|
785
|
+
try { folder = ensureProjectFolder(projectId, projectDoc.name || projectId); } catch { return; }
|
|
786
|
+
if (!folder) return;
|
|
787
|
+
try {
|
|
788
|
+
const dir = path.join(root, 'projects', folder, 'canvases');
|
|
789
|
+
mkdirSync(dir, { recursive: true });
|
|
790
|
+
const f = path.join(dir, String(canvasId) + '.json');
|
|
791
|
+
const tmp = f + '.' + process.pid + '.tmp';
|
|
792
|
+
writeFileSync(tmp, JSON.stringify(doc, null, 1), 'utf8');
|
|
793
|
+
renameSync(tmp, f);
|
|
794
|
+
} catch { /* 镜像失败不影响主存储 */ }
|
|
795
|
+
}
|
|
771
796
|
/** 一次性迁移/对齐数据目录布局:旧 pid 文件夹 → 项目名文件夹,并镜像全部项目文档。 */
|
|
772
797
|
function dataRootMigrate() {
|
|
773
798
|
const root = dataRoot();
|
|
@@ -775,7 +800,7 @@ function dataRootMigrate() {
|
|
|
775
800
|
let ids = [];
|
|
776
801
|
try {
|
|
777
802
|
ids = existsSync(PROJECTS_DIR)
|
|
778
|
-
? readdirSync(PROJECTS_DIR).filter((f) => f.endsWith('.json')).map((f) => f.replace(/\.json$/, ''))
|
|
803
|
+
? readdirSync(PROJECTS_DIR).filter((f) => f.endsWith('.json')).map((f) => f.replace(/\.json$/, '')).filter((id) => !isCanvasDocId(id))
|
|
779
804
|
: [];
|
|
780
805
|
} catch { ids = []; }
|
|
781
806
|
let filesMoved = 0;
|
|
@@ -837,6 +862,7 @@ function dataRootImport() {
|
|
|
837
862
|
? readdirSync(projectsDir, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name)
|
|
838
863
|
: [];
|
|
839
864
|
const imported = [];
|
|
865
|
+
const importedCanvases = [];
|
|
840
866
|
const assets = {};
|
|
841
867
|
for (const folder of dirs) {
|
|
842
868
|
const dir = path.join(projectsDir, folder);
|
|
@@ -856,8 +882,26 @@ function dataRootImport() {
|
|
|
856
882
|
const list = JSON.parse(readFileSync(path.join(dir, 'assets.json'), 'utf8'));
|
|
857
883
|
if (Array.isArray(list)) assets[id] = list;
|
|
858
884
|
} catch { /* 无资产索引 */ }
|
|
885
|
+
// 多画布:画布文档镜像在 projects/<项目文件夹>/canvases/*.json(updatedAt 新者胜)
|
|
886
|
+
try {
|
|
887
|
+
const cvDir = path.join(dir, 'canvases');
|
|
888
|
+
for (const f of readdirSync(cvDir)) {
|
|
889
|
+
if (!f.endsWith('.json')) continue;
|
|
890
|
+
let cdoc = null;
|
|
891
|
+
try { cdoc = JSON.parse(readFileSync(path.join(cvDir, f), 'utf8')); } catch { continue; }
|
|
892
|
+
const cid = normalizeCanvasId(cdoc && cdoc.canvasId);
|
|
893
|
+
if (!cid) continue;
|
|
894
|
+
const target = canvasDocId(id, cid);
|
|
895
|
+
let curC = null;
|
|
896
|
+
try { curC = JSON.parse(readFileSync(projectFile(target), 'utf8')); } catch { /* 本机没有该画布 */ }
|
|
897
|
+
if (!curC || (curC.updatedAt || 0) < (cdoc.updatedAt || 0)) {
|
|
898
|
+
writeIcDoc(target, { ...cdoc, id: target, projectId: id, canvasId: cid, updatedAt: Date.now() });
|
|
899
|
+
importedCanvases.push({ id, canvasId: cid, name: String(cdoc.name || cid) });
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
} catch { /* 无画布镜像 */ }
|
|
859
903
|
}
|
|
860
|
-
return { ok: true, imported, assets };
|
|
904
|
+
return { ok: true, imported, canvases: importedCanvases, assets };
|
|
861
905
|
}
|
|
862
906
|
/** 设定集字段规范化(世界观/风格 + 角色/场景/道具条目,条目可携带链接资产 assetIds)。 */
|
|
863
907
|
function normalizeBible(raw) {
|
|
@@ -977,13 +1021,76 @@ function pickFolderDialog() {
|
|
|
977
1021
|
// 客户端是内容权威(编辑后推送全量文档);Agent 经工具在 Host 侧追加节点;客户端轮询合并。
|
|
978
1022
|
const PROJECTS_DIR = process.env.DSH_IC_PROJECTS_DIR || path.join(os.homedir(), '.dsh', 'infinite-canvas', 'projects');
|
|
979
1023
|
function projectFile(id) {
|
|
980
|
-
|
|
981
|
-
|
|
1024
|
+
const s = String(id || '');
|
|
1025
|
+
const seg = isCanvasDocId(s) ? splitCanvasDocId(s) : { projectId: s, canvasId: null };
|
|
1026
|
+
const okProject = /^[A-Za-z0-9_-]{1,96}$/.test(seg.projectId || '');
|
|
1027
|
+
const okCanvas = seg.canvasId === null || /^[A-Za-z0-9_-]{1,64}$/.test(seg.canvasId || '');
|
|
1028
|
+
if (!okProject || !okCanvas) throw new Error('非法项目 id');
|
|
1029
|
+
return path.join(PROJECTS_DIR, s + '.json');
|
|
1030
|
+
}
|
|
1031
|
+
// ---------- 多画布(项目下子目录 = 独立画布文档)----------
|
|
1032
|
+
// 默认画布:canvasId = null,文档 id 仍是 projectId(旧数据零迁移)。
|
|
1033
|
+
// 子画布:文档 id = `${projectId}__cv__${canvasId}`,项目文档里登记 canvases: [{id,name,createdAt}];
|
|
1034
|
+
// 资产/设定集仍以 projectId 为维度共享,不随画布分割。
|
|
1035
|
+
const CANVAS_ID_SEP = '__cv__';
|
|
1036
|
+
function canvasDocId(projectId, canvasId) {
|
|
1037
|
+
const cid = normalizeCanvasId(canvasId);
|
|
1038
|
+
return cid ? (String(projectId) + CANVAS_ID_SEP + cid) : String(projectId);
|
|
1039
|
+
}
|
|
1040
|
+
function isCanvasDocId(docId) { return String(docId || '').includes(CANVAS_ID_SEP); }
|
|
1041
|
+
function splitCanvasDocId(docId) {
|
|
1042
|
+
const s = String(docId || '');
|
|
1043
|
+
const i = s.indexOf(CANVAS_ID_SEP);
|
|
1044
|
+
if (i < 0) return { projectId: s, canvasId: null };
|
|
1045
|
+
return { projectId: s.slice(0, i), canvasId: s.slice(i + CANVAS_ID_SEP.length) || null };
|
|
1046
|
+
}
|
|
1047
|
+
function normalizeCanvasId(v) {
|
|
1048
|
+
const s = String(v == null ? '' : v).trim();
|
|
1049
|
+
if (!s) return null;
|
|
1050
|
+
if (!/^[A-Za-z0-9_-]{1,64}$/.test(s)) throw new Error('非法画布 id: ' + s);
|
|
1051
|
+
return s;
|
|
1052
|
+
}
|
|
1053
|
+
function canvasRegistry(projectDoc) {
|
|
1054
|
+
return Array.isArray(projectDoc && projectDoc.canvases)
|
|
1055
|
+
? projectDoc.canvases.filter((c) => c && c.id).map((c) => ({ id: String(c.id), name: String(c.name || '').slice(0, 80), createdAt: Number(c.createdAt) || 0 }))
|
|
1056
|
+
: [];
|
|
1057
|
+
}
|
|
1058
|
+
/** 规范化画布清单(Host 侧唯一写入口):丢弃非法 id、同名去重、最多 200 个画布。 */
|
|
1059
|
+
function sanitizeCanvases(list) {
|
|
1060
|
+
const out = [];
|
|
1061
|
+
const seen = new Set();
|
|
1062
|
+
for (const raw of (Array.isArray(list) ? list : [])) {
|
|
1063
|
+
let cid;
|
|
1064
|
+
try { cid = normalizeCanvasId(raw && raw.id); } catch { continue; }
|
|
1065
|
+
if (!cid || seen.has(cid)) continue;
|
|
1066
|
+
seen.add(cid);
|
|
1067
|
+
out.push({ id: cid, name: String((raw && raw.name) || cid).slice(0, 80), createdAt: Number(raw && raw.createdAt) || Date.now() });
|
|
1068
|
+
}
|
|
1069
|
+
return out.slice(0, 200);
|
|
1070
|
+
}
|
|
1071
|
+
// ---- 默认画布(canvasId=null):名字存项目文档 defaultCanvasName;删除后 defaultCanvasDeleted=true ----
|
|
1072
|
+
const DEFAULT_CANVAS_NAME = '默认画布';
|
|
1073
|
+
/** 默认画布名:缺省 '默认画布'(旧项目无该字段 → 零迁移)。 */
|
|
1074
|
+
function defaultCanvasNameOf(doc) {
|
|
1075
|
+
const n = String((doc && doc.defaultCanvasName) || '').trim();
|
|
1076
|
+
return (n || DEFAULT_CANVAS_NAME).slice(0, 80);
|
|
1077
|
+
}
|
|
1078
|
+
/** 默认画布是否还存在(尚未删除)。 */
|
|
1079
|
+
function defaultCanvasExists(doc) { return !(doc && doc.defaultCanvasDeleted === true); }
|
|
1080
|
+
/** API 返回的默认画布名:默认画布已删除 → 空字符串(storage 里已清空)。 */
|
|
1081
|
+
function defaultCanvasNameForApi(doc) { return defaultCanvasExists(doc) ? defaultCanvasNameOf(doc) : ''; }
|
|
1082
|
+
/** 项目画布总数 = 默认画布(若还在)+ 子画布。 */
|
|
1083
|
+
function canvasTotal(projectDoc) { return canvasRegistry(projectDoc).length + (defaultCanvasExists(projectDoc) ? 1 : 0); }
|
|
1084
|
+
/** 项目下全部画布名(默认画布 + 子画布):重名判定用。 */
|
|
1085
|
+
function allCanvasNames(projectDoc) {
|
|
1086
|
+
const names = defaultCanvasExists(projectDoc) ? [defaultCanvasNameOf(projectDoc)] : [];
|
|
1087
|
+
return names.concat(canvasRegistry(projectDoc).map((c) => c.name));
|
|
982
1088
|
}
|
|
983
1089
|
function projectsList() {
|
|
984
1090
|
if (sqlActive()) {
|
|
985
1091
|
const db = getDb();
|
|
986
|
-
const rows = db.prepare('SELECT id, name, created_at, updated_at, bible_updated_at, summary, tombstones FROM projects ORDER BY updated_at DESC').all()
|
|
1092
|
+
const rows = db.prepare('SELECT id, name, created_at, updated_at, bible_updated_at, summary, tombstones FROM projects ORDER BY updated_at DESC').all()
|
|
1093
|
+
.filter((r) => !isCanvasDocId(r.id)); // 子画布行不是项目:不出现在项目列表(画布清单在项目文档里)
|
|
987
1094
|
return rows.map((r) => {
|
|
988
1095
|
let sum = {};
|
|
989
1096
|
try { sum = JSON.parse(r.summary || '{}'); } catch { /* 旧行/损坏摘要 */ }
|
|
@@ -991,8 +1098,13 @@ function projectsList() {
|
|
|
991
1098
|
try { tombs = JSON.parse(r.tombstones || '[]'); } catch { /* 旧行 */ }
|
|
992
1099
|
return {
|
|
993
1100
|
id: r.id, name: r.name, createdAt: r.created_at || 0, updatedAt: r.updated_at || 0,
|
|
994
|
-
source: sum.source || 'app', nodeIds: sum.nodeIds || [],
|
|
1101
|
+
source: sum.source || 'app', nodeIds: sum.nodeIds || [],
|
|
1102
|
+
// 旧版本摘要里可能是旧类型名:输出时按合并类型归一(image / video)
|
|
1103
|
+
nodes: (sum.nodes || []).map((n) => ({ ...n, type: normalizeNodeType(n && n.type) })),
|
|
995
1104
|
tombstones: Array.isArray(tombs) ? tombs : [], bibleUpdatedAt: r.bible_updated_at || 0,
|
|
1105
|
+
canvases: canvasRegistry(sum),
|
|
1106
|
+
defaultCanvasName: defaultCanvasNameForApi(sum),
|
|
1107
|
+
defaultCanvasDeleted: !defaultCanvasExists(sum),
|
|
996
1108
|
};
|
|
997
1109
|
});
|
|
998
1110
|
}
|
|
@@ -1001,6 +1113,7 @@ function projectsList() {
|
|
|
1001
1113
|
const list = [];
|
|
1002
1114
|
for (const f of files) {
|
|
1003
1115
|
const id = f.slice(0, -5);
|
|
1116
|
+
if (isCanvasDocId(id)) continue; // 子画布文档不是项目(由所属项目的 canvases 清单表达)
|
|
1004
1117
|
try {
|
|
1005
1118
|
let d;
|
|
1006
1119
|
try {
|
|
@@ -1011,10 +1124,10 @@ function projectsList() {
|
|
|
1011
1124
|
continue;
|
|
1012
1125
|
}
|
|
1013
1126
|
const nodes = Object.values(d.nodes || {}).map((n) => ({
|
|
1014
|
-
id: n.id, type: n.type, label: (n.data && n.data.label) || '',
|
|
1127
|
+
id: n.id, type: normalizeNodeType(n.type), label: (n.data && n.data.label) || '',
|
|
1015
1128
|
status: (n.data && n.data.status) || 'idle',
|
|
1016
1129
|
}));
|
|
1017
|
-
list.push({ id: d.id || id, name: d.name || id, createdAt: d.createdAt || 0, updatedAt: d.updatedAt || 0, source: d.source || 'app', nodeIds: Object.keys(d.nodes || {}), nodes, tombstones: d.tombstones || [], bibleUpdatedAt: d.bibleUpdatedAt || 0 });
|
|
1130
|
+
list.push({ id: d.id || id, name: d.name || id, createdAt: d.createdAt || 0, updatedAt: d.updatedAt || 0, source: d.source || 'app', nodeIds: Object.keys(d.nodes || {}), nodes, tombstones: d.tombstones || [], bibleUpdatedAt: d.bibleUpdatedAt || 0, canvases: canvasRegistry(d), defaultCanvasName: defaultCanvasNameForApi(d), defaultCanvasDeleted: !defaultCanvasExists(d) });
|
|
1018
1131
|
} catch { /* 单文件损坏跳过 */ }
|
|
1019
1132
|
}
|
|
1020
1133
|
return list.sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0));
|
|
@@ -1024,9 +1137,13 @@ async function projectsPut(args) {
|
|
|
1024
1137
|
// 项目删除墓碑(长效):删除后迟到的全量推送(450ms 防抖竞速 / 启动 pushAllProjectsToHost 快照)
|
|
1025
1138
|
// 一律拒收——否则项目原地复活(资产/设定集已清,复活即残废)。客户端不会复用项目 id,拒收永不误伤。
|
|
1026
1139
|
if (deletedProjectIds().includes(id)) return { ok: true, id, deleted: true };
|
|
1027
|
-
|
|
1140
|
+
// 多画布:canvasId 缺省 = 项目默认画布(文档 id 仍是 projectId,旧数据零迁移);
|
|
1141
|
+
// 子画布写到独立文档 id,项目文档只保留名称/设定集/画布清单/默认画布图。
|
|
1142
|
+
const canvasId = normalizeCanvasId(args && args.canvasId);
|
|
1143
|
+
const targetId = canvasId ? canvasDocId(id, canvasId) : id;
|
|
1144
|
+
projectFile(targetId); // 非法 id 早失败(画布文档 id 走同一套校验)
|
|
1028
1145
|
const doc = (args && args.doc) || {};
|
|
1029
|
-
let prev = readDocOrNull(
|
|
1146
|
+
let prev = readDocOrNull(targetId) || {};
|
|
1030
1147
|
// 墓碑:对话删除的节点 + 客户端本地删除的节点(客户端删除时也会写入 tombstones)。
|
|
1031
1148
|
// tombstoneAt 记录每个墓碑的创建时刻:只有**推送时间戳(doc.ts)不早于墓碑时刻**的推送
|
|
1032
1149
|
// 才被认定为"撤销恢复"允许清墓碑;陈旧推送(快照早于删除、无 ts 或 ts 更老)不得借
|
|
@@ -1112,8 +1229,7 @@ async function projectsPut(args) {
|
|
|
1112
1229
|
const keptEdgeTomb = edgeTombstones.slice(-400);
|
|
1113
1230
|
const pruneStamp = (m, keep) => { const o = {}; for (const k of keep) { if (m[k] !== undefined) o[k] = m[k]; } return o; };
|
|
1114
1231
|
const out = {
|
|
1115
|
-
id,
|
|
1116
|
-
name: String((args && args.name) || prev.name || id).slice(0, 80),
|
|
1232
|
+
id: targetId,
|
|
1117
1233
|
createdAt: prev.createdAt || (doc && doc.createdAt) || Date.now(),
|
|
1118
1234
|
updatedAt: Date.now(),
|
|
1119
1235
|
source: prev.source || 'app',
|
|
@@ -1125,8 +1241,27 @@ async function projectsPut(args) {
|
|
|
1125
1241
|
tombstoneAt: pruneStamp(tombstoneAt, keptTomb),
|
|
1126
1242
|
edgeTombstoneAt: pruneStamp(edgeTombstoneAt, keptEdgeTomb),
|
|
1127
1243
|
};
|
|
1244
|
+
if (canvasId) {
|
|
1245
|
+
out.projectId = id;
|
|
1246
|
+
out.canvasId = canvasId; // 子画布文档:不带 name/canvases/bible(项目级字段只在项目文档)
|
|
1247
|
+
} else {
|
|
1248
|
+
out.name = String((args && args.name) || prev.name || id).slice(0, 80);
|
|
1249
|
+
out.canvases = sanitizeCanvases((args && args.canvases) !== undefined ? args.canvases : prev.canvases);
|
|
1250
|
+
// 默认画布的项目级字段:名字(缺省 '默认画布',不写字段即零迁移)与"已删除"标记
|
|
1251
|
+
const dnRaw = (args && args.defaultCanvasName !== undefined) ? args.defaultCanvasName : prev.defaultCanvasName;
|
|
1252
|
+
if (dnRaw !== undefined && dnRaw !== null && String(dnRaw).trim()) out.defaultCanvasName = String(dnRaw).trim().slice(0, 80);
|
|
1253
|
+
const ddRaw = (args && args.defaultCanvasDeleted !== undefined) ? args.defaultCanvasDeleted : prev.defaultCanvasDeleted;
|
|
1254
|
+
if (ddRaw) out.defaultCanvasDeleted = true;
|
|
1255
|
+
else if (prev.defaultCanvasDeleted) out.defaultCanvasDeleted = true; // 客户端未带该字段时保留"已删除"(防旧推送把默认画布复活)
|
|
1256
|
+
}
|
|
1128
1257
|
// 设定集随文档一起同步(世界观/风格/角色/场景/道具):对话侧可读写
|
|
1129
|
-
|
|
1258
|
+
let canvasBible = null;
|
|
1259
|
+
if (canvasId) {
|
|
1260
|
+
// 设定集是项目级:子画布推送里携带的 bible 落到项目文档(画布文档不存 bible)
|
|
1261
|
+
const projectDoc = readDocOrNull(id) || {};
|
|
1262
|
+
if (doc.bible !== undefined) canvasBible = { bible: normalizeBible(doc.bible), bibleUpdatedAt: Date.now() };
|
|
1263
|
+
else if (projectDoc.bible !== undefined) canvasBible = { bible: projectDoc.bible, bibleUpdatedAt: projectDoc.bibleUpdatedAt || 0 };
|
|
1264
|
+
} else if (doc.bible !== undefined) {
|
|
1130
1265
|
out.bible = normalizeBible(doc.bible);
|
|
1131
1266
|
out.bibleUpdatedAt = Date.now();
|
|
1132
1267
|
} else if (prev.bible !== undefined) {
|
|
@@ -1134,23 +1269,47 @@ async function projectsPut(args) {
|
|
|
1134
1269
|
out.bibleUpdatedAt = prev.bibleUpdatedAt || 0;
|
|
1135
1270
|
}
|
|
1136
1271
|
// 设定集有更新时重算全部分镜表的一致性告警(客户端侧设定集编辑也走这条路径)
|
|
1137
|
-
|
|
1272
|
+
const bibleForWarn = canvasId ? (canvasBible && canvasBible.bible) : out.bible;
|
|
1273
|
+
if (bibleForWarn !== undefined) {
|
|
1138
1274
|
try {
|
|
1139
1275
|
const assetNames = readAssetIndexNames(id);
|
|
1140
1276
|
for (const nd of Object.values(merged)) {
|
|
1141
1277
|
if (nd && nd.type === 'storyboard' && Array.isArray(nd.data && nd.data.shots) && nd.data.shots.length) {
|
|
1142
|
-
const nextW = storyboardConsistencyWarnings(
|
|
1278
|
+
const nextW = storyboardConsistencyWarnings(bibleForWarn, nd.data.shots, assetNames);
|
|
1143
1279
|
if (JSON.stringify(nextW) !== JSON.stringify(nd.data.warnings || [])) nd.data.warnings = nextW;
|
|
1144
1280
|
}
|
|
1145
1281
|
}
|
|
1146
1282
|
} catch { /* 重算失败不影响保存 */ }
|
|
1147
1283
|
}
|
|
1148
1284
|
mkdirSync(PROJECTS_DIR, { recursive: true });
|
|
1149
|
-
writeIcDoc(
|
|
1150
|
-
|
|
1285
|
+
writeIcDoc(targetId, out); // 统一走原子写 + 数据目录镜像(此前直写绕过了镜像与原子性)
|
|
1286
|
+
if (canvasId) {
|
|
1287
|
+
// 项目级字段(名称/设定集/画布清单)始终只写项目文档:项目文档缺失时补一份最小项目
|
|
1288
|
+
const projectDoc = readDocOrNull(id);
|
|
1289
|
+
if (!projectDoc) {
|
|
1290
|
+
const canvases = sanitizeCanvases((args && args.canvases) || []);
|
|
1291
|
+
if (!canvases.some((c) => c.id === canvasId)) canvases.push({ id: canvasId, name: canvasId, createdAt: Date.now() });
|
|
1292
|
+
writeIcDoc(id, {
|
|
1293
|
+
id, name: String((args && args.name) || id).slice(0, 80), createdAt: Date.now(), updatedAt: Date.now(),
|
|
1294
|
+
nodes: {}, edges: [], view: { x: 60, y: 40, scale: 1 }, tombstones: [], canvases,
|
|
1295
|
+
defaultCanvasName: defaultCanvasNameOf({ defaultCanvasName: args && args.defaultCanvasName }),
|
|
1296
|
+
...(canvasBible ? { bible: canvasBible.bible, bibleUpdatedAt: canvasBible.bibleUpdatedAt } : {}),
|
|
1297
|
+
});
|
|
1298
|
+
} else {
|
|
1299
|
+
const patch = {};
|
|
1300
|
+
if (canvasBible) { patch.bible = canvasBible.bible; patch.bibleUpdatedAt = canvasBible.bibleUpdatedAt; }
|
|
1301
|
+
if (args && args.canvases !== undefined) patch.canvases = sanitizeCanvases(args.canvases);
|
|
1302
|
+
if (Object.keys(patch).length) patchProjectDoc(id, patch);
|
|
1303
|
+
}
|
|
1304
|
+
}
|
|
1305
|
+
return { ok: true, id, ...(canvasId ? { canvasId } : {}), updatedAt: out.updatedAt };
|
|
1151
1306
|
}
|
|
1152
1307
|
async function projectsGet(args) {
|
|
1153
|
-
|
|
1308
|
+
const id = String((args && args.id) || '');
|
|
1309
|
+
const canvasId = normalizeCanvasId(args && args.canvasId);
|
|
1310
|
+
if (!canvasId) return readDoc(id);
|
|
1311
|
+
// 默认画布/子画布同一入口:子画布返回独立文档 + 项目级字段(名称/设定集/画布清单)
|
|
1312
|
+
return readCanvasDoc(id, canvasId);
|
|
1154
1313
|
}
|
|
1155
1314
|
/** 长效项目删除墓碑(区别于 projectsSync 的一次性 deletedProjects 通知信号):
|
|
1156
1315
|
* 删除后任何迟到的 projectsPut 都被拒收,直到 dataRootImport 显式恢复该 id。 */
|
|
@@ -1244,6 +1403,7 @@ async function projectsDelete(args) {
|
|
|
1244
1403
|
cleanup = sqlDeleteProjectRows(getDb(), id, dataRoot());
|
|
1245
1404
|
} else {
|
|
1246
1405
|
try { unlinkSync(projectFile(id)); } catch { /* 已不存在视为成功 */ }
|
|
1406
|
+
try { deleteCanvasDocsForProject(id); } catch { /* 子画布清理失败不影响删除 */ }
|
|
1247
1407
|
try { removeDataRootProject(id); } catch { /* 数据目录镜像清理失败不影响删除 */ }
|
|
1248
1408
|
}
|
|
1249
1409
|
try { addDeletedProjectId(id); } catch { /* 墓碑写失败不影响删除 */ }
|
|
@@ -1267,6 +1427,100 @@ function defaultLabel(text) {
|
|
|
1267
1427
|
const line = String(text || '').trim().split(/\r?\n/)[0] || '';
|
|
1268
1428
|
return line.length > 18 ? line.slice(0, 18) + '…' : (line || '未命名');
|
|
1269
1429
|
}
|
|
1430
|
+
/** 旧节点类型 → 合并类型(与客户端 client.js 同一映射):
|
|
1431
|
+
* image-gen / image-asset → image,video-gen / video-asset → video。
|
|
1432
|
+
* 旧项目文档读入时统一归一(只改 type,节点 data / 连线端点原样保留:有 assetId 就是资产,有 prompt 就是提示词)。 */
|
|
1433
|
+
const LEGACY_NODE_TYPES = { 'image-gen': 'image', 'image-asset': 'image', 'video-gen': 'video', 'video-asset': 'video' };
|
|
1434
|
+
function normalizeNodeType(type) { return LEGACY_NODE_TYPES[type] || type; }
|
|
1435
|
+
function normalizeNode(n) {
|
|
1436
|
+
if (n && typeof n === 'object') {
|
|
1437
|
+
const t = normalizeNodeType(n.type);
|
|
1438
|
+
if (t !== n.type) n.type = t;
|
|
1439
|
+
}
|
|
1440
|
+
return n;
|
|
1441
|
+
}
|
|
1442
|
+
function normalizeDocNodes(doc) {
|
|
1443
|
+
if (!doc || typeof doc !== 'object') return doc;
|
|
1444
|
+
for (const n of Object.values(doc.nodes || {})) {
|
|
1445
|
+
normalizeNode(n);
|
|
1446
|
+
normalizeMediaNodeSize(n); // 与客户端同一几何规则:媒体节点外框 = 预览区 + 标题栏 +(有结果时)操作栏
|
|
1447
|
+
}
|
|
1448
|
+
return doc;
|
|
1449
|
+
}
|
|
1450
|
+
const MERGED_NODE_TYPES = ['image', 'video'];
|
|
1451
|
+
// ---- 媒体节点几何(必须与 client.js 的 NODE_HDR_H / NODE_FOOT_H / MEDIA_PREVIEW_SHORT / MEDIA_MAX_LONG 一致)----
|
|
1452
|
+
// 媒体节点三段:标题栏(固定)+ 预览区(盒比 = 画面比例,短边 360、长边 ≤800)+ 操作栏(固定,有结果才有)。
|
|
1453
|
+
const NODE_HDR_H = 32;
|
|
1454
|
+
const NODE_FOOT_H = 32;
|
|
1455
|
+
const MEDIA_PREVIEW_SHORT = 360;
|
|
1456
|
+
const MEDIA_MAX_LONG = 800;
|
|
1457
|
+
const RATIOS = ['16:9', '9:16', '1:1', '4:3', '3:4', '21:9'];
|
|
1458
|
+
const ratioIsValid = (r) => {
|
|
1459
|
+
const [rw, rh] = String(r || '').split(':').map(Number);
|
|
1460
|
+
return rw > 0 && rh > 0;
|
|
1461
|
+
};
|
|
1462
|
+
/** 画面比例 → 预览区尺寸(不含标题栏/操作栏)。 */
|
|
1463
|
+
function mediaPreviewSize(ratio) {
|
|
1464
|
+
const [rw, rh] = String(ratio == null ? '' : ratio).split(':').map(Number);
|
|
1465
|
+
if (!rw || !rh) return { w: MEDIA_PREVIEW_SHORT, h: MEDIA_PREVIEW_SHORT };
|
|
1466
|
+
let w; let h;
|
|
1467
|
+
if (rw >= rh) { h = MEDIA_PREVIEW_SHORT; w = Math.round(MEDIA_PREVIEW_SHORT * rw / rh); }
|
|
1468
|
+
else { w = MEDIA_PREVIEW_SHORT; h = Math.round(MEDIA_PREVIEW_SHORT * rh / rw); }
|
|
1469
|
+
const long = Math.max(w, h);
|
|
1470
|
+
if (long > MEDIA_MAX_LONG) { const k = MEDIA_MAX_LONG / long; w = Math.round(w * k); h = Math.round(h * k); }
|
|
1471
|
+
return { w, h };
|
|
1472
|
+
}
|
|
1473
|
+
/** 媒体节点外框尺寸 = 预览区尺寸 + 固定栏高度(hasFooter 只在有 assetId 时为 true)。 */
|
|
1474
|
+
function mediaNodeSize(ratio, hasFooter) {
|
|
1475
|
+
const pv = mediaPreviewSize(ratio);
|
|
1476
|
+
return { w: pv.w, h: pv.h + NODE_HDR_H + (hasFooter ? NODE_FOOT_H : 0) };
|
|
1477
|
+
}
|
|
1478
|
+
/** 像素尺寸 → 比例(吸附到 RATIOS 档,3% 容差;否则约分)。 */
|
|
1479
|
+
function ratioFromDims(w, h) {
|
|
1480
|
+
const aw = Number(w) || 0;
|
|
1481
|
+
const ah = Number(h) || 0;
|
|
1482
|
+
if (!(aw > 0 && ah > 0)) return '16:9';
|
|
1483
|
+
const want = aw / ah;
|
|
1484
|
+
let best = null;
|
|
1485
|
+
for (const r of RATIOS) {
|
|
1486
|
+
const [rw, rh] = r.split(':').map(Number);
|
|
1487
|
+
const diff = Math.abs(rw / rh - want) / want;
|
|
1488
|
+
if (!best || diff < best.diff) best = { ratio: r, diff };
|
|
1489
|
+
}
|
|
1490
|
+
if (best && best.diff <= 0.03) return best.ratio;
|
|
1491
|
+
const gcd = (a, b) => (b ? gcd(b, a % b) : a);
|
|
1492
|
+
const k = gcd(Math.round(aw), Math.round(ah)) || 1;
|
|
1493
|
+
return Math.round(aw / k) + ':' + Math.round(ah / k);
|
|
1494
|
+
}
|
|
1495
|
+
/** 媒体节点应使用的画面比例:记录值 → 资产实测尺寸 → 默认 16:9。 */
|
|
1496
|
+
function mediaRatioOf(n) {
|
|
1497
|
+
const d = (n && n.data) || {};
|
|
1498
|
+
if (ratioIsValid(d.ratio)) return d.ratio;
|
|
1499
|
+
const aw = Number(d.assetW);
|
|
1500
|
+
const ah = Number(d.assetH);
|
|
1501
|
+
if (aw > 0 && ah > 0) return ratioFromDims(aw, ah);
|
|
1502
|
+
return '16:9';
|
|
1503
|
+
}
|
|
1504
|
+
/** 文档读入时的幂等归一:媒体节点外框必须满足「预览区比例 = 画面比例」,
|
|
1505
|
+
* 否则按记录比例重算(用户等比拉伸过的预览区比例已正确 → 保留)。
|
|
1506
|
+
* 与 client.js normalizeMediaNodeSize 同一规则,Host 侧建节点/读文档也保持一致。 */
|
|
1507
|
+
function normalizeMediaNodeSize(n) {
|
|
1508
|
+
if (!n || (normalizeNodeType(n.type) !== 'image' && normalizeNodeType(n.type) !== 'video')) return n;
|
|
1509
|
+
const d = n.data || (n.data = {});
|
|
1510
|
+
const hasFooter = !!d.assetId;
|
|
1511
|
+
const ratio = mediaRatioOf(n);
|
|
1512
|
+
if (!ratioIsValid(d.ratio)) d.ratio = ratio;
|
|
1513
|
+
const [rw, rh] = String(ratio).split(':').map(Number);
|
|
1514
|
+
const want = rw / rh;
|
|
1515
|
+
const pw = Number(n.w) || 0;
|
|
1516
|
+
const ph = (Number(n.h) || 0) - NODE_HDR_H - (hasFooter ? NODE_FOOT_H : 0);
|
|
1517
|
+
const okPreview = pw > 0 && ph > 0 && Math.abs(pw / ph - want) / want <= 0.02;
|
|
1518
|
+
if (!okPreview) {
|
|
1519
|
+
const sz = mediaNodeSize(ratio, hasFooter);
|
|
1520
|
+
n.w = sz.w; n.h = sz.h;
|
|
1521
|
+
}
|
|
1522
|
+
return n;
|
|
1523
|
+
}
|
|
1270
1524
|
/** 构造与客户端 newNode() 完全一致的节点(形状不一致会导致渲染异常)。 */
|
|
1271
1525
|
function agentNode(kind, text, x, y, label) {
|
|
1272
1526
|
const id = 'na' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
|
|
@@ -1275,20 +1529,22 @@ function agentNode(kind, text, x, y, label) {
|
|
|
1275
1529
|
? { status: 'idle', error: null, text: String(text ?? '') }
|
|
1276
1530
|
: { status: 'idle', error: null, idea: String(text ?? ''), output: '', aiStatus: 'idle', aiError: null };
|
|
1277
1531
|
if (label) data.label = String(label).slice(0, 40);
|
|
1278
|
-
|
|
1532
|
+
// 与客户端一致:所有节点默认 360×360(文本/剧本/分镜固定这个尺寸,不随内容变化;尺寸持久化进文档)
|
|
1533
|
+
return { id, type: isText ? 'text' : 'script', x, y, w: 360, h: 360, data };
|
|
1279
1534
|
}
|
|
1280
1535
|
/** 新节点布局(用户设计):
|
|
1281
1536
|
* 1) 有 connectFrom 源 → 放在源节点右侧同一行(串联节点横向成对);
|
|
1282
1537
|
* 2) 无连线 → 同类型竖向成列(排在该类型最后一个节点下方,新起一行);
|
|
1283
1538
|
* 3) 碰撞检测:候选位置与任何现有节点(含最小间距 28px)相交 → 自动下移到该节点下方,绝不重叠。 */
|
|
1284
|
-
function nextNodePos(doc, type, connectFromId) {
|
|
1539
|
+
function nextNodePos(doc, type, connectFromId, size) {
|
|
1285
1540
|
const GAP_X = 60, GAP_Y = 40, MIN_GAP = 28;
|
|
1286
1541
|
const H = (n) => Number(n && n.h) || 160;
|
|
1287
1542
|
const W = (n) => Number(n && n.w) || 280;
|
|
1288
1543
|
const nodes = Object.values(doc.nodes || {});
|
|
1289
|
-
const EST_H = { storyboard:
|
|
1290
|
-
|
|
1291
|
-
const
|
|
1544
|
+
const EST_H = { storyboard: 360, video: 360, image: 360, script: 360, finalcut: 360, text: 360 };
|
|
1545
|
+
// 媒体节点的真实外框比 360 高/宽(预览区按比例 + 固定栏):建节点时传 size,避免估小后与邻居重叠
|
|
1546
|
+
const estH = (size && Number(size.h)) || EST_H[type] || 360;
|
|
1547
|
+
const estW = (size && Number(size.w)) || 360;
|
|
1292
1548
|
const same = nodes.filter((n) => n.type === type);
|
|
1293
1549
|
let x = 80;
|
|
1294
1550
|
let y = 40;
|
|
@@ -1405,8 +1661,8 @@ function canvasCreateProject(args) {
|
|
|
1405
1661
|
*/
|
|
1406
1662
|
function canvasWriteNode(args) {
|
|
1407
1663
|
const id = String((args && args.projectId) || '');
|
|
1408
|
-
const
|
|
1409
|
-
const doc =
|
|
1664
|
+
const canvasId = toolCanvasId(args);
|
|
1665
|
+
const doc = readToolDoc(args);
|
|
1410
1666
|
const text = String((args && args.text) || '');
|
|
1411
1667
|
const target = (args && args.target) === 'output' ? 'output' : 'idea';
|
|
1412
1668
|
const nodeId = String((args && args.nodeId) || '');
|
|
@@ -1457,7 +1713,8 @@ function canvasWriteNode(args) {
|
|
|
1457
1713
|
if (idea !== null && node.type === 'script') node.data.idea = idea;
|
|
1458
1714
|
if (name) node.data.label = name;
|
|
1459
1715
|
};
|
|
1460
|
-
const save = () => { doc.updatedAt = Date.now();
|
|
1716
|
+
const save = () => { doc.updatedAt = Date.now(); writeToolDoc(args, doc); };
|
|
1717
|
+
const withCanvas = (o) => (canvasId ? { ...o, canvasId } : o);
|
|
1461
1718
|
/** 该节点是否已有会被 target=output 覆盖的内容(剧本=成稿;分镜=镜头列表;文本=正文)。 */
|
|
1462
1719
|
const hasExisting = (node) => {
|
|
1463
1720
|
if (!node || !node.data) return false;
|
|
@@ -1475,14 +1732,14 @@ function canvasWriteNode(args) {
|
|
|
1475
1732
|
guardOverwrite(node);
|
|
1476
1733
|
writeInto(node);
|
|
1477
1734
|
save();
|
|
1478
|
-
return { ok: true, projectId: id, nodeId, updated: true, label: node.data.label || '' };
|
|
1735
|
+
return withCanvas({ ok: true, projectId: id, nodeId, updated: true, label: node.data.label || '' });
|
|
1479
1736
|
}
|
|
1480
1737
|
if ((args && args.kind) === 'text') {
|
|
1481
1738
|
const pos = nextNodePos(doc);
|
|
1482
1739
|
const n = agentNode('text', text, pos.x, pos.y, name || defaultLabel(text));
|
|
1483
1740
|
doc.nodes[n.id] = n;
|
|
1484
1741
|
save();
|
|
1485
|
-
return { ok: true, projectId: id, nodeId: n.id, label: n.data.label || '' };
|
|
1742
|
+
return withCanvas({ ok: true, projectId: id, nodeId: n.id, label: n.data.label || '' });
|
|
1486
1743
|
}
|
|
1487
1744
|
const scripts = Object.values(doc.nodes || {}).filter((n) => n.type === 'script');
|
|
1488
1745
|
let reuse = null;
|
|
@@ -1499,7 +1756,7 @@ function canvasWriteNode(args) {
|
|
|
1499
1756
|
}
|
|
1500
1757
|
writeInto(reuse);
|
|
1501
1758
|
save();
|
|
1502
|
-
return { ok: true, projectId: id, nodeId: reuse.id, updated: true, reused: true, label: reuse.data.label || '' };
|
|
1759
|
+
return withCanvas({ ok: true, projectId: id, nodeId: reuse.id, updated: true, reused: true, label: reuse.data.label || '' });
|
|
1503
1760
|
}
|
|
1504
1761
|
const pos = nextNodePos(doc);
|
|
1505
1762
|
const n = agentNode('script', target === 'output' ? '' : text, pos.x, pos.y, name || defaultLabel(text));
|
|
@@ -1507,14 +1764,15 @@ function canvasWriteNode(args) {
|
|
|
1507
1764
|
if (idea !== null) n.data.idea = idea;
|
|
1508
1765
|
doc.nodes[n.id] = n;
|
|
1509
1766
|
save();
|
|
1510
|
-
return { ok: true, projectId: id, nodeId: n.id, label: n.data.label || '' };
|
|
1767
|
+
return withCanvas({ ok: true, projectId: id, nodeId: n.id, label: n.data.label || '' });
|
|
1511
1768
|
}
|
|
1512
1769
|
/** 读取项目文档:把"文件不存在/内容损坏"变成用户和模型都读得懂的报错(不泄漏绝对路径)。 */
|
|
1513
1770
|
function readDoc(id) {
|
|
1514
1771
|
if (sqlActive()) {
|
|
1515
1772
|
const doc = readDocOrNull(id);
|
|
1516
1773
|
if (!doc) throw new Error('项目不存在:' + id + '(先用 canvas_list_projects 查看可用项目 id)');
|
|
1517
|
-
|
|
1774
|
+
// 旧项目兼容:读入即归一旧节点类型(image-gen/image-asset → image,video-gen/video-asset → video)
|
|
1775
|
+
return normalizeDocNodes(doc);
|
|
1518
1776
|
}
|
|
1519
1777
|
const f = projectFile(id);
|
|
1520
1778
|
let raw;
|
|
@@ -1524,13 +1782,16 @@ function readDoc(id) {
|
|
|
1524
1782
|
throw new Error('项目不存在:' + id + '(先用 canvas_list_projects 查看可用项目 id)');
|
|
1525
1783
|
}
|
|
1526
1784
|
try {
|
|
1527
|
-
return JSON.parse(raw);
|
|
1785
|
+
return normalizeDocNodes(JSON.parse(raw));
|
|
1528
1786
|
} catch {
|
|
1529
1787
|
throw new Error('项目文件损坏,无法解析:' + id + '(可考虑重建该项目)');
|
|
1530
1788
|
}
|
|
1531
1789
|
}
|
|
1532
|
-
/**
|
|
1790
|
+
/** 原子写项目/画布文档:SQL 模式走单行事务;文件模式先写临时文件再改名,避免半写导致项目"消失"。
|
|
1791
|
+
* 子画布文档(id 含 __cv__)不重复登记项目文件夹、不镜像 project.json——项目级 meta 只属于默认画布行。 */
|
|
1533
1792
|
function writeIcDoc(id, doc) {
|
|
1793
|
+
const canvasRef = splitCanvasDocId(id);
|
|
1794
|
+
const isCanvas = isCanvasDocId(id);
|
|
1534
1795
|
if (sqlActive()) {
|
|
1535
1796
|
const db = getDb();
|
|
1536
1797
|
db.prepare(`INSERT INTO projects (id, name, created_at, updated_at, bible_updated_at, summary, tombstones, doc)
|
|
@@ -1539,19 +1800,115 @@ function writeIcDoc(id, doc) {
|
|
|
1539
1800
|
bible_updated_at = excluded.bible_updated_at, summary = excluded.summary,
|
|
1540
1801
|
tombstones = excluded.tombstones, doc = excluded.doc`)
|
|
1541
1802
|
.run(
|
|
1542
|
-
id,
|
|
1543
|
-
doc.
|
|
1803
|
+
id,
|
|
1804
|
+
String((doc && doc.name) || (isCanvas ? canvasRef.projectId : id)).slice(0, 80),
|
|
1805
|
+
(doc && doc.createdAt) || Date.now(), (doc && doc.updatedAt) || Date.now(),
|
|
1806
|
+
(doc && doc.bibleUpdatedAt) || 0, JSON.stringify(docSummary(doc)), JSON.stringify((doc && doc.tombstones) || []), JSON.stringify(doc),
|
|
1544
1807
|
);
|
|
1545
1808
|
// SQL 模式也要保证数据目录里有 meta 映射(craft 清理/物理落盘按 folder 定位):
|
|
1546
|
-
//
|
|
1547
|
-
|
|
1809
|
+
// 子画布共用项目的物理文件夹,不单独登记。
|
|
1810
|
+
if (!isCanvas) {
|
|
1811
|
+
try { ensureProjectFolder(id, (doc && doc.name) || id); } catch { /* meta 写失败不阻塞主存储 */ }
|
|
1812
|
+
}
|
|
1548
1813
|
return;
|
|
1549
1814
|
}
|
|
1550
1815
|
const f = projectFile(id);
|
|
1551
1816
|
const tmp = f + '.' + process.pid + '.tmp';
|
|
1552
1817
|
writeFileSync(tmp, JSON.stringify(doc, null, 1), 'utf8');
|
|
1553
1818
|
renameSync(tmp, f);
|
|
1554
|
-
|
|
1819
|
+
// 数据目录镜像(含设定集/assetIds 绑定):失败不影响主存储;子画布镜像到项目文件夹下 canvases/,不写 project.json
|
|
1820
|
+
if (!isCanvas) mirrorProjectToDataRoot(id, doc);
|
|
1821
|
+
else mirrorCanvasDocToDataRoot(canvasRef.projectId, canvasRef.canvasId, doc);
|
|
1822
|
+
}
|
|
1823
|
+
/** 读取画布文档(多画布):默认画布 = 项目文档;子画布 = 独立文档 + 合并项目级字段(名称/设定集/画布清单)。 */
|
|
1824
|
+
function readCanvasDoc(projectId, canvasId) {
|
|
1825
|
+
const pid = String(projectId || '');
|
|
1826
|
+
const cid = normalizeCanvasId(canvasId);
|
|
1827
|
+
const projectDoc = readDocOrNull(pid);
|
|
1828
|
+
if (!projectDoc) throw new Error('项目不存在:' + pid + '(先用 canvas_list_projects 查看可用项目 id)');
|
|
1829
|
+
if (!cid) {
|
|
1830
|
+
if (!defaultCanvasExists(projectDoc)) throw new Error('默认画布已删除:请指定 canvasId(先用 canvas_list_projects 查看可用画布)');
|
|
1831
|
+
return normalizeDocNodes({
|
|
1832
|
+
...projectDoc, canvases: canvasRegistry(projectDoc),
|
|
1833
|
+
defaultCanvasName: defaultCanvasNameOf(projectDoc), defaultCanvasDeleted: false,
|
|
1834
|
+
});
|
|
1835
|
+
}
|
|
1836
|
+
const rec = canvasRegistry(projectDoc).find((c) => c.id === cid);
|
|
1837
|
+
if (!rec) throw new Error('画布不存在:' + cid + '(项目 ' + pid + ';先用 canvas_list_projects 查看可用画布)');
|
|
1838
|
+
const raw = readDocOrNull(canvasDocId(pid, cid)) || {};
|
|
1839
|
+
const doc = { ...raw, id: pid, projectId: pid, canvasId: cid, name: projectDoc.name || pid };
|
|
1840
|
+
doc.nodes = raw.nodes || {};
|
|
1841
|
+
doc.edges = raw.edges || [];
|
|
1842
|
+
doc.view = raw.view || { x: 60, y: 40, scale: 1 };
|
|
1843
|
+
if (projectDoc.bible !== undefined) { doc.bible = projectDoc.bible; doc.bibleUpdatedAt = projectDoc.bibleUpdatedAt || 0; }
|
|
1844
|
+
doc.canvases = canvasRegistry(projectDoc);
|
|
1845
|
+
doc.defaultCanvasName = defaultCanvasNameForApi(projectDoc);
|
|
1846
|
+
doc.defaultCanvasDeleted = !defaultCanvasExists(projectDoc);
|
|
1847
|
+
return normalizeDocNodes(doc);
|
|
1848
|
+
}
|
|
1849
|
+
/** 写画布文档:默认画布写项目文档;子画布写独立文档(项目级字段剔除)。 */
|
|
1850
|
+
function writeCanvasDoc(projectId, canvasId, doc) {
|
|
1851
|
+
const pid = String(projectId || '');
|
|
1852
|
+
const cid = normalizeCanvasId(canvasId);
|
|
1853
|
+
if (!cid) {
|
|
1854
|
+
writeIcDoc(pid, { ...doc, id: pid });
|
|
1855
|
+
return;
|
|
1856
|
+
}
|
|
1857
|
+
const out = { ...doc, id: canvasDocId(pid, cid), projectId: pid, canvasId: cid, updatedAt: Date.now() };
|
|
1858
|
+
delete out.bible;
|
|
1859
|
+
delete out.bibleUpdatedAt;
|
|
1860
|
+
delete out.canvases;
|
|
1861
|
+
delete out.name;
|
|
1862
|
+
writeIcDoc(canvasDocId(pid, cid), out);
|
|
1863
|
+
}
|
|
1864
|
+
/** 更新项目级字段(名称/设定集/画布清单)——只作用于默认画布那份项目文档。 */
|
|
1865
|
+
function patchProjectDoc(projectId, patch) {
|
|
1866
|
+
const pid = String(projectId || '');
|
|
1867
|
+
const cur = readDocOrNull(pid);
|
|
1868
|
+
if (!cur) throw new Error('项目不存在:' + pid);
|
|
1869
|
+
writeIcDoc(pid, { ...cur, ...patch, id: pid });
|
|
1870
|
+
}
|
|
1871
|
+
/** 工具入口统一的画布参数解析:args.canvasId 缺省 = 项目默认画布(null);非法 id 直接报错不静默降级。 */
|
|
1872
|
+
function toolCanvasId(args) {
|
|
1873
|
+
return normalizeCanvasId(args && args.canvasId);
|
|
1874
|
+
}
|
|
1875
|
+
/** 工具读文档:默认画布读项目文档,子画布读子文档(项目级字段已合并进来)。 */
|
|
1876
|
+
function readToolDoc(args) {
|
|
1877
|
+
const pid = String((args && args.projectId) || '');
|
|
1878
|
+
return readCanvasDoc(pid, toolCanvasId(args));
|
|
1879
|
+
}
|
|
1880
|
+
/** 工具写文档:默认画布写项目文档、子画布写子文档,并标记画布级变更信号。返回 canvasId。 */
|
|
1881
|
+
function writeToolDoc(args, doc) {
|
|
1882
|
+
const pid = String((args && args.projectId) || '');
|
|
1883
|
+
const cid = toolCanvasId(args);
|
|
1884
|
+
writeCanvasDoc(pid, cid, doc);
|
|
1885
|
+
markDocChanged(pid, cid);
|
|
1886
|
+
return cid;
|
|
1887
|
+
}
|
|
1888
|
+
/** 删除项目的全部子画布文档(项目删除时清干净,避免残留行/文件被列出)。 */
|
|
1889
|
+
function deleteCanvasDocsForProject(projectId) {
|
|
1890
|
+
const pid = String(projectId || '');
|
|
1891
|
+
if (!pid) return 0;
|
|
1892
|
+
let removed = 0;
|
|
1893
|
+
if (sqlActive()) {
|
|
1894
|
+
try {
|
|
1895
|
+
const db = getDb();
|
|
1896
|
+
const ids = db.prepare('SELECT id FROM projects WHERE instr(id, ?) > 0').all(CANVAS_ID_SEP).map((r) => r.id)
|
|
1897
|
+
.filter((id) => splitCanvasDocId(id).projectId === pid);
|
|
1898
|
+
const del = db.prepare('DELETE FROM projects WHERE id = ?');
|
|
1899
|
+
for (const id of ids) { try { del.run(id); removed++; } catch { /* 单行失败不影响其他 */ } }
|
|
1900
|
+
} catch { /* 清理失败不影响项目删除 */ }
|
|
1901
|
+
return removed;
|
|
1902
|
+
}
|
|
1903
|
+
try {
|
|
1904
|
+
for (const f of readdirSync(PROJECTS_DIR)) {
|
|
1905
|
+
if (!f.endsWith('.json')) continue;
|
|
1906
|
+
const id = f.slice(0, -5);
|
|
1907
|
+
if (!isCanvasDocId(id) || splitCanvasDocId(id).projectId !== pid) continue;
|
|
1908
|
+
try { unlinkSync(path.join(PROJECTS_DIR, f)); removed++; } catch { /* 文件占用/已删 */ }
|
|
1909
|
+
}
|
|
1910
|
+
} catch { /* 无目录 */ }
|
|
1911
|
+
return removed;
|
|
1555
1912
|
}
|
|
1556
1913
|
/** 读取设定集(对话侧"设定集先行"的前提)。 */
|
|
1557
1914
|
async function canvasGetBible(args) {
|
|
@@ -1613,29 +1970,38 @@ async function canvasUpdateBible(args) {
|
|
|
1613
1970
|
markDocChanged(id);
|
|
1614
1971
|
return { projectId: id, bible: doc.bible, bibleUpdatedAt: doc.bibleUpdatedAt };
|
|
1615
1972
|
}
|
|
1616
|
-
/** 通用建节点(供对话建角色/场景/道具参考图等);支持 script/text/image
|
|
1973
|
+
/** 通用建节点(供对话建角色/场景/道具参考图等);支持 script/text/image/video/storyboard/finalcut。
|
|
1974
|
+
* image / video 是合并类型:有 assetId 即资产节点、有 prompt 即待生成的空节点;旧名
|
|
1975
|
+
* image-gen / image-asset / video-gen / video-asset 仍接受,内部一律映射为合并类型。 */
|
|
1617
1976
|
function canvasCreateNode(args) {
|
|
1618
1977
|
const id = String((args && args.projectId) || '');
|
|
1619
|
-
const
|
|
1620
|
-
const doc =
|
|
1621
|
-
const
|
|
1622
|
-
|
|
1978
|
+
const canvasId = toolCanvasId(args);
|
|
1979
|
+
const doc = readToolDoc(args);
|
|
1980
|
+
const rawType = String((args && args.type) || 'text');
|
|
1981
|
+
const type = normalizeNodeType(rawType);
|
|
1982
|
+
if (!['script', 'text', 'image', 'video', 'storyboard', 'finalcut'].includes(type)) throw new Error('不支持的节点类型: ' + rawType);
|
|
1983
|
+
const legacyType = rawType !== type ? rawType : '';
|
|
1623
1984
|
const label = args && args.name ? String(args.name).slice(0, 40) : '';
|
|
1624
1985
|
const prompt = String((args && args.prompt) || '');
|
|
1625
1986
|
const connectFrom = String((args && args.connectFrom) || '');
|
|
1626
|
-
|
|
1987
|
+
// 媒体节点:空态按「预览区比例 + 标题栏」给外框(没有操作栏就不加 NODE_FOOT_H),
|
|
1988
|
+
// 布点时用真实外框做碰撞检测,避免按 360×360 估小后与同列邻居重叠。
|
|
1989
|
+
const mediaSize = (type === 'image' || type === 'video')
|
|
1990
|
+
? mediaNodeSize((args && args.ratio) || '16:9', false)
|
|
1991
|
+
: null;
|
|
1992
|
+
const pos = nextNodePos(doc, type, connectFrom, mediaSize);
|
|
1627
1993
|
let connected = null;
|
|
1628
1994
|
let autoConnected = null;
|
|
1629
1995
|
let node;
|
|
1630
|
-
if (type === 'image
|
|
1631
|
-
node = agentNode('text', '', pos.x, pos.y, label || '
|
|
1632
|
-
node.type = 'image
|
|
1633
|
-
node.w =
|
|
1634
|
-
node.data = { ...node.data, status: 'idle', error: null, prompt, assetId: null, demo: false, size: (args && args.size) || '2K', ratio: (args && args.ratio) || '16:9' };
|
|
1635
|
-
} else if (type === 'video
|
|
1636
|
-
node = agentNode('text', '', pos.x, pos.y, label || '
|
|
1637
|
-
node.type = 'video
|
|
1638
|
-
node.w =
|
|
1996
|
+
if (type === 'image') {
|
|
1997
|
+
node = agentNode('text', '', pos.x, pos.y, label || '图片节点');
|
|
1998
|
+
node.type = 'image';
|
|
1999
|
+
node.w = mediaSize.w; node.h = mediaSize.h;
|
|
2000
|
+
node.data = { ...node.data, status: 'idle', error: null, prompt, assetId: null, demo: false, size: (args && args.size) || '2K', ratio: (args && args.ratio) || '16:9', imageCount: 1 };
|
|
2001
|
+
} else if (type === 'video') {
|
|
2002
|
+
node = agentNode('text', '', pos.x, pos.y, label || '视频节点');
|
|
2003
|
+
node.type = 'video';
|
|
2004
|
+
node.w = mediaSize.w; node.h = mediaSize.h;
|
|
1639
2005
|
// 多镜头工作流(结构性强制):项目里已有**拆解过镜头**的分镜表 → 一律自动连线分镜表、prompt 忽略。
|
|
1640
2006
|
// 一个分镜表 = 一个完整视频,镜头清单只从分镜表读取;对话侧手写 prompt 绕过分镜表是高频错误,这里直接封死。
|
|
1641
2007
|
const sbs = Object.values(doc.nodes || {}).filter((n) => n.type === 'storyboard');
|
|
@@ -1667,7 +2033,7 @@ function canvasCreateNode(args) {
|
|
|
1667
2033
|
} else if (type === 'finalcut') {
|
|
1668
2034
|
node = agentNode('text', '', pos.x, pos.y, label || '成片');
|
|
1669
2035
|
node.type = 'finalcut';
|
|
1670
|
-
node.w =
|
|
2036
|
+
node.w = 360; node.h = 360;
|
|
1671
2037
|
node.data = { ...node.data, status: 'idle', error: null, resolution: (args && args.resolution) || '720P', ratio: (args && args.ratio) || '16:9', transition: 'none', progress: null, assetId: null, subs: {}, keepAudio: true };
|
|
1672
2038
|
} else if (type === 'script') {
|
|
1673
2039
|
node = agentNode('script', prompt, pos.x, pos.y, label || defaultLabel(prompt));
|
|
@@ -1684,22 +2050,26 @@ function canvasCreateNode(args) {
|
|
|
1684
2050
|
const defaultToPort = (type === 'storyboard' && (srcType === 'text' || srcType === 'script')) ? 'script' : undefined;
|
|
1685
2051
|
const edge = connectEdge(doc, connectFrom, node.id, args && args.connectFromPort, (args && args.connectToPort) || defaultToPort);
|
|
1686
2052
|
connected = edge.id;
|
|
1687
|
-
} else if (autoConnected && (type === 'storyboard' || type === 'video
|
|
1688
|
-
// 未指定连线但上游已存在(分镜表→剧本 /
|
|
2053
|
+
} else if (autoConnected && (type === 'storyboard' || type === 'video')) {
|
|
2054
|
+
// 未指定连线但上游已存在(分镜表→剧本 / 视频节点→分镜表)→ 默认自动连线(工作流的默认形态,不依赖 Agent 记得)
|
|
1689
2055
|
const edge = connectEdge(doc, autoConnected.from, node.id, undefined, type === 'storyboard' ? 'script' : undefined);
|
|
1690
2056
|
connected = edge.id;
|
|
1691
2057
|
}
|
|
1692
2058
|
doc.updatedAt = Date.now();
|
|
1693
|
-
|
|
1694
|
-
markDocChanged(id);
|
|
2059
|
+
writeToolDoc(args, doc);
|
|
1695
2060
|
const note = autoConnected && autoConnected.ignoredPrompt
|
|
1696
|
-
? (type === 'video
|
|
2061
|
+
? (type === 'video'
|
|
1697
2062
|
? '⚠️ 已强制连线到分镜表(多镜头工作流:一个分镜表 = 一个完整视频,镜头清单从分镜表读取),传入的 prompt 已忽略——不要手写视频提示词绕过分镜表。'
|
|
1698
2063
|
: '已自动连线到上游剧本节点,传入的 prompt 被忽略(内容从连线取)。')
|
|
1699
2064
|
: (autoConnected && autoConnected.emptyShots
|
|
1700
2065
|
? '已连线到分镜表;该分镜表尚未拆解镜头(shots 为空)——先 canvas_run_node 分镜表生成镜头,再生成视频。'
|
|
1701
2066
|
: '');
|
|
1702
|
-
return {
|
|
2067
|
+
return {
|
|
2068
|
+
ok: true, projectId: id, ...(canvasId ? { canvasId } : {}), nodeId: node.id, type: node.type, label: (node.data && node.data.label) || '',
|
|
2069
|
+
edgeId: connected || undefined, note: note || undefined,
|
|
2070
|
+
// 旧名(image-gen / image-asset / video-gen / video-asset)已映射为合并节点:明确告诉 Agent 实际类型
|
|
2071
|
+
mergedType: legacyType ? (legacyType + ' → ' + node.type + '(合并节点:有 assetId 即资产,空节点在画布点开面板生成)') : undefined,
|
|
2072
|
+
};
|
|
1703
2073
|
}
|
|
1704
2074
|
|
|
1705
2075
|
/** 一键重排:按「行」布局——横向数据流决定列,竖向端口(prev/frame)不参与分列;
|
|
@@ -1707,9 +2077,10 @@ function canvasCreateNode(args) {
|
|
|
1707
2077
|
* 行高取行内最高节点、行间保留间距,绝不重叠。 */
|
|
1708
2078
|
function canvasRelayout(args) {
|
|
1709
2079
|
const id = String((args && args.projectId) || '');
|
|
1710
|
-
const
|
|
2080
|
+
const canvasId = toolCanvasId(args);
|
|
2081
|
+
const doc = readToolDoc(args);
|
|
1711
2082
|
const nodes = Object.values(doc.nodes || {});
|
|
1712
|
-
if (!nodes.length) return { ok: true, projectId: id, moved: 0, rows: 0 };
|
|
2083
|
+
if (!nodes.length) return { ok: true, projectId: id, ...(canvasId ? { canvasId } : {}), moved: 0, rows: 0 };
|
|
1713
2084
|
const VERT = new Set(['prev', 'frame']);
|
|
1714
2085
|
const byId = new Map(nodes.map((n) => [n.id, n]));
|
|
1715
2086
|
const depth = new Map(nodes.map((n) => [n.id, 0]));
|
|
@@ -1754,9 +2125,8 @@ function canvasRelayout(args) {
|
|
|
1754
2125
|
cy += rowH + 40;
|
|
1755
2126
|
}
|
|
1756
2127
|
doc.updatedAt = Date.now();
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
return { ok: true, projectId: id, moved, rows: rowList.length };
|
|
2128
|
+
writeToolDoc(args, doc);
|
|
2129
|
+
return { ok: true, projectId: id, ...(canvasId ? { canvasId } : {}), moved, rows: rowList.length };
|
|
1760
2130
|
}
|
|
1761
2131
|
async function canvasDeleteProject(args) {
|
|
1762
2132
|
const id = String((args && args.projectId) || '');
|
|
@@ -1769,6 +2139,7 @@ async function canvasDeleteProject(args) {
|
|
|
1769
2139
|
cleanup = sqlDeleteProjectRows(getDb(), id, dataRoot());
|
|
1770
2140
|
} else {
|
|
1771
2141
|
try { unlinkSync(projectFile(id)); } catch { /* 已删除 */ }
|
|
2142
|
+
try { deleteCanvasDocsForProject(id); } catch { /* 子画布清理失败不影响删除 */ }
|
|
1772
2143
|
try { removeDataRootProject(id); } catch { /* 数据目录镜像清理失败不影响删除 */ }
|
|
1773
2144
|
}
|
|
1774
2145
|
// 通知客户端把这个项目也从本地删掉,否则下次推送会把它复活 + 长效墓碑:拒收迟到的推送
|
|
@@ -1787,29 +2158,29 @@ async function canvasDeleteProject(args) {
|
|
|
1787
2158
|
async function canvasDeleteNode(args) {
|
|
1788
2159
|
const id = String((args && args.projectId) || '');
|
|
1789
2160
|
const nodeId = String((args && args.nodeId) || '');
|
|
1790
|
-
const
|
|
1791
|
-
const doc =
|
|
2161
|
+
const canvasId = toolCanvasId(args);
|
|
2162
|
+
const doc = readToolDoc(args);
|
|
1792
2163
|
if (!doc.nodes[nodeId]) throw new Error('节点不存在: ' + nodeId);
|
|
1793
2164
|
delete doc.nodes[nodeId];
|
|
1794
2165
|
doc.edges = (doc.edges || []).filter((e) => e.fromNode !== nodeId && e.toNode !== nodeId);
|
|
1795
2166
|
doc.tombstones = [...new Set([...(doc.tombstones || []), nodeId])].slice(-100);
|
|
1796
2167
|
doc.tombstoneAt = { ...(doc.tombstoneAt || {}), [nodeId]: Date.now() }; // 记录墓碑创建时刻:撤销恢复的推送(ts 更新)才允许清墓碑
|
|
1797
2168
|
doc.updatedAt = Date.now();
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
return { ok: true, projectId: id, nodeId };
|
|
2169
|
+
writeToolDoc(args, doc);
|
|
2170
|
+
return { ok: true, projectId: id, ...(canvasId ? { canvasId } : {}), nodeId };
|
|
1801
2171
|
}
|
|
1802
2172
|
/** 运行节点:写一次性请求,画布客户端轮询消费后用画布内配置的模型/接口真实执行。 */
|
|
1803
2173
|
async function canvasRunNode(args) {
|
|
1804
2174
|
const pid = String((args && args.projectId) || '');
|
|
1805
2175
|
const nodeId = String((args && args.nodeId) || '');
|
|
1806
|
-
const
|
|
2176
|
+
const canvasId = toolCanvasId(args);
|
|
2177
|
+
const doc = readToolDoc(args);
|
|
1807
2178
|
const node = doc.nodes[nodeId];
|
|
1808
2179
|
if (!node) throw new Error('节点不存在: ' + nodeId);
|
|
1809
2180
|
const cfg = readIcConfig();
|
|
1810
|
-
cfg.ui = { ...(cfg.ui || {}), runRequest: { projectId: pid, nodeId, at: Date.now() } };
|
|
2181
|
+
cfg.ui = { ...(cfg.ui || {}), runRequest: { projectId: pid, canvasId, nodeId, at: Date.now() } };
|
|
1811
2182
|
writeIcConfig(cfg);
|
|
1812
|
-
return { ok: true, projectId: pid, nodeId, type: node.type, label: (node.data && node.data.label) || '' };
|
|
2183
|
+
return { ok: true, projectId: pid, ...(canvasId ? { canvasId } : {}), nodeId, type: node.type, label: (node.data && node.data.label) || '' };
|
|
1813
2184
|
}
|
|
1814
2185
|
/** 读取节点运行状态与产物(画布执行完成后会把结果推回 Host 文档)。 */
|
|
1815
2186
|
/** 分镜一致性检查(与客户端 checkShotConsistency 同规则,Host 供状态查询/设定集更新后重算):
|
|
@@ -1877,7 +2248,8 @@ function readAssetIndexNames(pid) {
|
|
|
1877
2248
|
async function canvasNodeStatus(args) {
|
|
1878
2249
|
const pid = String((args && args.projectId) || '');
|
|
1879
2250
|
const nodeId = String((args && args.nodeId) || '');
|
|
1880
|
-
const
|
|
2251
|
+
const canvasId = toolCanvasId(args);
|
|
2252
|
+
const doc = readToolDoc(args);
|
|
1881
2253
|
const n = doc.nodes[nodeId];
|
|
1882
2254
|
if (!n) throw new Error('节点不存在: ' + nodeId);
|
|
1883
2255
|
// 分镜表:返回镜头清单(结构化)——对话侧据此组织视频提示词,绝不自行推导/编造镜头表
|
|
@@ -1906,7 +2278,7 @@ async function canvasNodeStatus(args) {
|
|
|
1906
2278
|
: undefined;
|
|
1907
2279
|
const d = n.data || {};
|
|
1908
2280
|
return {
|
|
1909
|
-
projectId: pid, nodeId, type: n.type, label: d.label || '',
|
|
2281
|
+
projectId: pid, ...(canvasId ? { canvasId } : {}), nodeId, type: n.type, label: d.label || '',
|
|
1910
2282
|
status: d.status || 'idle',
|
|
1911
2283
|
aiStatus: d.aiStatus || 'idle',
|
|
1912
2284
|
// 文档级时间戳:canvas_generate_node 的等待循环用它区分「本轮真的跑过」与「上一次的残留终态」
|
|
@@ -1954,7 +2326,8 @@ const NODE_PATCH_FIELDS = ['prompt', 'idea', 'output', 'text', 'script', 'requir
|
|
|
1954
2326
|
function canvasUpdateNode(args) {
|
|
1955
2327
|
const pid = String((args && args.projectId) || '');
|
|
1956
2328
|
const nodeId = String((args && args.nodeId) || '');
|
|
1957
|
-
const
|
|
2329
|
+
const canvasId = toolCanvasId(args);
|
|
2330
|
+
const doc = readToolDoc(args);
|
|
1958
2331
|
const n = doc.nodes[nodeId];
|
|
1959
2332
|
if (!n) throw new Error('节点不存在: ' + nodeId + '(先用 canvas_list_projects 查看节点 id)');
|
|
1960
2333
|
const patch = (args && args.patch) || {};
|
|
@@ -2013,24 +2386,24 @@ function canvasUpdateNode(args) {
|
|
|
2013
2386
|
throw new Error('没有可应用的字段(允许:' + NODE_PATCH_FIELDS.join(' / ') + ' / shots);收到:' + keys.join(' / '));
|
|
2014
2387
|
}
|
|
2015
2388
|
doc.updatedAt = Date.now();
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
return { projectId: pid, nodeId, type: n.type, label: n.data.label || '', applied, skipped, prompt: String(n.data.prompt || '').slice(0, 400) };
|
|
2389
|
+
writeToolDoc(args, doc);
|
|
2390
|
+
return { projectId: pid, ...(canvasId ? { canvasId } : {}), nodeId, type: n.type, label: n.data.label || '', applied, skipped, prompt: String(n.data.prompt || '').slice(0, 400) };
|
|
2019
2391
|
}
|
|
2020
2392
|
/** 触发节点执行并(可选)等待到终态:对话侧"改完提示词 → 点生成 → 拿结果"的一站式入口。
|
|
2021
2393
|
* 执行发生在画布侧(需要浏览器开着画布页面消费运行请求),等待超时不代表失败。 */
|
|
2022
2394
|
async function canvasGenerateNode(args) {
|
|
2023
2395
|
const pid = String((args && args.projectId) || '');
|
|
2024
2396
|
const nodeId = String((args && args.nodeId) || '');
|
|
2397
|
+
const canvasId = toolCanvasId(args);
|
|
2025
2398
|
const waitSec = Math.max(0, Math.min(600, Number((args && args.waitSeconds) || 0)));
|
|
2026
2399
|
// 触发前快照:canvasRunNode 只写一次性 runRequest,真正的执行要等画布下一个 3s 同步 tick
|
|
2027
2400
|
// 才开始(状态才翻成 running);而下面的首轮轮询在 2.5s 就发生——对**曾经跑过**的节点,
|
|
2028
2401
|
// 首轮会读到上一次的 done/error 并误当本次结果(页面关闭 100%、页开约 17% 触发)。
|
|
2029
2402
|
// 因此终态只有在「本轮确实跑过」时才可信:轮询中见过 running,或文档在触发后被写过
|
|
2030
2403
|
//(快进完成的短任务可能整体落在两次轮询之间,用文档时间戳兜底)。
|
|
2031
|
-
const baseline = await canvasNodeStatus({ projectId: pid, nodeId }).catch(() => null);
|
|
2404
|
+
const baseline = await canvasNodeStatus({ projectId: pid, canvasId, nodeId }).catch(() => null);
|
|
2032
2405
|
const baseUpdatedAt = Number(baseline && baseline.docUpdatedAt) || 0;
|
|
2033
|
-
const run = await canvasRunNode({ projectId: pid, nodeId });
|
|
2406
|
+
const run = await canvasRunNode({ projectId: pid, canvasId, nodeId });
|
|
2034
2407
|
if (!waitSec) {
|
|
2035
2408
|
return { ok: true, triggered: true, finished: false, projectId: pid, nodeId, type: run.type, label: run.label, status: 'running', note: '已触发执行;画布需处于打开状态才会真正执行,可用 canvas_node_status 查询进度' };
|
|
2036
2409
|
}
|
|
@@ -2039,7 +2412,7 @@ async function canvasGenerateNode(args) {
|
|
|
2039
2412
|
let sawRunning = false;
|
|
2040
2413
|
while (Date.now() < deadline) {
|
|
2041
2414
|
await new Promise((r) => setTimeout(r, 2500));
|
|
2042
|
-
try { last = await canvasNodeStatus({ projectId: pid, nodeId }); } catch { continue; }
|
|
2415
|
+
try { last = await canvasNodeStatus({ projectId: pid, canvasId, nodeId }); } catch { continue; }
|
|
2043
2416
|
const st = String(last.status || '');
|
|
2044
2417
|
const ai = String(last.aiStatus || '');
|
|
2045
2418
|
if (st === 'running' || ai === 'running') { sawRunning = true; continue; }
|
|
@@ -2062,35 +2435,137 @@ async function canvasGenerateNode(args) {
|
|
|
2062
2435
|
}
|
|
2063
2436
|
async function canvasOpen(args) {
|
|
2064
2437
|
const pid = String((args && args.projectId) || '');
|
|
2438
|
+
const canvasId = toolCanvasId(args);
|
|
2065
2439
|
if (pid) {
|
|
2066
|
-
//
|
|
2067
|
-
if (
|
|
2440
|
+
// 项目(以及指定画布)必须真实存在:否则工具回"✅ 已打开",而前端因为找不到项目什么都不做(假成功)
|
|
2441
|
+
if (canvasId) readCanvasDoc(pid, canvasId);
|
|
2442
|
+
else if (!docExists(pid)) throw new Error('项目不存在:' + pid + '(先用 canvas_list_projects 查看可用项目 id)');
|
|
2068
2443
|
}
|
|
2069
2444
|
const cfg = readIcConfig();
|
|
2070
|
-
cfg.ui = { ...(cfg.ui || {}), openRequest: { projectId: pid || null, at: Date.now() } };
|
|
2445
|
+
cfg.ui = { ...(cfg.ui || {}), openRequest: { projectId: pid || null, canvasId: pid ? canvasId : null, at: Date.now() } };
|
|
2071
2446
|
writeIcConfig(cfg);
|
|
2072
|
-
return { ok: true, projectId: pid || null };
|
|
2447
|
+
return { ok: true, projectId: pid || null, ...(canvasId ? { canvasId } : {}) };
|
|
2073
2448
|
}
|
|
2074
2449
|
|
|
2075
|
-
/**
|
|
2450
|
+
/** 画布清单(多画布目录树的数据源):默认画布不登记在 canvases 里(客户端用 canvasId=null 表达),
|
|
2451
|
+
* 返回 projectId + 默认画布名/是否已删除 + canvases(子画布)。 */
|
|
2452
|
+
async function canvasesList(args) {
|
|
2453
|
+
const pid = String((args && args.projectId) || '');
|
|
2454
|
+
const doc = readDoc(pid);
|
|
2455
|
+
return {
|
|
2456
|
+
projectId: pid, name: doc.name || pid,
|
|
2457
|
+
canvases: canvasRegistry(doc),
|
|
2458
|
+
defaultCanvasName: defaultCanvasNameForApi(doc),
|
|
2459
|
+
defaultCanvasDeleted: !defaultCanvasExists(doc),
|
|
2460
|
+
};
|
|
2461
|
+
}
|
|
2462
|
+
/** 新建子画布:必须命名;画布 id 由 Host 生成(cv 前缀,避免与项目 id 混淆)。
|
|
2463
|
+
* 新画布首次打开是空白画布(没有文档行,readCanvasDoc 会兜底空图)。 */
|
|
2464
|
+
async function canvasCreate(args) {
|
|
2465
|
+
const pid = String((args && args.projectId) || '');
|
|
2466
|
+
const name = String((args && args.name) || '').trim();
|
|
2467
|
+
if (!name) throw new Error('需要画布名称(新建画布必须命名)');
|
|
2468
|
+
const projectDoc = readDoc(pid);
|
|
2469
|
+
const list = canvasRegistry(projectDoc);
|
|
2470
|
+
const defaultCanvasName = defaultCanvasNameForApi(projectDoc);
|
|
2471
|
+
// 同名复用:重复点击/重试不产生一堆同名画布
|
|
2472
|
+
const dup = list.find((c) => String(c.name).trim() === name);
|
|
2473
|
+
if (dup) return { ok: true, projectId: pid, canvasId: dup.id, name: dup.name, reused: true, defaultCanvasName };
|
|
2474
|
+
if (allCanvasNames(projectDoc).some((n) => n.trim() === name)) throw new Error('同名画布已存在:' + name);
|
|
2475
|
+
if (list.length >= 200) throw new Error('一个项目最多 200 个画布');
|
|
2476
|
+
// 客户端可自带 id(本地已登记、目录树已显示的路径):合法且未占用就采用,避免两端 id 不一致
|
|
2477
|
+
const wantId = normalizeCanvasId(args && args.canvasId);
|
|
2478
|
+
const cid = (wantId && !list.some((c) => c.id === wantId)) ? wantId : ('cv' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6));
|
|
2479
|
+
const next = sanitizeCanvases([...list, { id: cid, name: name.slice(0, 80), createdAt: Date.now() }]);
|
|
2480
|
+
patchProjectDoc(pid, { canvases: next });
|
|
2481
|
+
return { ok: true, projectId: pid, canvasId: cid, name: name.slice(0, 80), reused: false, defaultCanvasName };
|
|
2482
|
+
}
|
|
2483
|
+
/** 重命名画布:canvasId=null 表示默认画布(写项目文档 defaultCanvasName);必须命名、同名(除自己)拒绝。 */
|
|
2484
|
+
async function canvasRename(args) {
|
|
2485
|
+
const pid = String((args && args.projectId) || '');
|
|
2486
|
+
const cid = normalizeCanvasId(args && args.canvasId);
|
|
2487
|
+
const name = String((args && args.name) || '').trim();
|
|
2488
|
+
if (!name) throw new Error('需要画布名称');
|
|
2489
|
+
const projectDoc = readDoc(pid);
|
|
2490
|
+
const list = canvasRegistry(projectDoc);
|
|
2491
|
+
if (!cid) {
|
|
2492
|
+
if (!defaultCanvasExists(projectDoc)) throw new Error('默认画布已删除,无法重命名');
|
|
2493
|
+
if (list.some((c) => String(c.name).trim() === name)) throw new Error('同名画布已存在:' + name);
|
|
2494
|
+
patchProjectDoc(pid, { defaultCanvasName: name.slice(0, 80) });
|
|
2495
|
+
return { ok: true, projectId: pid, canvasId: null, name: name.slice(0, 80), defaultCanvasName: name.slice(0, 80) };
|
|
2496
|
+
}
|
|
2497
|
+
const target = list.find((c) => c.id === cid);
|
|
2498
|
+
if (!target) throw new Error('画布不存在:' + cid);
|
|
2499
|
+
const defNameApi = defaultCanvasNameForApi(projectDoc);
|
|
2500
|
+
if (defNameApi && String(defNameApi).trim() === name) throw new Error('同名画布已存在:' + name);
|
|
2501
|
+
if (list.some((c) => c.id !== cid && String(c.name).trim() === name)) throw new Error('同名画布已存在:' + name);
|
|
2502
|
+
const next = list.map((c) => (c.id === cid ? { ...c, name: name.slice(0, 80) } : c));
|
|
2503
|
+
patchProjectDoc(pid, { canvases: sanitizeCanvases(next) });
|
|
2504
|
+
return { ok: true, projectId: pid, canvasId: cid, name: name.slice(0, 80), defaultCanvasName: defaultCanvasNameForApi(projectDoc) };
|
|
2505
|
+
}
|
|
2506
|
+
/** 删除画布。canvasId=null 删除默认画布(清图 + defaultCanvasName + 标记 defaultCanvasDeleted,项目保留);
|
|
2507
|
+
* 子画布沿用原逻辑。画布总数 = 1 时拒绝删除(客户端已隐藏按钮,Host 兜底)。 */
|
|
2508
|
+
async function canvasDelete(args) {
|
|
2509
|
+
const pid = String((args && args.projectId) || '');
|
|
2510
|
+
const cid = normalizeCanvasId(args && args.canvasId);
|
|
2511
|
+
const projectDoc = readDoc(pid);
|
|
2512
|
+
const list = canvasRegistry(projectDoc);
|
|
2513
|
+
const total = canvasTotal(projectDoc);
|
|
2514
|
+
if (total <= 1) throw new Error('至少要保留一个画布');
|
|
2515
|
+
if (!cid) {
|
|
2516
|
+
if (!defaultCanvasExists(projectDoc)) throw new Error('默认画布已删除');
|
|
2517
|
+
const desiredName = defaultCanvasNameOf(projectDoc);
|
|
2518
|
+
const nextDoc = {
|
|
2519
|
+
...projectDoc,
|
|
2520
|
+
nodes: {}, edges: [],
|
|
2521
|
+
view: { x: 60, y: 40, scale: 1 },
|
|
2522
|
+
tombstones: [], edgeTombstones: [], tombstoneAt: {}, edgeTombstoneAt: {},
|
|
2523
|
+
defaultCanvasDeleted: true,
|
|
2524
|
+
defaultCanvasName: '',
|
|
2525
|
+
updatedAt: Date.now(),
|
|
2526
|
+
};
|
|
2527
|
+
writeIcDoc(pid, nextDoc);
|
|
2528
|
+
const nextCanvasId = list[0] ? list[0].id : null;
|
|
2529
|
+
return { ok: true, projectId: pid, canvasId: null, deletedName: desiredName, defaultCanvasName: '', defaultCanvasDeleted: true, nextCanvasId };
|
|
2530
|
+
}
|
|
2531
|
+
const targetId = canvasDocId(pid, cid);
|
|
2532
|
+
const inList = list.some((c) => c.id === cid);
|
|
2533
|
+
const hasDoc = Boolean(readDocOrNull(targetId));
|
|
2534
|
+
// 幂等:客户端先删本地登记再发删除请求时,Host 可能只剩文档或只剩登记
|
|
2535
|
+
if (!inList && !hasDoc) throw new Error('画布不存在:' + cid);
|
|
2536
|
+
if (inList) patchProjectDoc(pid, { canvases: sanitizeCanvases(list.filter((c) => c.id !== cid)) });
|
|
2537
|
+
try {
|
|
2538
|
+
if (sqlActive()) getDb().prepare('DELETE FROM projects WHERE id = ?').run(targetId);
|
|
2539
|
+
else unlinkSync(projectFile(targetId));
|
|
2540
|
+
} catch { /* 文档不存在视为已删除 */ }
|
|
2541
|
+
return {
|
|
2542
|
+
ok: true, projectId: pid, canvasId: cid,
|
|
2543
|
+
defaultCanvasName: defaultCanvasNameForApi(projectDoc),
|
|
2544
|
+
defaultCanvasDeleted: !defaultCanvasExists(projectDoc),
|
|
2545
|
+
};
|
|
2546
|
+
}
|
|
2547
|
+
|
|
2548
|
+
/** 与客户端一致的端口表(连线校验用)。image / video 是合并类型(旧生成/资产节点归一到这里):
|
|
2549
|
+
* image 输入 prompt+ref、输出 out;video 输入 prompt+frame+ref、输出 out/tailpick/last。 */
|
|
2076
2550
|
const PORT_TABLE = {
|
|
2077
2551
|
'text': { inputs: [], outputs: [{ id: 'out', kind: 'text' }] },
|
|
2078
2552
|
'shot': { inputs: [{ id: 'char', kind: 'image' }, { id: 'scene', kind: 'image' }, { id: 'prop', kind: 'image' }], outputs: [{ id: 'visual', kind: 'text' }, { id: 'motion', kind: 'text' }] },
|
|
2079
2553
|
'script': { inputs: [], outputs: [{ id: 'out', kind: 'text' }] },
|
|
2080
2554
|
'storyboard': { inputs: [{ id: 'prev', kind: 'text' }, { id: 'script', kind: 'text' }], outputs: [{ id: 'out', kind: 'text' }, { id: 'tail', kind: 'text' }] },
|
|
2081
|
-
'image
|
|
2082
|
-
'video
|
|
2083
|
-
'image-asset': { inputs: [{ id: 'in', kind: 'image' }], outputs: [{ id: 'out', kind: 'image' }] },
|
|
2084
|
-
'video-asset': { inputs: [{ id: 'in', kind: 'video' }], outputs: [{ id: 'out', kind: 'video' }, { id: 'tailpick', kind: 'image' }, { id: 'last', kind: 'image' }] },
|
|
2555
|
+
'image': { inputs: [{ id: 'prompt', kind: 'text' }, { id: 'ref', kind: 'image' }], outputs: [{ id: 'out', kind: 'image' }] },
|
|
2556
|
+
'video': { 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' }] },
|
|
2085
2557
|
'finalcut': { inputs: [{ id: 'in', kind: 'video' }], outputs: [] },
|
|
2086
2558
|
'group': { inputs: [], outputs: [] },
|
|
2087
2559
|
};
|
|
2088
2560
|
const MULTI_INPUT_PORTS = new Set(['ref', 'char', 'scene', 'prop']);
|
|
2089
|
-
/** Agent 改动了项目文档:记一次性信号,让画布在几秒内拉回合并(否则画布上看不到 Agent 的改动)。
|
|
2090
|
-
|
|
2561
|
+
/** Agent 改动了项目文档:记一次性信号,让画布在几秒内拉回合并(否则画布上看不到 Agent 的改动)。
|
|
2562
|
+
* canvasId 记录是哪个画布被改(缺省/默认画布 = null),客户端只刷新当前画布。 */
|
|
2563
|
+
function markDocChanged(projectId, canvasId) {
|
|
2091
2564
|
try {
|
|
2092
2565
|
const cfg = readIcConfig();
|
|
2093
|
-
|
|
2566
|
+
let cid = null;
|
|
2567
|
+
try { cid = normalizeCanvasId(canvasId); } catch { cid = null; }
|
|
2568
|
+
cfg.ui = { ...(cfg.ui || {}), docChanged: { projectId, canvasId: cid, at: Date.now() } };
|
|
2094
2569
|
writeIcConfig(cfg);
|
|
2095
2570
|
} catch { /* 配置写失败不影响主流程 */ }
|
|
2096
2571
|
}
|
|
@@ -2103,14 +2578,42 @@ function connectEdge(doc, fromId, toId, fromPort, toPort) {
|
|
|
2103
2578
|
if (fromId === toId) throw new Error('不能把节点连到自己');
|
|
2104
2579
|
const outs = (PORT_TABLE[from.type] && PORT_TABLE[from.type].outputs) || [];
|
|
2105
2580
|
const ins = (PORT_TABLE[to.type] && PORT_TABLE[to.type].inputs) || [];
|
|
2106
|
-
|
|
2581
|
+
let fp = String(fromPort || '').trim();
|
|
2107
2582
|
// 目标端口缺省时按语义选择,而不是死板取第一个输入口:
|
|
2108
2583
|
// 剧本/文本 → 分镜表 = 剧本来源(script);分镜表 → 分镜表 = 承接上一镜(prev);其余取第一个输入口
|
|
2109
2584
|
let tp = String(toPort || '').trim();
|
|
2110
2585
|
if (!tp) {
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2586
|
+
// 目标端口缺省时按语义选择(与客户端「端口单点、系统自动选口」同一套规则):
|
|
2587
|
+
// 文本 → 分镜表 script;分镜表 → 分镜表 prev;文本 → 图片/视频 prompt;
|
|
2588
|
+
// 图片 → 图片/视频 ref;视频 → 视频 frame 或成片 in。
|
|
2589
|
+
if (to.type === 'storyboard') {
|
|
2590
|
+
tp = from.type === 'storyboard' ? 'prev' : ((from.type === 'text' || from.type === 'script') ? 'script' : String((ins[0] && ins[0].id) || ''));
|
|
2591
|
+
} else if (to.type === 'image') {
|
|
2592
|
+
tp = (from.type === 'image' || from.type === 'image-asset' || from.type === 'image-gen') ? 'ref' : 'prompt';
|
|
2593
|
+
} else if (to.type === 'video') {
|
|
2594
|
+
if (from.type === 'video' || from.type === 'video-asset' || from.type === 'video-gen') tp = 'frame';
|
|
2595
|
+
else if (from.type === 'image' || from.type === 'image-asset' || from.type === 'image-gen') tp = 'ref';
|
|
2596
|
+
else tp = 'prompt';
|
|
2597
|
+
} else if (to.type === 'finalcut') {
|
|
2598
|
+
tp = 'in';
|
|
2599
|
+
} else {
|
|
2600
|
+
tp = String((ins[0] && ins[0].id) || '');
|
|
2601
|
+
}
|
|
2602
|
+
}
|
|
2603
|
+
if (!fp) {
|
|
2604
|
+
const want = ins.find((p) => p.id === tp);
|
|
2605
|
+
const same = want ? outs.filter((o) => o.kind === want.kind) : [];
|
|
2606
|
+
if (tp === 'prev') {
|
|
2607
|
+
const tail = same.find((o) => o.id === 'tail');
|
|
2608
|
+
fp = String((tail && tail.id) || (same[0] && same[0].id) || (outs[0] && outs[0].id) || '');
|
|
2609
|
+
} else if (want && want.kind === 'image') {
|
|
2610
|
+
// 视频 → 视频的 frame:优先尾帧最佳选帧,退到真·最后帧
|
|
2611
|
+
const pick = same.find((o) => o.id === 'tailpick');
|
|
2612
|
+
const last = same.find((o) => o.id === 'last');
|
|
2613
|
+
fp = String((pick && from.data && from.data.lastPickAssetId ? 'tailpick' : '') || (last && last.id) || (same[0] && same[0].id) || (outs[0] && outs[0].id) || '');
|
|
2614
|
+
} else {
|
|
2615
|
+
fp = String((same[0] && same[0].id) || (outs[0] && outs[0].id) || '');
|
|
2616
|
+
}
|
|
2114
2617
|
}
|
|
2115
2618
|
const outDef = outs.find((p) => p.id === fp);
|
|
2116
2619
|
const inDef = ins.find((p) => p.id === tp);
|
|
@@ -2130,7 +2633,8 @@ function connectEdge(doc, fromId, toId, fromPort, toPort) {
|
|
|
2130
2633
|
/** 在两个节点间连线(画布工作流的关键:剧本→分镜→图→视频靠连线组织,不要复制文本)。 */
|
|
2131
2634
|
function canvasConnect(args) {
|
|
2132
2635
|
const id = String((args && args.projectId) || '');
|
|
2133
|
-
const
|
|
2636
|
+
const canvasId = toolCanvasId(args);
|
|
2637
|
+
const doc = readToolDoc(args);
|
|
2134
2638
|
const fromId = String((args && args.fromNode) || '');
|
|
2135
2639
|
const toId = String((args && args.toNode) || '');
|
|
2136
2640
|
const already = (doc.edges || []).some((e) => e.fromNode === fromId && e.toNode === toId);
|
|
@@ -2140,10 +2644,9 @@ function canvasConnect(args) {
|
|
|
2140
2644
|
const fromLabel = String((from.data && from.data.label) || fromId);
|
|
2141
2645
|
const toLabel = String((to.data && to.data.label) || toId);
|
|
2142
2646
|
doc.updatedAt = Date.now();
|
|
2143
|
-
|
|
2144
|
-
markDocChanged(id);
|
|
2647
|
+
writeToolDoc(args, doc);
|
|
2145
2648
|
return {
|
|
2146
|
-
ok: true, projectId: id, edgeId: edge.id, already,
|
|
2649
|
+
ok: true, projectId: id, ...(canvasId ? { canvasId } : {}), edgeId: edge.id, already,
|
|
2147
2650
|
text: '✅ 已连线:「' + fromLabel + '」(' + edge.fromPort + ') → 「' + toLabel + '」(' + edge.toPort + ')。' + (already ? '(此前已连过,本次未重复建线)' : ''),
|
|
2148
2651
|
};
|
|
2149
2652
|
}
|
|
@@ -2269,7 +2772,7 @@ async function updateInstall() {
|
|
|
2269
2772
|
export const inject = ['webServer', 'tools'];
|
|
2270
2773
|
|
|
2271
2774
|
// 仅供诊断 / 自动化测试(不属于 RPC 面,运行时行为不受影响)
|
|
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 };
|
|
2775
|
+
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, canvasesList, canvasCreate, canvasRename, canvasDelete, canvasDocId, isCanvasDocId, splitCanvasDocId, normalizeCanvasId, canvasRegistry, sanitizeCanvases, readCanvasDoc, writeCanvasDoc, deleteCanvasDocsForProject, projectsList, projectsPut, projectsGet, projectsDelete, projectsSync, sqliteAvailable: () => !!SQLITE, getDb, closeDb, dbAssetsList, dbAssetsGet, dbAssetNames, dbAssetCreate, dbAssetUpdate, dbAssetDelete, objectAbsFor, ftsReconcile, readDocOrNull, docExists, sqlActive, rpcMethods: () => _rpcMethods, canvasUpdateNode, canvasGenerateNode };
|
|
2273
2776
|
|
|
2274
2777
|
function resolveModel(providers, want) {
|
|
2275
2778
|
const byProvider = want && want.provider
|
|
@@ -2627,6 +3130,10 @@ export function apply(ctx) {
|
|
|
2627
3130
|
async canvasRunNode(args) { return canvasRunNode(args); },
|
|
2628
3131
|
async canvasNodeStatus(args) { return canvasNodeStatus(args); },
|
|
2629
3132
|
async canvasOpen(args) { return canvasOpen(args); },
|
|
3133
|
+
async canvasesList(args) { return canvasesList(args); },
|
|
3134
|
+
async canvasCreate(args) { return canvasCreate(args); },
|
|
3135
|
+
async canvasRename(args) { return canvasRename(args); },
|
|
3136
|
+
async canvasDelete(args) { return canvasDelete(args); },
|
|
2630
3137
|
async assetDelete(args) {
|
|
2631
3138
|
const id = String((args && args.id) || '');
|
|
2632
3139
|
if (!id) return { ok: true };
|
|
@@ -2717,7 +3224,7 @@ export function apply(ctx) {
|
|
|
2717
3224
|
};
|
|
2718
3225
|
reg({
|
|
2719
3226
|
name: 'canvas_list_projects',
|
|
2720
|
-
description: '
|
|
3227
|
+
description: '列出无限画布的项目、每个项目下的画布(多画布:默认画布 + 子画布)及其节点(节点 id/类型/名称/状态)。用户提到画布项目、想看有哪些项目/画布、或要指定某个项目/画布/节点时使用。子画布用 canvasId 标识(缺省 = 默认画布)。',
|
|
2721
3228
|
parameters: { type: 'object', properties: { keyword: { type: 'string', description: '按项目名称过滤(可选)' } }, additionalProperties: false },
|
|
2722
3229
|
output: {
|
|
2723
3230
|
schema: { type: 'object', properties: { count: { type: 'integer' }, text: { type: 'string' } }, required: ['count', 'text'] },
|
|
@@ -2729,8 +3236,13 @@ export function apply(ctx) {
|
|
|
2729
3236
|
const list = projectsList().filter((p) => !kw || String(p.name).toLowerCase().includes(kw));
|
|
2730
3237
|
const lines = [];
|
|
2731
3238
|
for (const p of list) {
|
|
2732
|
-
|
|
3239
|
+
const canvases = Array.isArray(p.canvases) ? p.canvases : [];
|
|
3240
|
+
const defTxt = p.defaultCanvasDeleted
|
|
3241
|
+
? '默认画布已删除'
|
|
3242
|
+
: '默认画布「' + (p.defaultCanvasName || '默认画布') + '」canvasId=null';
|
|
3243
|
+
lines.push('- 「' + p.name + '」 id=' + p.id + ' · ' + defTxt + ' · ' + canvases.length + ' 个子画布 · ' + p.nodes.length + ' 节点 · 更新于 ' + new Date(p.updatedAt).toLocaleString());
|
|
2733
3244
|
for (const n of p.nodes) lines.push(' · [' + n.type + '] ' + (n.label || '(未命名)') + ' id=' + n.id + ' status=' + n.status);
|
|
3245
|
+
for (const c of canvases) lines.push(' ▸ 画布「' + (c.name || c.id) + '」 canvasId=' + c.id + '(用 canvasId 参数指定;资产与设定集按项目共享)');
|
|
2734
3246
|
}
|
|
2735
3247
|
const text = lines.length ? ('共 ' + list.length + ' 个项目:\n' + lines.join('\n')) : '画布上还没有项目。可调用 canvas_create_project 创建。';
|
|
2736
3248
|
return { count: list.length, text };
|
|
@@ -2767,6 +3279,7 @@ export function apply(ctx) {
|
|
|
2767
3279
|
type: 'object',
|
|
2768
3280
|
properties: {
|
|
2769
3281
|
projectId: { type: 'string', description: '项目 id(来自 canvas_list_projects 或 canvas_create_project)' },
|
|
3282
|
+
canvasId: { type: 'string', description: '可选:画布 id(项目下子画布;缺省 = 项目默认画布。多画布项目必须显式指定目标画布)' },
|
|
2770
3283
|
text: { type: 'string', description: '要写入的内容(按 target 与节点类型决定写哪里)' },
|
|
2771
3284
|
kind: { type: 'string', enum: ['script', 'text'], description: '新节点类型:script=剧本(默认),text=纯文本' },
|
|
2772
3285
|
target: { type: 'string', enum: ['idea', 'output'], description: '写哪一部分:idea=提示词/创作意图(默认),output=成稿' },
|
|
@@ -2793,6 +3306,7 @@ export function apply(ctx) {
|
|
|
2793
3306
|
type: 'object',
|
|
2794
3307
|
properties: {
|
|
2795
3308
|
projectId: { type: 'string', description: '项目 id' },
|
|
3309
|
+
canvasId: { type: 'string', description: '可选:画布 id(项目下的子画布;缺省 = 项目默认画布。来自 canvas_list_projects 的画布清单)' },
|
|
2796
3310
|
nodeId: { type: 'string', description: '节点 id(来自 canvas_list_projects)' },
|
|
2797
3311
|
},
|
|
2798
3312
|
required: ['projectId', 'nodeId'],
|
|
@@ -2812,13 +3326,14 @@ export function apply(ctx) {
|
|
|
2812
3326
|
type: 'object',
|
|
2813
3327
|
properties: {
|
|
2814
3328
|
projectId: { type: 'string', description: '项目 id' },
|
|
3329
|
+
canvasId: { type: 'string', description: '可选:画布 id(项目下的子画布;缺省 = 项目默认画布)' },
|
|
2815
3330
|
nodeId: { type: 'string', description: '节点 id' },
|
|
2816
3331
|
},
|
|
2817
3332
|
required: ['projectId', 'nodeId'],
|
|
2818
3333
|
additionalProperties: false,
|
|
2819
3334
|
},
|
|
2820
3335
|
output: {
|
|
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'] },
|
|
3336
|
+
schema: { type: 'object', properties: { ok: { type: 'boolean' }, text: { type: 'string' }, type: { 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'] },
|
|
2822
3337
|
render: (args, value) => [{ type: 'text', text: String(value.text || '') }],
|
|
2823
3338
|
},
|
|
2824
3339
|
timeoutMs: 30000,
|
|
@@ -2870,7 +3385,7 @@ export function apply(ctx) {
|
|
|
2870
3385
|
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
3386
|
if (s.tailPick && s.tailPickAssetId) lines.push('尾帧最佳选帧: ' + Number(s.tailPick.t || 0).toFixed(2) + 's 处(得分 ' + s.tailPick.score + ')资产 ' + s.tailPickAssetId);
|
|
2872
3387
|
return {
|
|
2873
|
-
ok: true, status: s.status, assetId: s.assetId || undefined,
|
|
3388
|
+
ok: true, type: s.type, status: s.status, assetId: s.assetId || undefined,
|
|
2874
3389
|
prompt: s.prompt || undefined, ratio: s.ratio || undefined, size: s.size || undefined,
|
|
2875
3390
|
duration: s.duration == null ? undefined : s.duration, resolution: s.resolution || undefined,
|
|
2876
3391
|
frameMode: s.frameMode || undefined, imageModel: s.imageModel || undefined, videoModel: s.videoModel || undefined,
|
|
@@ -2890,12 +3405,13 @@ export function apply(ctx) {
|
|
|
2890
3405
|
type: 'object',
|
|
2891
3406
|
properties: {
|
|
2892
3407
|
projectId: { type: 'string', description: '项目 id' },
|
|
3408
|
+
canvasId: { type: 'string', description: '可选:画布 id(项目下的子画布;缺省 = 项目默认画布)' },
|
|
2893
3409
|
nodeId: { type: 'string', description: '节点 id(来自 canvas_list_projects)' },
|
|
2894
3410
|
patch: {
|
|
2895
3411
|
type: 'object',
|
|
2896
3412
|
description: '要修改的字段(只传需要改的,其余保持不动)',
|
|
2897
3413
|
properties: {
|
|
2898
|
-
prompt: { type: 'string', description: '
|
|
3414
|
+
prompt: { type: 'string', description: '图片/视频节点的提示词(对话框内容)' },
|
|
2899
3415
|
idea: { type: 'string', description: '剧本节点的创作意图' },
|
|
2900
3416
|
output: { type: 'string', description: '剧本节点的成稿' },
|
|
2901
3417
|
text: { type: 'string', description: '文本节点的正文' },
|
|
@@ -2934,6 +3450,7 @@ export function apply(ctx) {
|
|
|
2934
3450
|
type: 'object',
|
|
2935
3451
|
properties: {
|
|
2936
3452
|
projectId: { type: 'string', description: '项目 id' },
|
|
3453
|
+
canvasId: { type: 'string', description: '可选:画布 id(项目下的子画布;缺省 = 项目默认画布)' },
|
|
2937
3454
|
nodeId: { type: 'string', description: '节点 id' },
|
|
2938
3455
|
waitSeconds: { type: 'number', description: '可选:等待秒数(0=只触发;上限 600)' },
|
|
2939
3456
|
},
|
|
@@ -2956,7 +3473,7 @@ export function apply(ctx) {
|
|
|
2956
3473
|
description: '删除无限画布项目中的一个节点(连同它的连线)。用于清理误创建的节点。',
|
|
2957
3474
|
parameters: {
|
|
2958
3475
|
type: 'object',
|
|
2959
|
-
properties: { projectId: { type: 'string', description: '项目 id' }, nodeId: { type: 'string', description: '节点 id' } },
|
|
3476
|
+
properties: { projectId: { type: 'string', description: '项目 id' }, canvasId: { type: 'string', description: '可选:画布 id(缺省 = 项目默认画布)' }, nodeId: { type: 'string', description: '节点 id' } },
|
|
2960
3477
|
required: ['projectId', 'nodeId'],
|
|
2961
3478
|
additionalProperties: false,
|
|
2962
3479
|
},
|
|
@@ -2972,7 +3489,7 @@ export function apply(ctx) {
|
|
|
2972
3489
|
description: '一键重排画布节点布局(消除重叠):按「行」排列——横向数据流决定列(文本→分镜表→视频→资产),竖向关系(衔接 prev / 首帧 frame)不参与分列;每行 = 一个横向连通组(如「场N文本 ↔ 场N分镜表」同行对齐),行高取行内最高节点、行间保留间距。节点重叠、或批量建点后布局混乱时调用。',
|
|
2973
3490
|
parameters: {
|
|
2974
3491
|
type: 'object',
|
|
2975
|
-
properties: { projectId: { type: 'string', description: '项目 id' } },
|
|
3492
|
+
properties: { projectId: { type: 'string', description: '项目 id' }, canvasId: { type: 'string', description: '可选:画布 id(缺省 = 项目默认画布)' } },
|
|
2976
3493
|
required: ['projectId'],
|
|
2977
3494
|
additionalProperties: false,
|
|
2978
3495
|
},
|
|
@@ -3055,12 +3572,13 @@ export function apply(ctx) {
|
|
|
3055
3572
|
});
|
|
3056
3573
|
reg({
|
|
3057
3574
|
name: 'canvas_create_node',
|
|
3058
|
-
description: '在项目画布上新建指定类型的节点(image
|
|
3575
|
+
description: '在项目画布上新建指定类型的节点(image 图片节点 / video 视频节点 / storyboard 分镜表 / finalcut 成片 / script 剧本 / text 文本)。图片与视频是**合并节点**:有 assetId 就是已有素材(资产),空节点在画布上点开生成面板填写提示词后生成;旧类型名 image-gen / image-asset / video-gen / video-asset 仍可用,会被自动映射为 image / video。【分镜必须用 type:"storyboard" 的分镜表节点,绝不要用剧本节点装分镜】。【工作流铁律】实际生成由节点完成:你只负责写提示词、连线(connectFrom)、canvas_run_node 触发,不要把成稿直接填进 output。分镜表用 connectFrom 连到上游剧本节点(内容来自连线,不要复制剧本全文);image/video 没有上游提示词时才在 prompt 里写内容,有上游时靠连线取。',
|
|
3059
3576
|
parameters: {
|
|
3060
3577
|
type: 'object',
|
|
3061
3578
|
properties: {
|
|
3062
3579
|
projectId: { type: 'string', description: '项目 id' },
|
|
3063
|
-
|
|
3580
|
+
canvasId: { type: 'string', description: '可选:画布 id(项目下的子画布;缺省 = 项目默认画布)' },
|
|
3581
|
+
type: { type: 'string', enum: ['script', 'text', 'image', 'video', 'storyboard', 'finalcut', 'image-gen', 'image-asset', 'video-gen', 'video-asset'], description: '节点类型(image/video 为合并类型;旧名 image-gen 等仍接受并自动映射)' },
|
|
3064
3582
|
name: { type: 'string', description: '节点名字(建议填写,如"角色-陈拾 参考图")' },
|
|
3065
3583
|
prompt: { type: 'string', description: '提示词(图片/视频用画面提示词;剧本用创作意图;文本用正文)' },
|
|
3066
3584
|
size: { type: 'string', description: '可选:图片清晰度 1K/2K/4K' },
|
|
@@ -3080,20 +3598,30 @@ export function apply(ctx) {
|
|
|
3080
3598
|
timeoutMs: 30000,
|
|
3081
3599
|
async execute(args) {
|
|
3082
3600
|
const r = canvasCreateNode(args);
|
|
3083
|
-
|
|
3601
|
+
const kindHint = r.type === 'image'
|
|
3602
|
+
? '(图片节点:有 assetId 即资产;空节点在画布上点开生成面板写提示词后生成)'
|
|
3603
|
+
: r.type === 'video'
|
|
3604
|
+
? '(视频节点:有 assetId 即资产;空节点在画布上点开生成面板写提示词后生成)'
|
|
3605
|
+
: '';
|
|
3606
|
+
return {
|
|
3607
|
+
ok: true, projectId: r.projectId, nodeId: r.nodeId, label: r.label || undefined,
|
|
3608
|
+
text: '✅ 已新建[' + r.type + ']节点「' + (r.label || r.nodeId) + '」(id=' + r.nodeId + ')。' + kindHint
|
|
3609
|
+
+ (r.mergedType ? '\n注:旧类型 ' + r.mergedType : '') + (r.note ? '\n' + r.note : ''),
|
|
3610
|
+
};
|
|
3084
3611
|
},
|
|
3085
3612
|
});
|
|
3086
3613
|
reg({
|
|
3087
3614
|
name: 'canvas_connect',
|
|
3088
|
-
description: '在项目画布上把两个节点连成工作流连线(剧本 → 分镜表 → 图片/视频 →
|
|
3615
|
+
description: '在项目画布上把两个节点连成工作流连线(剧本 → 分镜表 → 图片/视频 → 成片)。【工作流铁律】分镜表的内容来自连线的上游剧本节点,不要把剧本全文复制进分镜表节点;图片/视频节点没有上游提示词时才在 prompt 里写内容,有上游时靠连线取。端口类型必须匹配(text→text、image→image、video→video),不匹配会报错;单线输入端口(script/prompt/frame/in)重复连线会替换旧线,ref 类端口允许多条。目标端口缺省时按语义自动选:文本→分镜表 script、分镜表→分镜表 prev、文本→图片/视频 prompt、图片→图片/视频 ref、视频→视频 frame / 成片 in。',
|
|
3089
3616
|
parameters: {
|
|
3090
3617
|
type: 'object',
|
|
3091
3618
|
properties: {
|
|
3092
3619
|
projectId: { type: 'string', description: '项目 id' },
|
|
3620
|
+
canvasId: { type: 'string', description: '可选:画布 id(项目下的子画布;缺省 = 项目默认画布)' },
|
|
3093
3621
|
fromNode: { type: 'string', description: '源节点 id(连出方)' },
|
|
3094
3622
|
fromPort: { type: 'string', description: '可选:源端口,默认该节点第一个输出端口(剧本/文本/图片/视频都是 out)' },
|
|
3095
3623
|
toNode: { type: 'string', description: '目标节点 id(连入方)' },
|
|
3096
|
-
toPort: { type: 'string', description: '
|
|
3624
|
+
toPort: { type: 'string', description: '可选:目标端口,缺省按语义自动选择(分镜表=script/prev、图片=prompt/ref、视频=prompt/ref/frame、成片=in)' },
|
|
3097
3625
|
},
|
|
3098
3626
|
required: ['projectId', 'fromNode', 'toNode'],
|
|
3099
3627
|
additionalProperties: false,
|
|
@@ -3108,7 +3636,7 @@ export function apply(ctx) {
|
|
|
3108
3636
|
reg({
|
|
3109
3637
|
name: 'canvas_open',
|
|
3110
3638
|
description: '打开无限画布页面并跳到指定项目(画布侧会在几秒内自动切换)。用户想查看/编辑画布时使用。',
|
|
3111
|
-
parameters: { type: 'object', properties: { projectId: { type: 'string', description: '要打开的项目 id(缺省只打开画布页)' } }, additionalProperties: false },
|
|
3639
|
+
parameters: { type: 'object', properties: { projectId: { type: 'string', description: '要打开的项目 id(缺省只打开画布页)' }, canvasId: { type: 'string', description: '可选:要打开的画布 id(缺省 = 项目默认画布)' } }, additionalProperties: false },
|
|
3112
3640
|
output: textOutput,
|
|
3113
3641
|
timeoutMs: 30000,
|
|
3114
3642
|
async execute(args) {
|