@ninkch/dsh-infinite-canvas 0.18.6 → 0.19.0
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 +47 -10
- package/README.zh-CN.md +46 -9
- package/assets/canvas-craft/SKILL.md +283 -12
- package/assets/canvas-layout/SKILL.md +6 -3
- package/assets/canvas-studio/SKILL.md +57 -11
- package/assets/director/engine.js +5177 -0
- package/assets/director/models/bld_a.json +1 -0
- package/assets/director/models/bld_b.json +1 -0
- package/assets/director/models/bld_c.json +1 -0
- package/assets/director/models/bld_d.json +1 -0
- package/assets/director/models/bld_e.json +1 -0
- package/assets/director/models/bld_f.json +1 -0
- package/assets/director/models/bld_g.json +1 -0
- package/assets/director/models/bld_h.json +1 -0
- package/assets/director/models/bld_i.json +1 -0
- package/assets/director/models/bld_j.json +1 -0
- package/assets/director/models/bld_skyscraper_a.json +1 -0
- package/assets/director/models/bld_skyscraper_b.json +1 -0
- package/assets/director/models/bld_skyscraper_c.json +1 -0
- package/assets/director/models/bld_skyscraper_d.json +1 -0
- package/assets/director/models/cesiumman.json +1 -0
- package/assets/director/models/furn_bedDouble.json +1 -0
- package/assets/director/models/furn_bench.json +1 -0
- package/assets/director/models/furn_bookcaseClosed.json +1 -0
- package/assets/director/models/furn_cabinetTelevision.json +1 -0
- package/assets/director/models/furn_chair.json +1 -0
- package/assets/director/models/furn_kitchenBar.json +1 -0
- package/assets/director/models/furn_lampRoundFloor.json +1 -0
- package/assets/director/models/furn_rugRectangle.json +1 -0
- package/assets/director/models/furn_stoolBar.json +1 -0
- package/assets/director/models/furn_tableCross.json +1 -0
- package/assets/director/models/furn_televisionModern.json +1 -0
- package/assets/director/models/index.json +285 -0
- package/assets/director/models/soldier.json +1 -0
- package/assets/director/models/tree_blocks.json +1 -0
- package/assets/director/models/tree_cone.json +1 -0
- package/assets/director/models/tree_default.json +1 -0
- package/assets/director/models/tree_detailed.json +1 -0
- package/assets/director/models/tree_fat.json +1 -0
- package/assets/director/models/tree_oak.json +1 -0
- package/assets/director/models/tree_palm.json +1 -0
- package/assets/director/models/tree_palmDetailedTall.json +1 -0
- package/assets/director/models/tree_palmTall.json +1 -0
- package/assets/director/models/tree_pineDefaultA.json +1 -0
- package/assets/director/models/tree_pineRoundA.json +1 -0
- package/assets/director/models/tree_thin.json +1 -0
- package/assets/director/models/xbot.json +1 -0
- package/client.js +8357 -1357
- package/docs/UI-RULES.md +73 -0
- package/docs/director-3d.md +578 -0
- package/docs/guide.en.md +84 -8
- package/docs/guide.zh-CN.md +85 -9
- package/docs/images/director-assets-buildings-en.png +0 -0
- package/docs/images/director-assets-buildings-zh.png +0 -0
- package/docs/images/director-assets-characters-en.png +0 -0
- package/docs/images/director-assets-characters-zh.png +0 -0
- package/docs/images/director-assets-furniture-en.png +0 -0
- package/docs/images/director-assets-furniture-zh.png +0 -0
- package/docs/images/director-assets-primitives-en.png +0 -0
- package/docs/images/director-assets-primitives-zh.png +0 -0
- package/docs/images/director-assets-trees-en.png +0 -0
- package/docs/images/director-assets-trees-zh.png +0 -0
- package/docs/images/director-daylight-en.png +0 -0
- package/docs/images/director-dynamic-en.png +0 -0
- package/docs/images/director-dynamic-zh.png +0 -0
- package/docs/images/director-live-render-zh.png +0 -0
- package/docs/images/director-static-en.png +0 -0
- package/docs/images/director-static-zh.png +0 -0
- package/docs/images/help-en.png +0 -0
- package/docs/images/help-zh.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-zh.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/scene1-zh.png +0 -0
- package/docs/images/shortcuts-en.png +0 -0
- package/docs/images/shortcuts-zh.png +0 -0
- package/docs/images/video-node-refs.png +0 -0
- package/docs/storyboard-segmentation.md +446 -0
- package/index.js +802 -120
- package/package.json +5 -2
- package/docs/images/panel-bible-en.png +0 -0
- package/docs/images/panel-projects-en.png +0 -0
- package/docs/images/pipeline-zh.png +0 -0
- package/docs/images/provider-detail.png +0 -0
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) {
|
|
@@ -922,6 +966,9 @@ const MIME_BY_EXT = {
|
|
|
922
966
|
webm: 'video/webm', mp4: 'video/mp4', mov: 'video/quicktime', mkv: 'video/x-matroska',
|
|
923
967
|
wav: 'audio/wav', mp3: 'audio/mpeg', m4a: 'audio/mp4', ogg: 'audio/ogg',
|
|
924
968
|
json: 'application/json', srt: 'text/plain; charset=utf-8', txt: 'text/plain; charset=utf-8',
|
|
969
|
+
// js/mjs:浏览器动态 import() 只认 text/javascript —— 导演台引擎模块(assets/director/engine.js)
|
|
970
|
+
// 走 plugin-asset 路由加载,没有这个 MIME 会被浏览器拒载("Failed to fetch dynamically imported module")
|
|
971
|
+
js: 'text/javascript; charset=utf-8', mjs: 'text/javascript; charset=utf-8',
|
|
925
972
|
};
|
|
926
973
|
function mimeOf(p) {
|
|
927
974
|
const m = /\.([a-z0-9]+)$/i.exec(p);
|
|
@@ -977,13 +1024,76 @@ function pickFolderDialog() {
|
|
|
977
1024
|
// 客户端是内容权威(编辑后推送全量文档);Agent 经工具在 Host 侧追加节点;客户端轮询合并。
|
|
978
1025
|
const PROJECTS_DIR = process.env.DSH_IC_PROJECTS_DIR || path.join(os.homedir(), '.dsh', 'infinite-canvas', 'projects');
|
|
979
1026
|
function projectFile(id) {
|
|
980
|
-
|
|
981
|
-
|
|
1027
|
+
const s = String(id || '');
|
|
1028
|
+
const seg = isCanvasDocId(s) ? splitCanvasDocId(s) : { projectId: s, canvasId: null };
|
|
1029
|
+
const okProject = /^[A-Za-z0-9_-]{1,96}$/.test(seg.projectId || '');
|
|
1030
|
+
const okCanvas = seg.canvasId === null || /^[A-Za-z0-9_-]{1,64}$/.test(seg.canvasId || '');
|
|
1031
|
+
if (!okProject || !okCanvas) throw new Error('非法项目 id');
|
|
1032
|
+
return path.join(PROJECTS_DIR, s + '.json');
|
|
1033
|
+
}
|
|
1034
|
+
// ---------- 多画布(项目下子目录 = 独立画布文档)----------
|
|
1035
|
+
// 默认画布:canvasId = null,文档 id 仍是 projectId(旧数据零迁移)。
|
|
1036
|
+
// 子画布:文档 id = `${projectId}__cv__${canvasId}`,项目文档里登记 canvases: [{id,name,createdAt}];
|
|
1037
|
+
// 资产/设定集仍以 projectId 为维度共享,不随画布分割。
|
|
1038
|
+
const CANVAS_ID_SEP = '__cv__';
|
|
1039
|
+
function canvasDocId(projectId, canvasId) {
|
|
1040
|
+
const cid = normalizeCanvasId(canvasId);
|
|
1041
|
+
return cid ? (String(projectId) + CANVAS_ID_SEP + cid) : String(projectId);
|
|
1042
|
+
}
|
|
1043
|
+
function isCanvasDocId(docId) { return String(docId || '').includes(CANVAS_ID_SEP); }
|
|
1044
|
+
function splitCanvasDocId(docId) {
|
|
1045
|
+
const s = String(docId || '');
|
|
1046
|
+
const i = s.indexOf(CANVAS_ID_SEP);
|
|
1047
|
+
if (i < 0) return { projectId: s, canvasId: null };
|
|
1048
|
+
return { projectId: s.slice(0, i), canvasId: s.slice(i + CANVAS_ID_SEP.length) || null };
|
|
1049
|
+
}
|
|
1050
|
+
function normalizeCanvasId(v) {
|
|
1051
|
+
const s = String(v == null ? '' : v).trim();
|
|
1052
|
+
if (!s) return null;
|
|
1053
|
+
if (!/^[A-Za-z0-9_-]{1,64}$/.test(s)) throw new Error('非法画布 id: ' + s);
|
|
1054
|
+
return s;
|
|
1055
|
+
}
|
|
1056
|
+
function canvasRegistry(projectDoc) {
|
|
1057
|
+
return Array.isArray(projectDoc && projectDoc.canvases)
|
|
1058
|
+
? 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 }))
|
|
1059
|
+
: [];
|
|
1060
|
+
}
|
|
1061
|
+
/** 规范化画布清单(Host 侧唯一写入口):丢弃非法 id、同名去重、最多 200 个画布。 */
|
|
1062
|
+
function sanitizeCanvases(list) {
|
|
1063
|
+
const out = [];
|
|
1064
|
+
const seen = new Set();
|
|
1065
|
+
for (const raw of (Array.isArray(list) ? list : [])) {
|
|
1066
|
+
let cid;
|
|
1067
|
+
try { cid = normalizeCanvasId(raw && raw.id); } catch { continue; }
|
|
1068
|
+
if (!cid || seen.has(cid)) continue;
|
|
1069
|
+
seen.add(cid);
|
|
1070
|
+
out.push({ id: cid, name: String((raw && raw.name) || cid).slice(0, 80), createdAt: Number(raw && raw.createdAt) || Date.now() });
|
|
1071
|
+
}
|
|
1072
|
+
return out.slice(0, 200);
|
|
1073
|
+
}
|
|
1074
|
+
// ---- 默认画布(canvasId=null):名字存项目文档 defaultCanvasName;删除后 defaultCanvasDeleted=true ----
|
|
1075
|
+
const DEFAULT_CANVAS_NAME = '默认画布';
|
|
1076
|
+
/** 默认画布名:缺省 '默认画布'(旧项目无该字段 → 零迁移)。 */
|
|
1077
|
+
function defaultCanvasNameOf(doc) {
|
|
1078
|
+
const n = String((doc && doc.defaultCanvasName) || '').trim();
|
|
1079
|
+
return (n || DEFAULT_CANVAS_NAME).slice(0, 80);
|
|
1080
|
+
}
|
|
1081
|
+
/** 默认画布是否还存在(尚未删除)。 */
|
|
1082
|
+
function defaultCanvasExists(doc) { return !(doc && doc.defaultCanvasDeleted === true); }
|
|
1083
|
+
/** API 返回的默认画布名:默认画布已删除 → 空字符串(storage 里已清空)。 */
|
|
1084
|
+
function defaultCanvasNameForApi(doc) { return defaultCanvasExists(doc) ? defaultCanvasNameOf(doc) : ''; }
|
|
1085
|
+
/** 项目画布总数 = 默认画布(若还在)+ 子画布。 */
|
|
1086
|
+
function canvasTotal(projectDoc) { return canvasRegistry(projectDoc).length + (defaultCanvasExists(projectDoc) ? 1 : 0); }
|
|
1087
|
+
/** 项目下全部画布名(默认画布 + 子画布):重名判定用。 */
|
|
1088
|
+
function allCanvasNames(projectDoc) {
|
|
1089
|
+
const names = defaultCanvasExists(projectDoc) ? [defaultCanvasNameOf(projectDoc)] : [];
|
|
1090
|
+
return names.concat(canvasRegistry(projectDoc).map((c) => c.name));
|
|
982
1091
|
}
|
|
983
1092
|
function projectsList() {
|
|
984
1093
|
if (sqlActive()) {
|
|
985
1094
|
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()
|
|
1095
|
+
const rows = db.prepare('SELECT id, name, created_at, updated_at, bible_updated_at, summary, tombstones FROM projects ORDER BY updated_at DESC').all()
|
|
1096
|
+
.filter((r) => !isCanvasDocId(r.id)); // 子画布行不是项目:不出现在项目列表(画布清单在项目文档里)
|
|
987
1097
|
return rows.map((r) => {
|
|
988
1098
|
let sum = {};
|
|
989
1099
|
try { sum = JSON.parse(r.summary || '{}'); } catch { /* 旧行/损坏摘要 */ }
|
|
@@ -991,8 +1101,13 @@ function projectsList() {
|
|
|
991
1101
|
try { tombs = JSON.parse(r.tombstones || '[]'); } catch { /* 旧行 */ }
|
|
992
1102
|
return {
|
|
993
1103
|
id: r.id, name: r.name, createdAt: r.created_at || 0, updatedAt: r.updated_at || 0,
|
|
994
|
-
source: sum.source || 'app', nodeIds: sum.nodeIds || [],
|
|
1104
|
+
source: sum.source || 'app', nodeIds: sum.nodeIds || [],
|
|
1105
|
+
// 旧版本摘要里可能是旧类型名:输出时按合并类型归一(image / video)
|
|
1106
|
+
nodes: (sum.nodes || []).map((n) => ({ ...n, type: normalizeNodeType(n && n.type) })),
|
|
995
1107
|
tombstones: Array.isArray(tombs) ? tombs : [], bibleUpdatedAt: r.bible_updated_at || 0,
|
|
1108
|
+
canvases: canvasRegistry(sum),
|
|
1109
|
+
defaultCanvasName: defaultCanvasNameForApi(sum),
|
|
1110
|
+
defaultCanvasDeleted: !defaultCanvasExists(sum),
|
|
996
1111
|
};
|
|
997
1112
|
});
|
|
998
1113
|
}
|
|
@@ -1001,6 +1116,7 @@ function projectsList() {
|
|
|
1001
1116
|
const list = [];
|
|
1002
1117
|
for (const f of files) {
|
|
1003
1118
|
const id = f.slice(0, -5);
|
|
1119
|
+
if (isCanvasDocId(id)) continue; // 子画布文档不是项目(由所属项目的 canvases 清单表达)
|
|
1004
1120
|
try {
|
|
1005
1121
|
let d;
|
|
1006
1122
|
try {
|
|
@@ -1011,10 +1127,10 @@ function projectsList() {
|
|
|
1011
1127
|
continue;
|
|
1012
1128
|
}
|
|
1013
1129
|
const nodes = Object.values(d.nodes || {}).map((n) => ({
|
|
1014
|
-
id: n.id, type: n.type, label: (n.data && n.data.label) || '',
|
|
1130
|
+
id: n.id, type: normalizeNodeType(n.type), label: (n.data && n.data.label) || '',
|
|
1015
1131
|
status: (n.data && n.data.status) || 'idle',
|
|
1016
1132
|
}));
|
|
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 });
|
|
1133
|
+
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
1134
|
} catch { /* 单文件损坏跳过 */ }
|
|
1019
1135
|
}
|
|
1020
1136
|
return list.sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0));
|
|
@@ -1024,9 +1140,13 @@ async function projectsPut(args) {
|
|
|
1024
1140
|
// 项目删除墓碑(长效):删除后迟到的全量推送(450ms 防抖竞速 / 启动 pushAllProjectsToHost 快照)
|
|
1025
1141
|
// 一律拒收——否则项目原地复活(资产/设定集已清,复活即残废)。客户端不会复用项目 id,拒收永不误伤。
|
|
1026
1142
|
if (deletedProjectIds().includes(id)) return { ok: true, id, deleted: true };
|
|
1027
|
-
|
|
1143
|
+
// 多画布:canvasId 缺省 = 项目默认画布(文档 id 仍是 projectId,旧数据零迁移);
|
|
1144
|
+
// 子画布写到独立文档 id,项目文档只保留名称/设定集/画布清单/默认画布图。
|
|
1145
|
+
const canvasId = normalizeCanvasId(args && args.canvasId);
|
|
1146
|
+
const targetId = canvasId ? canvasDocId(id, canvasId) : id;
|
|
1147
|
+
projectFile(targetId); // 非法 id 早失败(画布文档 id 走同一套校验)
|
|
1028
1148
|
const doc = (args && args.doc) || {};
|
|
1029
|
-
let prev = readDocOrNull(
|
|
1149
|
+
let prev = readDocOrNull(targetId) || {};
|
|
1030
1150
|
// 墓碑:对话删除的节点 + 客户端本地删除的节点(客户端删除时也会写入 tombstones)。
|
|
1031
1151
|
// tombstoneAt 记录每个墓碑的创建时刻:只有**推送时间戳(doc.ts)不早于墓碑时刻**的推送
|
|
1032
1152
|
// 才被认定为"撤销恢复"允许清墓碑;陈旧推送(快照早于删除、无 ts 或 ts 更老)不得借
|
|
@@ -1112,8 +1232,7 @@ async function projectsPut(args) {
|
|
|
1112
1232
|
const keptEdgeTomb = edgeTombstones.slice(-400);
|
|
1113
1233
|
const pruneStamp = (m, keep) => { const o = {}; for (const k of keep) { if (m[k] !== undefined) o[k] = m[k]; } return o; };
|
|
1114
1234
|
const out = {
|
|
1115
|
-
id,
|
|
1116
|
-
name: String((args && args.name) || prev.name || id).slice(0, 80),
|
|
1235
|
+
id: targetId,
|
|
1117
1236
|
createdAt: prev.createdAt || (doc && doc.createdAt) || Date.now(),
|
|
1118
1237
|
updatedAt: Date.now(),
|
|
1119
1238
|
source: prev.source || 'app',
|
|
@@ -1125,8 +1244,27 @@ async function projectsPut(args) {
|
|
|
1125
1244
|
tombstoneAt: pruneStamp(tombstoneAt, keptTomb),
|
|
1126
1245
|
edgeTombstoneAt: pruneStamp(edgeTombstoneAt, keptEdgeTomb),
|
|
1127
1246
|
};
|
|
1247
|
+
if (canvasId) {
|
|
1248
|
+
out.projectId = id;
|
|
1249
|
+
out.canvasId = canvasId; // 子画布文档:不带 name/canvases/bible(项目级字段只在项目文档)
|
|
1250
|
+
} else {
|
|
1251
|
+
out.name = String((args && args.name) || prev.name || id).slice(0, 80);
|
|
1252
|
+
out.canvases = sanitizeCanvases((args && args.canvases) !== undefined ? args.canvases : prev.canvases);
|
|
1253
|
+
// 默认画布的项目级字段:名字(缺省 '默认画布',不写字段即零迁移)与"已删除"标记
|
|
1254
|
+
const dnRaw = (args && args.defaultCanvasName !== undefined) ? args.defaultCanvasName : prev.defaultCanvasName;
|
|
1255
|
+
if (dnRaw !== undefined && dnRaw !== null && String(dnRaw).trim()) out.defaultCanvasName = String(dnRaw).trim().slice(0, 80);
|
|
1256
|
+
const ddRaw = (args && args.defaultCanvasDeleted !== undefined) ? args.defaultCanvasDeleted : prev.defaultCanvasDeleted;
|
|
1257
|
+
if (ddRaw) out.defaultCanvasDeleted = true;
|
|
1258
|
+
else if (prev.defaultCanvasDeleted) out.defaultCanvasDeleted = true; // 客户端未带该字段时保留"已删除"(防旧推送把默认画布复活)
|
|
1259
|
+
}
|
|
1128
1260
|
// 设定集随文档一起同步(世界观/风格/角色/场景/道具):对话侧可读写
|
|
1129
|
-
|
|
1261
|
+
let canvasBible = null;
|
|
1262
|
+
if (canvasId) {
|
|
1263
|
+
// 设定集是项目级:子画布推送里携带的 bible 落到项目文档(画布文档不存 bible)
|
|
1264
|
+
const projectDoc = readDocOrNull(id) || {};
|
|
1265
|
+
if (doc.bible !== undefined) canvasBible = { bible: normalizeBible(doc.bible), bibleUpdatedAt: Date.now() };
|
|
1266
|
+
else if (projectDoc.bible !== undefined) canvasBible = { bible: projectDoc.bible, bibleUpdatedAt: projectDoc.bibleUpdatedAt || 0 };
|
|
1267
|
+
} else if (doc.bible !== undefined) {
|
|
1130
1268
|
out.bible = normalizeBible(doc.bible);
|
|
1131
1269
|
out.bibleUpdatedAt = Date.now();
|
|
1132
1270
|
} else if (prev.bible !== undefined) {
|
|
@@ -1134,23 +1272,47 @@ async function projectsPut(args) {
|
|
|
1134
1272
|
out.bibleUpdatedAt = prev.bibleUpdatedAt || 0;
|
|
1135
1273
|
}
|
|
1136
1274
|
// 设定集有更新时重算全部分镜表的一致性告警(客户端侧设定集编辑也走这条路径)
|
|
1137
|
-
|
|
1275
|
+
const bibleForWarn = canvasId ? (canvasBible && canvasBible.bible) : out.bible;
|
|
1276
|
+
if (bibleForWarn !== undefined) {
|
|
1138
1277
|
try {
|
|
1139
1278
|
const assetNames = readAssetIndexNames(id);
|
|
1140
1279
|
for (const nd of Object.values(merged)) {
|
|
1141
1280
|
if (nd && nd.type === 'storyboard' && Array.isArray(nd.data && nd.data.shots) && nd.data.shots.length) {
|
|
1142
|
-
const nextW = storyboardConsistencyWarnings(
|
|
1281
|
+
const nextW = storyboardConsistencyWarnings(bibleForWarn, nd.data.shots, assetNames);
|
|
1143
1282
|
if (JSON.stringify(nextW) !== JSON.stringify(nd.data.warnings || [])) nd.data.warnings = nextW;
|
|
1144
1283
|
}
|
|
1145
1284
|
}
|
|
1146
1285
|
} catch { /* 重算失败不影响保存 */ }
|
|
1147
1286
|
}
|
|
1148
1287
|
mkdirSync(PROJECTS_DIR, { recursive: true });
|
|
1149
|
-
writeIcDoc(
|
|
1150
|
-
|
|
1288
|
+
writeIcDoc(targetId, out); // 统一走原子写 + 数据目录镜像(此前直写绕过了镜像与原子性)
|
|
1289
|
+
if (canvasId) {
|
|
1290
|
+
// 项目级字段(名称/设定集/画布清单)始终只写项目文档:项目文档缺失时补一份最小项目
|
|
1291
|
+
const projectDoc = readDocOrNull(id);
|
|
1292
|
+
if (!projectDoc) {
|
|
1293
|
+
const canvases = sanitizeCanvases((args && args.canvases) || []);
|
|
1294
|
+
if (!canvases.some((c) => c.id === canvasId)) canvases.push({ id: canvasId, name: canvasId, createdAt: Date.now() });
|
|
1295
|
+
writeIcDoc(id, {
|
|
1296
|
+
id, name: String((args && args.name) || id).slice(0, 80), createdAt: Date.now(), updatedAt: Date.now(),
|
|
1297
|
+
nodes: {}, edges: [], view: { x: 60, y: 40, scale: 1 }, tombstones: [], canvases,
|
|
1298
|
+
defaultCanvasName: defaultCanvasNameOf({ defaultCanvasName: args && args.defaultCanvasName }),
|
|
1299
|
+
...(canvasBible ? { bible: canvasBible.bible, bibleUpdatedAt: canvasBible.bibleUpdatedAt } : {}),
|
|
1300
|
+
});
|
|
1301
|
+
} else {
|
|
1302
|
+
const patch = {};
|
|
1303
|
+
if (canvasBible) { patch.bible = canvasBible.bible; patch.bibleUpdatedAt = canvasBible.bibleUpdatedAt; }
|
|
1304
|
+
if (args && args.canvases !== undefined) patch.canvases = sanitizeCanvases(args.canvases);
|
|
1305
|
+
if (Object.keys(patch).length) patchProjectDoc(id, patch);
|
|
1306
|
+
}
|
|
1307
|
+
}
|
|
1308
|
+
return { ok: true, id, ...(canvasId ? { canvasId } : {}), updatedAt: out.updatedAt };
|
|
1151
1309
|
}
|
|
1152
1310
|
async function projectsGet(args) {
|
|
1153
|
-
|
|
1311
|
+
const id = String((args && args.id) || '');
|
|
1312
|
+
const canvasId = normalizeCanvasId(args && args.canvasId);
|
|
1313
|
+
if (!canvasId) return readDoc(id);
|
|
1314
|
+
// 默认画布/子画布同一入口:子画布返回独立文档 + 项目级字段(名称/设定集/画布清单)
|
|
1315
|
+
return readCanvasDoc(id, canvasId);
|
|
1154
1316
|
}
|
|
1155
1317
|
/** 长效项目删除墓碑(区别于 projectsSync 的一次性 deletedProjects 通知信号):
|
|
1156
1318
|
* 删除后任何迟到的 projectsPut 都被拒收,直到 dataRootImport 显式恢复该 id。 */
|
|
@@ -1244,6 +1406,7 @@ async function projectsDelete(args) {
|
|
|
1244
1406
|
cleanup = sqlDeleteProjectRows(getDb(), id, dataRoot());
|
|
1245
1407
|
} else {
|
|
1246
1408
|
try { unlinkSync(projectFile(id)); } catch { /* 已不存在视为成功 */ }
|
|
1409
|
+
try { deleteCanvasDocsForProject(id); } catch { /* 子画布清理失败不影响删除 */ }
|
|
1247
1410
|
try { removeDataRootProject(id); } catch { /* 数据目录镜像清理失败不影响删除 */ }
|
|
1248
1411
|
}
|
|
1249
1412
|
try { addDeletedProjectId(id); } catch { /* 墓碑写失败不影响删除 */ }
|
|
@@ -1267,6 +1430,100 @@ function defaultLabel(text) {
|
|
|
1267
1430
|
const line = String(text || '').trim().split(/\r?\n/)[0] || '';
|
|
1268
1431
|
return line.length > 18 ? line.slice(0, 18) + '…' : (line || '未命名');
|
|
1269
1432
|
}
|
|
1433
|
+
/** 旧节点类型 → 合并类型(与客户端 client.js 同一映射):
|
|
1434
|
+
* image-gen / image-asset → image,video-gen / video-asset → video。
|
|
1435
|
+
* 旧项目文档读入时统一归一(只改 type,节点 data / 连线端点原样保留:有 assetId 就是资产,有 prompt 就是提示词)。 */
|
|
1436
|
+
const LEGACY_NODE_TYPES = { 'image-gen': 'image', 'image-asset': 'image', 'video-gen': 'video', 'video-asset': 'video' };
|
|
1437
|
+
function normalizeNodeType(type) { return LEGACY_NODE_TYPES[type] || type; }
|
|
1438
|
+
function normalizeNode(n) {
|
|
1439
|
+
if (n && typeof n === 'object') {
|
|
1440
|
+
const t = normalizeNodeType(n.type);
|
|
1441
|
+
if (t !== n.type) n.type = t;
|
|
1442
|
+
}
|
|
1443
|
+
return n;
|
|
1444
|
+
}
|
|
1445
|
+
function normalizeDocNodes(doc) {
|
|
1446
|
+
if (!doc || typeof doc !== 'object') return doc;
|
|
1447
|
+
for (const n of Object.values(doc.nodes || {})) {
|
|
1448
|
+
normalizeNode(n);
|
|
1449
|
+
normalizeMediaNodeSize(n); // 与客户端同一几何规则:媒体节点外框 = 预览区 + 标题栏 +(有结果时)操作栏
|
|
1450
|
+
}
|
|
1451
|
+
return doc;
|
|
1452
|
+
}
|
|
1453
|
+
const MERGED_NODE_TYPES = ['image', 'video'];
|
|
1454
|
+
// ---- 媒体节点几何(必须与 client.js 的 NODE_HDR_H / NODE_FOOT_H / MEDIA_PREVIEW_SHORT / MEDIA_MAX_LONG 一致)----
|
|
1455
|
+
// 媒体节点三段:标题栏(固定)+ 预览区(盒比 = 画面比例,短边 360、长边 ≤800)+ 操作栏(固定,有结果才有)。
|
|
1456
|
+
const NODE_HDR_H = 32;
|
|
1457
|
+
const NODE_FOOT_H = 32;
|
|
1458
|
+
const MEDIA_PREVIEW_SHORT = 360;
|
|
1459
|
+
const MEDIA_MAX_LONG = 800;
|
|
1460
|
+
const RATIOS = ['16:9', '9:16', '1:1', '4:3', '3:4', '21:9'];
|
|
1461
|
+
const ratioIsValid = (r) => {
|
|
1462
|
+
const [rw, rh] = String(r || '').split(':').map(Number);
|
|
1463
|
+
return rw > 0 && rh > 0;
|
|
1464
|
+
};
|
|
1465
|
+
/** 画面比例 → 预览区尺寸(不含标题栏/操作栏)。 */
|
|
1466
|
+
function mediaPreviewSize(ratio) {
|
|
1467
|
+
const [rw, rh] = String(ratio == null ? '' : ratio).split(':').map(Number);
|
|
1468
|
+
if (!rw || !rh) return { w: MEDIA_PREVIEW_SHORT, h: MEDIA_PREVIEW_SHORT };
|
|
1469
|
+
let w; let h;
|
|
1470
|
+
if (rw >= rh) { h = MEDIA_PREVIEW_SHORT; w = Math.round(MEDIA_PREVIEW_SHORT * rw / rh); }
|
|
1471
|
+
else { w = MEDIA_PREVIEW_SHORT; h = Math.round(MEDIA_PREVIEW_SHORT * rh / rw); }
|
|
1472
|
+
const long = Math.max(w, h);
|
|
1473
|
+
if (long > MEDIA_MAX_LONG) { const k = MEDIA_MAX_LONG / long; w = Math.round(w * k); h = Math.round(h * k); }
|
|
1474
|
+
return { w, h };
|
|
1475
|
+
}
|
|
1476
|
+
/** 媒体节点外框尺寸 = 预览区尺寸 + 固定栏高度(hasFooter 只在有 assetId 时为 true)。 */
|
|
1477
|
+
function mediaNodeSize(ratio, hasFooter) {
|
|
1478
|
+
const pv = mediaPreviewSize(ratio);
|
|
1479
|
+
return { w: pv.w, h: pv.h + NODE_HDR_H + (hasFooter ? NODE_FOOT_H : 0) };
|
|
1480
|
+
}
|
|
1481
|
+
/** 像素尺寸 → 比例(吸附到 RATIOS 档,3% 容差;否则约分)。 */
|
|
1482
|
+
function ratioFromDims(w, h) {
|
|
1483
|
+
const aw = Number(w) || 0;
|
|
1484
|
+
const ah = Number(h) || 0;
|
|
1485
|
+
if (!(aw > 0 && ah > 0)) return '16:9';
|
|
1486
|
+
const want = aw / ah;
|
|
1487
|
+
let best = null;
|
|
1488
|
+
for (const r of RATIOS) {
|
|
1489
|
+
const [rw, rh] = r.split(':').map(Number);
|
|
1490
|
+
const diff = Math.abs(rw / rh - want) / want;
|
|
1491
|
+
if (!best || diff < best.diff) best = { ratio: r, diff };
|
|
1492
|
+
}
|
|
1493
|
+
if (best && best.diff <= 0.03) return best.ratio;
|
|
1494
|
+
const gcd = (a, b) => (b ? gcd(b, a % b) : a);
|
|
1495
|
+
const k = gcd(Math.round(aw), Math.round(ah)) || 1;
|
|
1496
|
+
return Math.round(aw / k) + ':' + Math.round(ah / k);
|
|
1497
|
+
}
|
|
1498
|
+
/** 媒体节点应使用的画面比例:记录值 → 资产实测尺寸 → 默认 16:9。 */
|
|
1499
|
+
function mediaRatioOf(n) {
|
|
1500
|
+
const d = (n && n.data) || {};
|
|
1501
|
+
if (ratioIsValid(d.ratio)) return d.ratio;
|
|
1502
|
+
const aw = Number(d.assetW);
|
|
1503
|
+
const ah = Number(d.assetH);
|
|
1504
|
+
if (aw > 0 && ah > 0) return ratioFromDims(aw, ah);
|
|
1505
|
+
return '16:9';
|
|
1506
|
+
}
|
|
1507
|
+
/** 文档读入时的幂等归一:媒体节点外框必须满足「预览区比例 = 画面比例」,
|
|
1508
|
+
* 否则按记录比例重算(用户等比拉伸过的预览区比例已正确 → 保留)。
|
|
1509
|
+
* 与 client.js normalizeMediaNodeSize 同一规则,Host 侧建节点/读文档也保持一致。 */
|
|
1510
|
+
function normalizeMediaNodeSize(n) {
|
|
1511
|
+
if (!n || (normalizeNodeType(n.type) !== 'image' && normalizeNodeType(n.type) !== 'video')) return n;
|
|
1512
|
+
const d = n.data || (n.data = {});
|
|
1513
|
+
const hasFooter = !!d.assetId;
|
|
1514
|
+
const ratio = mediaRatioOf(n);
|
|
1515
|
+
if (!ratioIsValid(d.ratio)) d.ratio = ratio;
|
|
1516
|
+
const [rw, rh] = String(ratio).split(':').map(Number);
|
|
1517
|
+
const want = rw / rh;
|
|
1518
|
+
const pw = Number(n.w) || 0;
|
|
1519
|
+
const ph = (Number(n.h) || 0) - NODE_HDR_H - (hasFooter ? NODE_FOOT_H : 0);
|
|
1520
|
+
const okPreview = pw > 0 && ph > 0 && Math.abs(pw / ph - want) / want <= 0.02;
|
|
1521
|
+
if (!okPreview) {
|
|
1522
|
+
const sz = mediaNodeSize(ratio, hasFooter);
|
|
1523
|
+
n.w = sz.w; n.h = sz.h;
|
|
1524
|
+
}
|
|
1525
|
+
return n;
|
|
1526
|
+
}
|
|
1270
1527
|
/** 构造与客户端 newNode() 完全一致的节点(形状不一致会导致渲染异常)。 */
|
|
1271
1528
|
function agentNode(kind, text, x, y, label) {
|
|
1272
1529
|
const id = 'na' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
|
|
@@ -1275,20 +1532,22 @@ function agentNode(kind, text, x, y, label) {
|
|
|
1275
1532
|
? { status: 'idle', error: null, text: String(text ?? '') }
|
|
1276
1533
|
: { status: 'idle', error: null, idea: String(text ?? ''), output: '', aiStatus: 'idle', aiError: null };
|
|
1277
1534
|
if (label) data.label = String(label).slice(0, 40);
|
|
1278
|
-
|
|
1535
|
+
// 与客户端一致:所有节点默认 360×360(文本/剧本/分镜固定这个尺寸,不随内容变化;尺寸持久化进文档)
|
|
1536
|
+
return { id, type: isText ? 'text' : 'script', x, y, w: 360, h: 360, data };
|
|
1279
1537
|
}
|
|
1280
1538
|
/** 新节点布局(用户设计):
|
|
1281
1539
|
* 1) 有 connectFrom 源 → 放在源节点右侧同一行(串联节点横向成对);
|
|
1282
1540
|
* 2) 无连线 → 同类型竖向成列(排在该类型最后一个节点下方,新起一行);
|
|
1283
1541
|
* 3) 碰撞检测:候选位置与任何现有节点(含最小间距 28px)相交 → 自动下移到该节点下方,绝不重叠。 */
|
|
1284
|
-
function nextNodePos(doc, type, connectFromId) {
|
|
1542
|
+
function nextNodePos(doc, type, connectFromId, size) {
|
|
1285
1543
|
const GAP_X = 60, GAP_Y = 40, MIN_GAP = 28;
|
|
1286
1544
|
const H = (n) => Number(n && n.h) || 160;
|
|
1287
1545
|
const W = (n) => Number(n && n.w) || 280;
|
|
1288
1546
|
const nodes = Object.values(doc.nodes || {});
|
|
1289
|
-
const EST_H = { storyboard:
|
|
1290
|
-
|
|
1291
|
-
const
|
|
1547
|
+
const EST_H = { storyboard: 360, video: 360, image: 360, script: 360, finalcut: 360, text: 360 };
|
|
1548
|
+
// 媒体节点的真实外框比 360 高/宽(预览区按比例 + 固定栏):建节点时传 size,避免估小后与邻居重叠
|
|
1549
|
+
const estH = (size && Number(size.h)) || EST_H[type] || 360;
|
|
1550
|
+
const estW = (size && Number(size.w)) || 360;
|
|
1292
1551
|
const same = nodes.filter((n) => n.type === type);
|
|
1293
1552
|
let x = 80;
|
|
1294
1553
|
let y = 40;
|
|
@@ -1405,8 +1664,8 @@ function canvasCreateProject(args) {
|
|
|
1405
1664
|
*/
|
|
1406
1665
|
function canvasWriteNode(args) {
|
|
1407
1666
|
const id = String((args && args.projectId) || '');
|
|
1408
|
-
const
|
|
1409
|
-
const doc =
|
|
1667
|
+
const canvasId = toolCanvasId(args);
|
|
1668
|
+
const doc = readToolDoc(args);
|
|
1410
1669
|
const text = String((args && args.text) || '');
|
|
1411
1670
|
const target = (args && args.target) === 'output' ? 'output' : 'idea';
|
|
1412
1671
|
const nodeId = String((args && args.nodeId) || '');
|
|
@@ -1457,7 +1716,8 @@ function canvasWriteNode(args) {
|
|
|
1457
1716
|
if (idea !== null && node.type === 'script') node.data.idea = idea;
|
|
1458
1717
|
if (name) node.data.label = name;
|
|
1459
1718
|
};
|
|
1460
|
-
const save = () => { doc.updatedAt = Date.now();
|
|
1719
|
+
const save = () => { doc.updatedAt = Date.now(); writeToolDoc(args, doc); };
|
|
1720
|
+
const withCanvas = (o) => (canvasId ? { ...o, canvasId } : o);
|
|
1461
1721
|
/** 该节点是否已有会被 target=output 覆盖的内容(剧本=成稿;分镜=镜头列表;文本=正文)。 */
|
|
1462
1722
|
const hasExisting = (node) => {
|
|
1463
1723
|
if (!node || !node.data) return false;
|
|
@@ -1475,14 +1735,14 @@ function canvasWriteNode(args) {
|
|
|
1475
1735
|
guardOverwrite(node);
|
|
1476
1736
|
writeInto(node);
|
|
1477
1737
|
save();
|
|
1478
|
-
return { ok: true, projectId: id, nodeId, updated: true, label: node.data.label || '' };
|
|
1738
|
+
return withCanvas({ ok: true, projectId: id, nodeId, updated: true, label: node.data.label || '' });
|
|
1479
1739
|
}
|
|
1480
1740
|
if ((args && args.kind) === 'text') {
|
|
1481
1741
|
const pos = nextNodePos(doc);
|
|
1482
1742
|
const n = agentNode('text', text, pos.x, pos.y, name || defaultLabel(text));
|
|
1483
1743
|
doc.nodes[n.id] = n;
|
|
1484
1744
|
save();
|
|
1485
|
-
return { ok: true, projectId: id, nodeId: n.id, label: n.data.label || '' };
|
|
1745
|
+
return withCanvas({ ok: true, projectId: id, nodeId: n.id, label: n.data.label || '' });
|
|
1486
1746
|
}
|
|
1487
1747
|
const scripts = Object.values(doc.nodes || {}).filter((n) => n.type === 'script');
|
|
1488
1748
|
let reuse = null;
|
|
@@ -1499,7 +1759,7 @@ function canvasWriteNode(args) {
|
|
|
1499
1759
|
}
|
|
1500
1760
|
writeInto(reuse);
|
|
1501
1761
|
save();
|
|
1502
|
-
return { ok: true, projectId: id, nodeId: reuse.id, updated: true, reused: true, label: reuse.data.label || '' };
|
|
1762
|
+
return withCanvas({ ok: true, projectId: id, nodeId: reuse.id, updated: true, reused: true, label: reuse.data.label || '' });
|
|
1503
1763
|
}
|
|
1504
1764
|
const pos = nextNodePos(doc);
|
|
1505
1765
|
const n = agentNode('script', target === 'output' ? '' : text, pos.x, pos.y, name || defaultLabel(text));
|
|
@@ -1507,14 +1767,15 @@ function canvasWriteNode(args) {
|
|
|
1507
1767
|
if (idea !== null) n.data.idea = idea;
|
|
1508
1768
|
doc.nodes[n.id] = n;
|
|
1509
1769
|
save();
|
|
1510
|
-
return { ok: true, projectId: id, nodeId: n.id, label: n.data.label || '' };
|
|
1770
|
+
return withCanvas({ ok: true, projectId: id, nodeId: n.id, label: n.data.label || '' });
|
|
1511
1771
|
}
|
|
1512
1772
|
/** 读取项目文档:把"文件不存在/内容损坏"变成用户和模型都读得懂的报错(不泄漏绝对路径)。 */
|
|
1513
1773
|
function readDoc(id) {
|
|
1514
1774
|
if (sqlActive()) {
|
|
1515
1775
|
const doc = readDocOrNull(id);
|
|
1516
1776
|
if (!doc) throw new Error('项目不存在:' + id + '(先用 canvas_list_projects 查看可用项目 id)');
|
|
1517
|
-
|
|
1777
|
+
// 旧项目兼容:读入即归一旧节点类型(image-gen/image-asset → image,video-gen/video-asset → video)
|
|
1778
|
+
return normalizeDocNodes(doc);
|
|
1518
1779
|
}
|
|
1519
1780
|
const f = projectFile(id);
|
|
1520
1781
|
let raw;
|
|
@@ -1524,13 +1785,16 @@ function readDoc(id) {
|
|
|
1524
1785
|
throw new Error('项目不存在:' + id + '(先用 canvas_list_projects 查看可用项目 id)');
|
|
1525
1786
|
}
|
|
1526
1787
|
try {
|
|
1527
|
-
return JSON.parse(raw);
|
|
1788
|
+
return normalizeDocNodes(JSON.parse(raw));
|
|
1528
1789
|
} catch {
|
|
1529
1790
|
throw new Error('项目文件损坏,无法解析:' + id + '(可考虑重建该项目)');
|
|
1530
1791
|
}
|
|
1531
1792
|
}
|
|
1532
|
-
/**
|
|
1793
|
+
/** 原子写项目/画布文档:SQL 模式走单行事务;文件模式先写临时文件再改名,避免半写导致项目"消失"。
|
|
1794
|
+
* 子画布文档(id 含 __cv__)不重复登记项目文件夹、不镜像 project.json——项目级 meta 只属于默认画布行。 */
|
|
1533
1795
|
function writeIcDoc(id, doc) {
|
|
1796
|
+
const canvasRef = splitCanvasDocId(id);
|
|
1797
|
+
const isCanvas = isCanvasDocId(id);
|
|
1534
1798
|
if (sqlActive()) {
|
|
1535
1799
|
const db = getDb();
|
|
1536
1800
|
db.prepare(`INSERT INTO projects (id, name, created_at, updated_at, bible_updated_at, summary, tombstones, doc)
|
|
@@ -1539,19 +1803,115 @@ function writeIcDoc(id, doc) {
|
|
|
1539
1803
|
bible_updated_at = excluded.bible_updated_at, summary = excluded.summary,
|
|
1540
1804
|
tombstones = excluded.tombstones, doc = excluded.doc`)
|
|
1541
1805
|
.run(
|
|
1542
|
-
id,
|
|
1543
|
-
doc.
|
|
1806
|
+
id,
|
|
1807
|
+
String((doc && doc.name) || (isCanvas ? canvasRef.projectId : id)).slice(0, 80),
|
|
1808
|
+
(doc && doc.createdAt) || Date.now(), (doc && doc.updatedAt) || Date.now(),
|
|
1809
|
+
(doc && doc.bibleUpdatedAt) || 0, JSON.stringify(docSummary(doc)), JSON.stringify((doc && doc.tombstones) || []), JSON.stringify(doc),
|
|
1544
1810
|
);
|
|
1545
1811
|
// SQL 模式也要保证数据目录里有 meta 映射(craft 清理/物理落盘按 folder 定位):
|
|
1546
|
-
//
|
|
1547
|
-
|
|
1812
|
+
// 子画布共用项目的物理文件夹,不单独登记。
|
|
1813
|
+
if (!isCanvas) {
|
|
1814
|
+
try { ensureProjectFolder(id, (doc && doc.name) || id); } catch { /* meta 写失败不阻塞主存储 */ }
|
|
1815
|
+
}
|
|
1548
1816
|
return;
|
|
1549
1817
|
}
|
|
1550
1818
|
const f = projectFile(id);
|
|
1551
1819
|
const tmp = f + '.' + process.pid + '.tmp';
|
|
1552
1820
|
writeFileSync(tmp, JSON.stringify(doc, null, 1), 'utf8');
|
|
1553
1821
|
renameSync(tmp, f);
|
|
1554
|
-
|
|
1822
|
+
// 数据目录镜像(含设定集/assetIds 绑定):失败不影响主存储;子画布镜像到项目文件夹下 canvases/,不写 project.json
|
|
1823
|
+
if (!isCanvas) mirrorProjectToDataRoot(id, doc);
|
|
1824
|
+
else mirrorCanvasDocToDataRoot(canvasRef.projectId, canvasRef.canvasId, doc);
|
|
1825
|
+
}
|
|
1826
|
+
/** 读取画布文档(多画布):默认画布 = 项目文档;子画布 = 独立文档 + 合并项目级字段(名称/设定集/画布清单)。 */
|
|
1827
|
+
function readCanvasDoc(projectId, canvasId) {
|
|
1828
|
+
const pid = String(projectId || '');
|
|
1829
|
+
const cid = normalizeCanvasId(canvasId);
|
|
1830
|
+
const projectDoc = readDocOrNull(pid);
|
|
1831
|
+
if (!projectDoc) throw new Error('项目不存在:' + pid + '(先用 canvas_list_projects 查看可用项目 id)');
|
|
1832
|
+
if (!cid) {
|
|
1833
|
+
if (!defaultCanvasExists(projectDoc)) throw new Error('默认画布已删除:请指定 canvasId(先用 canvas_list_projects 查看可用画布)');
|
|
1834
|
+
return normalizeDocNodes({
|
|
1835
|
+
...projectDoc, canvases: canvasRegistry(projectDoc),
|
|
1836
|
+
defaultCanvasName: defaultCanvasNameOf(projectDoc), defaultCanvasDeleted: false,
|
|
1837
|
+
});
|
|
1838
|
+
}
|
|
1839
|
+
const rec = canvasRegistry(projectDoc).find((c) => c.id === cid);
|
|
1840
|
+
if (!rec) throw new Error('画布不存在:' + cid + '(项目 ' + pid + ';先用 canvas_list_projects 查看可用画布)');
|
|
1841
|
+
const raw = readDocOrNull(canvasDocId(pid, cid)) || {};
|
|
1842
|
+
const doc = { ...raw, id: pid, projectId: pid, canvasId: cid, name: projectDoc.name || pid };
|
|
1843
|
+
doc.nodes = raw.nodes || {};
|
|
1844
|
+
doc.edges = raw.edges || [];
|
|
1845
|
+
doc.view = raw.view || { x: 60, y: 40, scale: 1 };
|
|
1846
|
+
if (projectDoc.bible !== undefined) { doc.bible = projectDoc.bible; doc.bibleUpdatedAt = projectDoc.bibleUpdatedAt || 0; }
|
|
1847
|
+
doc.canvases = canvasRegistry(projectDoc);
|
|
1848
|
+
doc.defaultCanvasName = defaultCanvasNameForApi(projectDoc);
|
|
1849
|
+
doc.defaultCanvasDeleted = !defaultCanvasExists(projectDoc);
|
|
1850
|
+
return normalizeDocNodes(doc);
|
|
1851
|
+
}
|
|
1852
|
+
/** 写画布文档:默认画布写项目文档;子画布写独立文档(项目级字段剔除)。 */
|
|
1853
|
+
function writeCanvasDoc(projectId, canvasId, doc) {
|
|
1854
|
+
const pid = String(projectId || '');
|
|
1855
|
+
const cid = normalizeCanvasId(canvasId);
|
|
1856
|
+
if (!cid) {
|
|
1857
|
+
writeIcDoc(pid, { ...doc, id: pid });
|
|
1858
|
+
return;
|
|
1859
|
+
}
|
|
1860
|
+
const out = { ...doc, id: canvasDocId(pid, cid), projectId: pid, canvasId: cid, updatedAt: Date.now() };
|
|
1861
|
+
delete out.bible;
|
|
1862
|
+
delete out.bibleUpdatedAt;
|
|
1863
|
+
delete out.canvases;
|
|
1864
|
+
delete out.name;
|
|
1865
|
+
writeIcDoc(canvasDocId(pid, cid), out);
|
|
1866
|
+
}
|
|
1867
|
+
/** 更新项目级字段(名称/设定集/画布清单)——只作用于默认画布那份项目文档。 */
|
|
1868
|
+
function patchProjectDoc(projectId, patch) {
|
|
1869
|
+
const pid = String(projectId || '');
|
|
1870
|
+
const cur = readDocOrNull(pid);
|
|
1871
|
+
if (!cur) throw new Error('项目不存在:' + pid);
|
|
1872
|
+
writeIcDoc(pid, { ...cur, ...patch, id: pid });
|
|
1873
|
+
}
|
|
1874
|
+
/** 工具入口统一的画布参数解析:args.canvasId 缺省 = 项目默认画布(null);非法 id 直接报错不静默降级。 */
|
|
1875
|
+
function toolCanvasId(args) {
|
|
1876
|
+
return normalizeCanvasId(args && args.canvasId);
|
|
1877
|
+
}
|
|
1878
|
+
/** 工具读文档:默认画布读项目文档,子画布读子文档(项目级字段已合并进来)。 */
|
|
1879
|
+
function readToolDoc(args) {
|
|
1880
|
+
const pid = String((args && args.projectId) || '');
|
|
1881
|
+
return readCanvasDoc(pid, toolCanvasId(args));
|
|
1882
|
+
}
|
|
1883
|
+
/** 工具写文档:默认画布写项目文档、子画布写子文档,并标记画布级变更信号。返回 canvasId。 */
|
|
1884
|
+
function writeToolDoc(args, doc) {
|
|
1885
|
+
const pid = String((args && args.projectId) || '');
|
|
1886
|
+
const cid = toolCanvasId(args);
|
|
1887
|
+
writeCanvasDoc(pid, cid, doc);
|
|
1888
|
+
markDocChanged(pid, cid);
|
|
1889
|
+
return cid;
|
|
1890
|
+
}
|
|
1891
|
+
/** 删除项目的全部子画布文档(项目删除时清干净,避免残留行/文件被列出)。 */
|
|
1892
|
+
function deleteCanvasDocsForProject(projectId) {
|
|
1893
|
+
const pid = String(projectId || '');
|
|
1894
|
+
if (!pid) return 0;
|
|
1895
|
+
let removed = 0;
|
|
1896
|
+
if (sqlActive()) {
|
|
1897
|
+
try {
|
|
1898
|
+
const db = getDb();
|
|
1899
|
+
const ids = db.prepare('SELECT id FROM projects WHERE instr(id, ?) > 0').all(CANVAS_ID_SEP).map((r) => r.id)
|
|
1900
|
+
.filter((id) => splitCanvasDocId(id).projectId === pid);
|
|
1901
|
+
const del = db.prepare('DELETE FROM projects WHERE id = ?');
|
|
1902
|
+
for (const id of ids) { try { del.run(id); removed++; } catch { /* 单行失败不影响其他 */ } }
|
|
1903
|
+
} catch { /* 清理失败不影响项目删除 */ }
|
|
1904
|
+
return removed;
|
|
1905
|
+
}
|
|
1906
|
+
try {
|
|
1907
|
+
for (const f of readdirSync(PROJECTS_DIR)) {
|
|
1908
|
+
if (!f.endsWith('.json')) continue;
|
|
1909
|
+
const id = f.slice(0, -5);
|
|
1910
|
+
if (!isCanvasDocId(id) || splitCanvasDocId(id).projectId !== pid) continue;
|
|
1911
|
+
try { unlinkSync(path.join(PROJECTS_DIR, f)); removed++; } catch { /* 文件占用/已删 */ }
|
|
1912
|
+
}
|
|
1913
|
+
} catch { /* 无目录 */ }
|
|
1914
|
+
return removed;
|
|
1555
1915
|
}
|
|
1556
1916
|
/** 读取设定集(对话侧"设定集先行"的前提)。 */
|
|
1557
1917
|
async function canvasGetBible(args) {
|
|
@@ -1613,29 +1973,38 @@ async function canvasUpdateBible(args) {
|
|
|
1613
1973
|
markDocChanged(id);
|
|
1614
1974
|
return { projectId: id, bible: doc.bible, bibleUpdatedAt: doc.bibleUpdatedAt };
|
|
1615
1975
|
}
|
|
1616
|
-
/** 通用建节点(供对话建角色/场景/道具参考图等);支持 script/text/image
|
|
1976
|
+
/** 通用建节点(供对话建角色/场景/道具参考图等);支持 script/text/image/video/storyboard/finalcut。
|
|
1977
|
+
* image / video 是合并类型:有 assetId 即资产节点、有 prompt 即待生成的空节点;旧名
|
|
1978
|
+
* image-gen / image-asset / video-gen / video-asset 仍接受,内部一律映射为合并类型。 */
|
|
1617
1979
|
function canvasCreateNode(args) {
|
|
1618
1980
|
const id = String((args && args.projectId) || '');
|
|
1619
|
-
const
|
|
1620
|
-
const doc =
|
|
1621
|
-
const
|
|
1622
|
-
|
|
1981
|
+
const canvasId = toolCanvasId(args);
|
|
1982
|
+
const doc = readToolDoc(args);
|
|
1983
|
+
const rawType = String((args && args.type) || 'text');
|
|
1984
|
+
const type = normalizeNodeType(rawType);
|
|
1985
|
+
if (!['script', 'text', 'image', 'video', 'storyboard', 'finalcut'].includes(type)) throw new Error('不支持的节点类型: ' + rawType);
|
|
1986
|
+
const legacyType = rawType !== type ? rawType : '';
|
|
1623
1987
|
const label = args && args.name ? String(args.name).slice(0, 40) : '';
|
|
1624
1988
|
const prompt = String((args && args.prompt) || '');
|
|
1625
1989
|
const connectFrom = String((args && args.connectFrom) || '');
|
|
1626
|
-
|
|
1990
|
+
// 媒体节点:空态按「预览区比例 + 标题栏」给外框(没有操作栏就不加 NODE_FOOT_H),
|
|
1991
|
+
// 布点时用真实外框做碰撞检测,避免按 360×360 估小后与同列邻居重叠。
|
|
1992
|
+
const mediaSize = (type === 'image' || type === 'video')
|
|
1993
|
+
? mediaNodeSize((args && args.ratio) || '16:9', false)
|
|
1994
|
+
: null;
|
|
1995
|
+
const pos = nextNodePos(doc, type, connectFrom, mediaSize);
|
|
1627
1996
|
let connected = null;
|
|
1628
1997
|
let autoConnected = null;
|
|
1629
1998
|
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 =
|
|
1999
|
+
if (type === 'image') {
|
|
2000
|
+
node = agentNode('text', '', pos.x, pos.y, label || '图片节点');
|
|
2001
|
+
node.type = 'image';
|
|
2002
|
+
node.w = mediaSize.w; node.h = mediaSize.h;
|
|
2003
|
+
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 };
|
|
2004
|
+
} else if (type === 'video') {
|
|
2005
|
+
node = agentNode('text', '', pos.x, pos.y, label || '视频节点');
|
|
2006
|
+
node.type = 'video';
|
|
2007
|
+
node.w = mediaSize.w; node.h = mediaSize.h;
|
|
1639
2008
|
// 多镜头工作流(结构性强制):项目里已有**拆解过镜头**的分镜表 → 一律自动连线分镜表、prompt 忽略。
|
|
1640
2009
|
// 一个分镜表 = 一个完整视频,镜头清单只从分镜表读取;对话侧手写 prompt 绕过分镜表是高频错误,这里直接封死。
|
|
1641
2010
|
const sbs = Object.values(doc.nodes || {}).filter((n) => n.type === 'storyboard');
|
|
@@ -1667,7 +2036,7 @@ function canvasCreateNode(args) {
|
|
|
1667
2036
|
} else if (type === 'finalcut') {
|
|
1668
2037
|
node = agentNode('text', '', pos.x, pos.y, label || '成片');
|
|
1669
2038
|
node.type = 'finalcut';
|
|
1670
|
-
node.w =
|
|
2039
|
+
node.w = 360; node.h = 360;
|
|
1671
2040
|
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
2041
|
} else if (type === 'script') {
|
|
1673
2042
|
node = agentNode('script', prompt, pos.x, pos.y, label || defaultLabel(prompt));
|
|
@@ -1684,22 +2053,26 @@ function canvasCreateNode(args) {
|
|
|
1684
2053
|
const defaultToPort = (type === 'storyboard' && (srcType === 'text' || srcType === 'script')) ? 'script' : undefined;
|
|
1685
2054
|
const edge = connectEdge(doc, connectFrom, node.id, args && args.connectFromPort, (args && args.connectToPort) || defaultToPort);
|
|
1686
2055
|
connected = edge.id;
|
|
1687
|
-
} else if (autoConnected && (type === 'storyboard' || type === 'video
|
|
1688
|
-
// 未指定连线但上游已存在(分镜表→剧本 /
|
|
2056
|
+
} else if (autoConnected && (type === 'storyboard' || type === 'video')) {
|
|
2057
|
+
// 未指定连线但上游已存在(分镜表→剧本 / 视频节点→分镜表)→ 默认自动连线(工作流的默认形态,不依赖 Agent 记得)
|
|
1689
2058
|
const edge = connectEdge(doc, autoConnected.from, node.id, undefined, type === 'storyboard' ? 'script' : undefined);
|
|
1690
2059
|
connected = edge.id;
|
|
1691
2060
|
}
|
|
1692
2061
|
doc.updatedAt = Date.now();
|
|
1693
|
-
|
|
1694
|
-
markDocChanged(id);
|
|
2062
|
+
writeToolDoc(args, doc);
|
|
1695
2063
|
const note = autoConnected && autoConnected.ignoredPrompt
|
|
1696
|
-
? (type === 'video
|
|
2064
|
+
? (type === 'video'
|
|
1697
2065
|
? '⚠️ 已强制连线到分镜表(多镜头工作流:一个分镜表 = 一个完整视频,镜头清单从分镜表读取),传入的 prompt 已忽略——不要手写视频提示词绕过分镜表。'
|
|
1698
2066
|
: '已自动连线到上游剧本节点,传入的 prompt 被忽略(内容从连线取)。')
|
|
1699
2067
|
: (autoConnected && autoConnected.emptyShots
|
|
1700
2068
|
? '已连线到分镜表;该分镜表尚未拆解镜头(shots 为空)——先 canvas_run_node 分镜表生成镜头,再生成视频。'
|
|
1701
2069
|
: '');
|
|
1702
|
-
return {
|
|
2070
|
+
return {
|
|
2071
|
+
ok: true, projectId: id, ...(canvasId ? { canvasId } : {}), nodeId: node.id, type: node.type, label: (node.data && node.data.label) || '',
|
|
2072
|
+
edgeId: connected || undefined, note: note || undefined,
|
|
2073
|
+
// 旧名(image-gen / image-asset / video-gen / video-asset)已映射为合并节点:明确告诉 Agent 实际类型
|
|
2074
|
+
mergedType: legacyType ? (legacyType + ' → ' + node.type + '(合并节点:有 assetId 即资产,空节点在画布点开面板生成)') : undefined,
|
|
2075
|
+
};
|
|
1703
2076
|
}
|
|
1704
2077
|
|
|
1705
2078
|
/** 一键重排:按「行」布局——横向数据流决定列,竖向端口(prev/frame)不参与分列;
|
|
@@ -1707,9 +2080,10 @@ function canvasCreateNode(args) {
|
|
|
1707
2080
|
* 行高取行内最高节点、行间保留间距,绝不重叠。 */
|
|
1708
2081
|
function canvasRelayout(args) {
|
|
1709
2082
|
const id = String((args && args.projectId) || '');
|
|
1710
|
-
const
|
|
2083
|
+
const canvasId = toolCanvasId(args);
|
|
2084
|
+
const doc = readToolDoc(args);
|
|
1711
2085
|
const nodes = Object.values(doc.nodes || {});
|
|
1712
|
-
if (!nodes.length) return { ok: true, projectId: id, moved: 0, rows: 0 };
|
|
2086
|
+
if (!nodes.length) return { ok: true, projectId: id, ...(canvasId ? { canvasId } : {}), moved: 0, rows: 0 };
|
|
1713
2087
|
const VERT = new Set(['prev', 'frame']);
|
|
1714
2088
|
const byId = new Map(nodes.map((n) => [n.id, n]));
|
|
1715
2089
|
const depth = new Map(nodes.map((n) => [n.id, 0]));
|
|
@@ -1754,9 +2128,8 @@ function canvasRelayout(args) {
|
|
|
1754
2128
|
cy += rowH + 40;
|
|
1755
2129
|
}
|
|
1756
2130
|
doc.updatedAt = Date.now();
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
return { ok: true, projectId: id, moved, rows: rowList.length };
|
|
2131
|
+
writeToolDoc(args, doc);
|
|
2132
|
+
return { ok: true, projectId: id, ...(canvasId ? { canvasId } : {}), moved, rows: rowList.length };
|
|
1760
2133
|
}
|
|
1761
2134
|
async function canvasDeleteProject(args) {
|
|
1762
2135
|
const id = String((args && args.projectId) || '');
|
|
@@ -1769,6 +2142,7 @@ async function canvasDeleteProject(args) {
|
|
|
1769
2142
|
cleanup = sqlDeleteProjectRows(getDb(), id, dataRoot());
|
|
1770
2143
|
} else {
|
|
1771
2144
|
try { unlinkSync(projectFile(id)); } catch { /* 已删除 */ }
|
|
2145
|
+
try { deleteCanvasDocsForProject(id); } catch { /* 子画布清理失败不影响删除 */ }
|
|
1772
2146
|
try { removeDataRootProject(id); } catch { /* 数据目录镜像清理失败不影响删除 */ }
|
|
1773
2147
|
}
|
|
1774
2148
|
// 通知客户端把这个项目也从本地删掉,否则下次推送会把它复活 + 长效墓碑:拒收迟到的推送
|
|
@@ -1787,29 +2161,29 @@ async function canvasDeleteProject(args) {
|
|
|
1787
2161
|
async function canvasDeleteNode(args) {
|
|
1788
2162
|
const id = String((args && args.projectId) || '');
|
|
1789
2163
|
const nodeId = String((args && args.nodeId) || '');
|
|
1790
|
-
const
|
|
1791
|
-
const doc =
|
|
2164
|
+
const canvasId = toolCanvasId(args);
|
|
2165
|
+
const doc = readToolDoc(args);
|
|
1792
2166
|
if (!doc.nodes[nodeId]) throw new Error('节点不存在: ' + nodeId);
|
|
1793
2167
|
delete doc.nodes[nodeId];
|
|
1794
2168
|
doc.edges = (doc.edges || []).filter((e) => e.fromNode !== nodeId && e.toNode !== nodeId);
|
|
1795
2169
|
doc.tombstones = [...new Set([...(doc.tombstones || []), nodeId])].slice(-100);
|
|
1796
2170
|
doc.tombstoneAt = { ...(doc.tombstoneAt || {}), [nodeId]: Date.now() }; // 记录墓碑创建时刻:撤销恢复的推送(ts 更新)才允许清墓碑
|
|
1797
2171
|
doc.updatedAt = Date.now();
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
return { ok: true, projectId: id, nodeId };
|
|
2172
|
+
writeToolDoc(args, doc);
|
|
2173
|
+
return { ok: true, projectId: id, ...(canvasId ? { canvasId } : {}), nodeId };
|
|
1801
2174
|
}
|
|
1802
2175
|
/** 运行节点:写一次性请求,画布客户端轮询消费后用画布内配置的模型/接口真实执行。 */
|
|
1803
2176
|
async function canvasRunNode(args) {
|
|
1804
2177
|
const pid = String((args && args.projectId) || '');
|
|
1805
2178
|
const nodeId = String((args && args.nodeId) || '');
|
|
1806
|
-
const
|
|
2179
|
+
const canvasId = toolCanvasId(args);
|
|
2180
|
+
const doc = readToolDoc(args);
|
|
1807
2181
|
const node = doc.nodes[nodeId];
|
|
1808
2182
|
if (!node) throw new Error('节点不存在: ' + nodeId);
|
|
1809
2183
|
const cfg = readIcConfig();
|
|
1810
|
-
cfg.ui = { ...(cfg.ui || {}), runRequest: { projectId: pid, nodeId, at: Date.now() } };
|
|
2184
|
+
cfg.ui = { ...(cfg.ui || {}), runRequest: { projectId: pid, canvasId, nodeId, at: Date.now() } };
|
|
1811
2185
|
writeIcConfig(cfg);
|
|
1812
|
-
return { ok: true, projectId: pid, nodeId, type: node.type, label: (node.data && node.data.label) || '' };
|
|
2186
|
+
return { ok: true, projectId: pid, ...(canvasId ? { canvasId } : {}), nodeId, type: node.type, label: (node.data && node.data.label) || '' };
|
|
1813
2187
|
}
|
|
1814
2188
|
/** 读取节点运行状态与产物(画布执行完成后会把结果推回 Host 文档)。 */
|
|
1815
2189
|
/** 分镜一致性检查(与客户端 checkShotConsistency 同规则,Host 供状态查询/设定集更新后重算):
|
|
@@ -1877,7 +2251,8 @@ function readAssetIndexNames(pid) {
|
|
|
1877
2251
|
async function canvasNodeStatus(args) {
|
|
1878
2252
|
const pid = String((args && args.projectId) || '');
|
|
1879
2253
|
const nodeId = String((args && args.nodeId) || '');
|
|
1880
|
-
const
|
|
2254
|
+
const canvasId = toolCanvasId(args);
|
|
2255
|
+
const doc = readToolDoc(args);
|
|
1881
2256
|
const n = doc.nodes[nodeId];
|
|
1882
2257
|
if (!n) throw new Error('节点不存在: ' + nodeId);
|
|
1883
2258
|
// 分镜表:返回镜头清单(结构化)——对话侧据此组织视频提示词,绝不自行推导/编造镜头表
|
|
@@ -1906,7 +2281,7 @@ async function canvasNodeStatus(args) {
|
|
|
1906
2281
|
: undefined;
|
|
1907
2282
|
const d = n.data || {};
|
|
1908
2283
|
return {
|
|
1909
|
-
projectId: pid, nodeId, type: n.type, label: d.label || '',
|
|
2284
|
+
projectId: pid, ...(canvasId ? { canvasId } : {}), nodeId, type: n.type, label: d.label || '',
|
|
1910
2285
|
status: d.status || 'idle',
|
|
1911
2286
|
aiStatus: d.aiStatus || 'idle',
|
|
1912
2287
|
// 文档级时间戳:canvas_generate_node 的等待循环用它区分「本轮真的跑过」与「上一次的残留终态」
|
|
@@ -1954,7 +2329,8 @@ const NODE_PATCH_FIELDS = ['prompt', 'idea', 'output', 'text', 'script', 'requir
|
|
|
1954
2329
|
function canvasUpdateNode(args) {
|
|
1955
2330
|
const pid = String((args && args.projectId) || '');
|
|
1956
2331
|
const nodeId = String((args && args.nodeId) || '');
|
|
1957
|
-
const
|
|
2332
|
+
const canvasId = toolCanvasId(args);
|
|
2333
|
+
const doc = readToolDoc(args);
|
|
1958
2334
|
const n = doc.nodes[nodeId];
|
|
1959
2335
|
if (!n) throw new Error('节点不存在: ' + nodeId + '(先用 canvas_list_projects 查看节点 id)');
|
|
1960
2336
|
const patch = (args && args.patch) || {};
|
|
@@ -2013,24 +2389,24 @@ function canvasUpdateNode(args) {
|
|
|
2013
2389
|
throw new Error('没有可应用的字段(允许:' + NODE_PATCH_FIELDS.join(' / ') + ' / shots);收到:' + keys.join(' / '));
|
|
2014
2390
|
}
|
|
2015
2391
|
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) };
|
|
2392
|
+
writeToolDoc(args, doc);
|
|
2393
|
+
return { projectId: pid, ...(canvasId ? { canvasId } : {}), nodeId, type: n.type, label: n.data.label || '', applied, skipped, prompt: String(n.data.prompt || '').slice(0, 400) };
|
|
2019
2394
|
}
|
|
2020
2395
|
/** 触发节点执行并(可选)等待到终态:对话侧"改完提示词 → 点生成 → 拿结果"的一站式入口。
|
|
2021
2396
|
* 执行发生在画布侧(需要浏览器开着画布页面消费运行请求),等待超时不代表失败。 */
|
|
2022
2397
|
async function canvasGenerateNode(args) {
|
|
2023
2398
|
const pid = String((args && args.projectId) || '');
|
|
2024
2399
|
const nodeId = String((args && args.nodeId) || '');
|
|
2400
|
+
const canvasId = toolCanvasId(args);
|
|
2025
2401
|
const waitSec = Math.max(0, Math.min(600, Number((args && args.waitSeconds) || 0)));
|
|
2026
2402
|
// 触发前快照:canvasRunNode 只写一次性 runRequest,真正的执行要等画布下一个 3s 同步 tick
|
|
2027
2403
|
// 才开始(状态才翻成 running);而下面的首轮轮询在 2.5s 就发生——对**曾经跑过**的节点,
|
|
2028
2404
|
// 首轮会读到上一次的 done/error 并误当本次结果(页面关闭 100%、页开约 17% 触发)。
|
|
2029
2405
|
// 因此终态只有在「本轮确实跑过」时才可信:轮询中见过 running,或文档在触发后被写过
|
|
2030
2406
|
//(快进完成的短任务可能整体落在两次轮询之间,用文档时间戳兜底)。
|
|
2031
|
-
const baseline = await canvasNodeStatus({ projectId: pid, nodeId }).catch(() => null);
|
|
2407
|
+
const baseline = await canvasNodeStatus({ projectId: pid, canvasId, nodeId }).catch(() => null);
|
|
2032
2408
|
const baseUpdatedAt = Number(baseline && baseline.docUpdatedAt) || 0;
|
|
2033
|
-
const run = await canvasRunNode({ projectId: pid, nodeId });
|
|
2409
|
+
const run = await canvasRunNode({ projectId: pid, canvasId, nodeId });
|
|
2034
2410
|
if (!waitSec) {
|
|
2035
2411
|
return { ok: true, triggered: true, finished: false, projectId: pid, nodeId, type: run.type, label: run.label, status: 'running', note: '已触发执行;画布需处于打开状态才会真正执行,可用 canvas_node_status 查询进度' };
|
|
2036
2412
|
}
|
|
@@ -2039,7 +2415,7 @@ async function canvasGenerateNode(args) {
|
|
|
2039
2415
|
let sawRunning = false;
|
|
2040
2416
|
while (Date.now() < deadline) {
|
|
2041
2417
|
await new Promise((r) => setTimeout(r, 2500));
|
|
2042
|
-
try { last = await canvasNodeStatus({ projectId: pid, nodeId }); } catch { continue; }
|
|
2418
|
+
try { last = await canvasNodeStatus({ projectId: pid, canvasId, nodeId }); } catch { continue; }
|
|
2043
2419
|
const st = String(last.status || '');
|
|
2044
2420
|
const ai = String(last.aiStatus || '');
|
|
2045
2421
|
if (st === 'running' || ai === 'running') { sawRunning = true; continue; }
|
|
@@ -2062,35 +2438,137 @@ async function canvasGenerateNode(args) {
|
|
|
2062
2438
|
}
|
|
2063
2439
|
async function canvasOpen(args) {
|
|
2064
2440
|
const pid = String((args && args.projectId) || '');
|
|
2441
|
+
const canvasId = toolCanvasId(args);
|
|
2065
2442
|
if (pid) {
|
|
2066
|
-
//
|
|
2067
|
-
if (
|
|
2443
|
+
// 项目(以及指定画布)必须真实存在:否则工具回"✅ 已打开",而前端因为找不到项目什么都不做(假成功)
|
|
2444
|
+
if (canvasId) readCanvasDoc(pid, canvasId);
|
|
2445
|
+
else if (!docExists(pid)) throw new Error('项目不存在:' + pid + '(先用 canvas_list_projects 查看可用项目 id)');
|
|
2068
2446
|
}
|
|
2069
2447
|
const cfg = readIcConfig();
|
|
2070
|
-
cfg.ui = { ...(cfg.ui || {}), openRequest: { projectId: pid || null, at: Date.now() } };
|
|
2448
|
+
cfg.ui = { ...(cfg.ui || {}), openRequest: { projectId: pid || null, canvasId: pid ? canvasId : null, at: Date.now() } };
|
|
2071
2449
|
writeIcConfig(cfg);
|
|
2072
|
-
return { ok: true, projectId: pid || null };
|
|
2450
|
+
return { ok: true, projectId: pid || null, ...(canvasId ? { canvasId } : {}) };
|
|
2073
2451
|
}
|
|
2074
2452
|
|
|
2075
|
-
/**
|
|
2453
|
+
/** 画布清单(多画布目录树的数据源):默认画布不登记在 canvases 里(客户端用 canvasId=null 表达),
|
|
2454
|
+
* 返回 projectId + 默认画布名/是否已删除 + canvases(子画布)。 */
|
|
2455
|
+
async function canvasesList(args) {
|
|
2456
|
+
const pid = String((args && args.projectId) || '');
|
|
2457
|
+
const doc = readDoc(pid);
|
|
2458
|
+
return {
|
|
2459
|
+
projectId: pid, name: doc.name || pid,
|
|
2460
|
+
canvases: canvasRegistry(doc),
|
|
2461
|
+
defaultCanvasName: defaultCanvasNameForApi(doc),
|
|
2462
|
+
defaultCanvasDeleted: !defaultCanvasExists(doc),
|
|
2463
|
+
};
|
|
2464
|
+
}
|
|
2465
|
+
/** 新建子画布:必须命名;画布 id 由 Host 生成(cv 前缀,避免与项目 id 混淆)。
|
|
2466
|
+
* 新画布首次打开是空白画布(没有文档行,readCanvasDoc 会兜底空图)。 */
|
|
2467
|
+
async function canvasCreate(args) {
|
|
2468
|
+
const pid = String((args && args.projectId) || '');
|
|
2469
|
+
const name = String((args && args.name) || '').trim();
|
|
2470
|
+
if (!name) throw new Error('需要画布名称(新建画布必须命名)');
|
|
2471
|
+
const projectDoc = readDoc(pid);
|
|
2472
|
+
const list = canvasRegistry(projectDoc);
|
|
2473
|
+
const defaultCanvasName = defaultCanvasNameForApi(projectDoc);
|
|
2474
|
+
// 同名复用:重复点击/重试不产生一堆同名画布
|
|
2475
|
+
const dup = list.find((c) => String(c.name).trim() === name);
|
|
2476
|
+
if (dup) return { ok: true, projectId: pid, canvasId: dup.id, name: dup.name, reused: true, defaultCanvasName };
|
|
2477
|
+
if (allCanvasNames(projectDoc).some((n) => n.trim() === name)) throw new Error('同名画布已存在:' + name);
|
|
2478
|
+
if (list.length >= 200) throw new Error('一个项目最多 200 个画布');
|
|
2479
|
+
// 客户端可自带 id(本地已登记、目录树已显示的路径):合法且未占用就采用,避免两端 id 不一致
|
|
2480
|
+
const wantId = normalizeCanvasId(args && args.canvasId);
|
|
2481
|
+
const cid = (wantId && !list.some((c) => c.id === wantId)) ? wantId : ('cv' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6));
|
|
2482
|
+
const next = sanitizeCanvases([...list, { id: cid, name: name.slice(0, 80), createdAt: Date.now() }]);
|
|
2483
|
+
patchProjectDoc(pid, { canvases: next });
|
|
2484
|
+
return { ok: true, projectId: pid, canvasId: cid, name: name.slice(0, 80), reused: false, defaultCanvasName };
|
|
2485
|
+
}
|
|
2486
|
+
/** 重命名画布:canvasId=null 表示默认画布(写项目文档 defaultCanvasName);必须命名、同名(除自己)拒绝。 */
|
|
2487
|
+
async function canvasRename(args) {
|
|
2488
|
+
const pid = String((args && args.projectId) || '');
|
|
2489
|
+
const cid = normalizeCanvasId(args && args.canvasId);
|
|
2490
|
+
const name = String((args && args.name) || '').trim();
|
|
2491
|
+
if (!name) throw new Error('需要画布名称');
|
|
2492
|
+
const projectDoc = readDoc(pid);
|
|
2493
|
+
const list = canvasRegistry(projectDoc);
|
|
2494
|
+
if (!cid) {
|
|
2495
|
+
if (!defaultCanvasExists(projectDoc)) throw new Error('默认画布已删除,无法重命名');
|
|
2496
|
+
if (list.some((c) => String(c.name).trim() === name)) throw new Error('同名画布已存在:' + name);
|
|
2497
|
+
patchProjectDoc(pid, { defaultCanvasName: name.slice(0, 80) });
|
|
2498
|
+
return { ok: true, projectId: pid, canvasId: null, name: name.slice(0, 80), defaultCanvasName: name.slice(0, 80) };
|
|
2499
|
+
}
|
|
2500
|
+
const target = list.find((c) => c.id === cid);
|
|
2501
|
+
if (!target) throw new Error('画布不存在:' + cid);
|
|
2502
|
+
const defNameApi = defaultCanvasNameForApi(projectDoc);
|
|
2503
|
+
if (defNameApi && String(defNameApi).trim() === name) throw new Error('同名画布已存在:' + name);
|
|
2504
|
+
if (list.some((c) => c.id !== cid && String(c.name).trim() === name)) throw new Error('同名画布已存在:' + name);
|
|
2505
|
+
const next = list.map((c) => (c.id === cid ? { ...c, name: name.slice(0, 80) } : c));
|
|
2506
|
+
patchProjectDoc(pid, { canvases: sanitizeCanvases(next) });
|
|
2507
|
+
return { ok: true, projectId: pid, canvasId: cid, name: name.slice(0, 80), defaultCanvasName: defaultCanvasNameForApi(projectDoc) };
|
|
2508
|
+
}
|
|
2509
|
+
/** 删除画布。canvasId=null 删除默认画布(清图 + defaultCanvasName + 标记 defaultCanvasDeleted,项目保留);
|
|
2510
|
+
* 子画布沿用原逻辑。画布总数 = 1 时拒绝删除(客户端已隐藏按钮,Host 兜底)。 */
|
|
2511
|
+
async function canvasDelete(args) {
|
|
2512
|
+
const pid = String((args && args.projectId) || '');
|
|
2513
|
+
const cid = normalizeCanvasId(args && args.canvasId);
|
|
2514
|
+
const projectDoc = readDoc(pid);
|
|
2515
|
+
const list = canvasRegistry(projectDoc);
|
|
2516
|
+
const total = canvasTotal(projectDoc);
|
|
2517
|
+
if (total <= 1) throw new Error('至少要保留一个画布');
|
|
2518
|
+
if (!cid) {
|
|
2519
|
+
if (!defaultCanvasExists(projectDoc)) throw new Error('默认画布已删除');
|
|
2520
|
+
const desiredName = defaultCanvasNameOf(projectDoc);
|
|
2521
|
+
const nextDoc = {
|
|
2522
|
+
...projectDoc,
|
|
2523
|
+
nodes: {}, edges: [],
|
|
2524
|
+
view: { x: 60, y: 40, scale: 1 },
|
|
2525
|
+
tombstones: [], edgeTombstones: [], tombstoneAt: {}, edgeTombstoneAt: {},
|
|
2526
|
+
defaultCanvasDeleted: true,
|
|
2527
|
+
defaultCanvasName: '',
|
|
2528
|
+
updatedAt: Date.now(),
|
|
2529
|
+
};
|
|
2530
|
+
writeIcDoc(pid, nextDoc);
|
|
2531
|
+
const nextCanvasId = list[0] ? list[0].id : null;
|
|
2532
|
+
return { ok: true, projectId: pid, canvasId: null, deletedName: desiredName, defaultCanvasName: '', defaultCanvasDeleted: true, nextCanvasId };
|
|
2533
|
+
}
|
|
2534
|
+
const targetId = canvasDocId(pid, cid);
|
|
2535
|
+
const inList = list.some((c) => c.id === cid);
|
|
2536
|
+
const hasDoc = Boolean(readDocOrNull(targetId));
|
|
2537
|
+
// 幂等:客户端先删本地登记再发删除请求时,Host 可能只剩文档或只剩登记
|
|
2538
|
+
if (!inList && !hasDoc) throw new Error('画布不存在:' + cid);
|
|
2539
|
+
if (inList) patchProjectDoc(pid, { canvases: sanitizeCanvases(list.filter((c) => c.id !== cid)) });
|
|
2540
|
+
try {
|
|
2541
|
+
if (sqlActive()) getDb().prepare('DELETE FROM projects WHERE id = ?').run(targetId);
|
|
2542
|
+
else unlinkSync(projectFile(targetId));
|
|
2543
|
+
} catch { /* 文档不存在视为已删除 */ }
|
|
2544
|
+
return {
|
|
2545
|
+
ok: true, projectId: pid, canvasId: cid,
|
|
2546
|
+
defaultCanvasName: defaultCanvasNameForApi(projectDoc),
|
|
2547
|
+
defaultCanvasDeleted: !defaultCanvasExists(projectDoc),
|
|
2548
|
+
};
|
|
2549
|
+
}
|
|
2550
|
+
|
|
2551
|
+
/** 与客户端一致的端口表(连线校验用)。image / video 是合并类型(旧生成/资产节点归一到这里):
|
|
2552
|
+
* image 输入 prompt+ref、输出 out;video 输入 prompt+frame+ref、输出 out/tailpick/last。 */
|
|
2076
2553
|
const PORT_TABLE = {
|
|
2077
2554
|
'text': { inputs: [], outputs: [{ id: 'out', kind: 'text' }] },
|
|
2078
2555
|
'shot': { inputs: [{ id: 'char', kind: 'image' }, { id: 'scene', kind: 'image' }, { id: 'prop', kind: 'image' }], outputs: [{ id: 'visual', kind: 'text' }, { id: 'motion', kind: 'text' }] },
|
|
2079
2556
|
'script': { inputs: [], outputs: [{ id: 'out', kind: 'text' }] },
|
|
2080
2557
|
'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' }] },
|
|
2558
|
+
'image': { inputs: [{ id: 'prompt', kind: 'text' }, { id: 'ref', kind: 'image' }], outputs: [{ id: 'out', kind: 'image' }] },
|
|
2559
|
+
'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
2560
|
'finalcut': { inputs: [{ id: 'in', kind: 'video' }], outputs: [] },
|
|
2086
2561
|
'group': { inputs: [], outputs: [] },
|
|
2087
2562
|
};
|
|
2088
2563
|
const MULTI_INPUT_PORTS = new Set(['ref', 'char', 'scene', 'prop']);
|
|
2089
|
-
/** Agent 改动了项目文档:记一次性信号,让画布在几秒内拉回合并(否则画布上看不到 Agent 的改动)。
|
|
2090
|
-
|
|
2564
|
+
/** Agent 改动了项目文档:记一次性信号,让画布在几秒内拉回合并(否则画布上看不到 Agent 的改动)。
|
|
2565
|
+
* canvasId 记录是哪个画布被改(缺省/默认画布 = null),客户端只刷新当前画布。 */
|
|
2566
|
+
function markDocChanged(projectId, canvasId) {
|
|
2091
2567
|
try {
|
|
2092
2568
|
const cfg = readIcConfig();
|
|
2093
|
-
|
|
2569
|
+
let cid = null;
|
|
2570
|
+
try { cid = normalizeCanvasId(canvasId); } catch { cid = null; }
|
|
2571
|
+
cfg.ui = { ...(cfg.ui || {}), docChanged: { projectId, canvasId: cid, at: Date.now() } };
|
|
2094
2572
|
writeIcConfig(cfg);
|
|
2095
2573
|
} catch { /* 配置写失败不影响主流程 */ }
|
|
2096
2574
|
}
|
|
@@ -2103,14 +2581,42 @@ function connectEdge(doc, fromId, toId, fromPort, toPort) {
|
|
|
2103
2581
|
if (fromId === toId) throw new Error('不能把节点连到自己');
|
|
2104
2582
|
const outs = (PORT_TABLE[from.type] && PORT_TABLE[from.type].outputs) || [];
|
|
2105
2583
|
const ins = (PORT_TABLE[to.type] && PORT_TABLE[to.type].inputs) || [];
|
|
2106
|
-
|
|
2584
|
+
let fp = String(fromPort || '').trim();
|
|
2107
2585
|
// 目标端口缺省时按语义选择,而不是死板取第一个输入口:
|
|
2108
2586
|
// 剧本/文本 → 分镜表 = 剧本来源(script);分镜表 → 分镜表 = 承接上一镜(prev);其余取第一个输入口
|
|
2109
2587
|
let tp = String(toPort || '').trim();
|
|
2110
2588
|
if (!tp) {
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2589
|
+
// 目标端口缺省时按语义选择(与客户端「端口单点、系统自动选口」同一套规则):
|
|
2590
|
+
// 文本 → 分镜表 script;分镜表 → 分镜表 prev;文本 → 图片/视频 prompt;
|
|
2591
|
+
// 图片 → 图片/视频 ref;视频 → 视频 frame 或成片 in。
|
|
2592
|
+
if (to.type === 'storyboard') {
|
|
2593
|
+
tp = from.type === 'storyboard' ? 'prev' : ((from.type === 'text' || from.type === 'script') ? 'script' : String((ins[0] && ins[0].id) || ''));
|
|
2594
|
+
} else if (to.type === 'image') {
|
|
2595
|
+
tp = (from.type === 'image' || from.type === 'image-asset' || from.type === 'image-gen') ? 'ref' : 'prompt';
|
|
2596
|
+
} else if (to.type === 'video') {
|
|
2597
|
+
if (from.type === 'video' || from.type === 'video-asset' || from.type === 'video-gen') tp = 'frame';
|
|
2598
|
+
else if (from.type === 'image' || from.type === 'image-asset' || from.type === 'image-gen') tp = 'ref';
|
|
2599
|
+
else tp = 'prompt';
|
|
2600
|
+
} else if (to.type === 'finalcut') {
|
|
2601
|
+
tp = 'in';
|
|
2602
|
+
} else {
|
|
2603
|
+
tp = String((ins[0] && ins[0].id) || '');
|
|
2604
|
+
}
|
|
2605
|
+
}
|
|
2606
|
+
if (!fp) {
|
|
2607
|
+
const want = ins.find((p) => p.id === tp);
|
|
2608
|
+
const same = want ? outs.filter((o) => o.kind === want.kind) : [];
|
|
2609
|
+
if (tp === 'prev') {
|
|
2610
|
+
const tail = same.find((o) => o.id === 'tail');
|
|
2611
|
+
fp = String((tail && tail.id) || (same[0] && same[0].id) || (outs[0] && outs[0].id) || '');
|
|
2612
|
+
} else if (want && want.kind === 'image') {
|
|
2613
|
+
// 视频 → 视频的 frame:优先尾帧最佳选帧,退到真·最后帧
|
|
2614
|
+
const pick = same.find((o) => o.id === 'tailpick');
|
|
2615
|
+
const last = same.find((o) => o.id === 'last');
|
|
2616
|
+
fp = String((pick && from.data && from.data.lastPickAssetId ? 'tailpick' : '') || (last && last.id) || (same[0] && same[0].id) || (outs[0] && outs[0].id) || '');
|
|
2617
|
+
} else {
|
|
2618
|
+
fp = String((same[0] && same[0].id) || (outs[0] && outs[0].id) || '');
|
|
2619
|
+
}
|
|
2114
2620
|
}
|
|
2115
2621
|
const outDef = outs.find((p) => p.id === fp);
|
|
2116
2622
|
const inDef = ins.find((p) => p.id === tp);
|
|
@@ -2130,7 +2636,8 @@ function connectEdge(doc, fromId, toId, fromPort, toPort) {
|
|
|
2130
2636
|
/** 在两个节点间连线(画布工作流的关键:剧本→分镜→图→视频靠连线组织,不要复制文本)。 */
|
|
2131
2637
|
function canvasConnect(args) {
|
|
2132
2638
|
const id = String((args && args.projectId) || '');
|
|
2133
|
-
const
|
|
2639
|
+
const canvasId = toolCanvasId(args);
|
|
2640
|
+
const doc = readToolDoc(args);
|
|
2134
2641
|
const fromId = String((args && args.fromNode) || '');
|
|
2135
2642
|
const toId = String((args && args.toNode) || '');
|
|
2136
2643
|
const already = (doc.edges || []).some((e) => e.fromNode === fromId && e.toNode === toId);
|
|
@@ -2140,10 +2647,9 @@ function canvasConnect(args) {
|
|
|
2140
2647
|
const fromLabel = String((from.data && from.data.label) || fromId);
|
|
2141
2648
|
const toLabel = String((to.data && to.data.label) || toId);
|
|
2142
2649
|
doc.updatedAt = Date.now();
|
|
2143
|
-
|
|
2144
|
-
markDocChanged(id);
|
|
2650
|
+
writeToolDoc(args, doc);
|
|
2145
2651
|
return {
|
|
2146
|
-
ok: true, projectId: id, edgeId: edge.id, already,
|
|
2652
|
+
ok: true, projectId: id, ...(canvasId ? { canvasId } : {}), edgeId: edge.id, already,
|
|
2147
2653
|
text: '✅ 已连线:「' + fromLabel + '」(' + edge.fromPort + ') → 「' + toLabel + '」(' + edge.toPort + ')。' + (already ? '(此前已连过,本次未重复建线)' : ''),
|
|
2148
2654
|
};
|
|
2149
2655
|
}
|
|
@@ -2269,7 +2775,7 @@ async function updateInstall() {
|
|
|
2269
2775
|
export const inject = ['webServer', 'tools'];
|
|
2270
2776
|
|
|
2271
2777
|
// 仅供诊断 / 自动化测试(不属于 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 };
|
|
2778
|
+
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
2779
|
|
|
2274
2780
|
function resolveModel(providers, want) {
|
|
2275
2781
|
const byProvider = want && want.provider
|
|
@@ -2419,6 +2925,17 @@ export function apply(ctx) {
|
|
|
2419
2925
|
);
|
|
2420
2926
|
}
|
|
2421
2927
|
|
|
2928
|
+
/** 懒加载导演台引擎(纯 ESM 函数/常量,无 DOM 依赖),供导演台工具读取姿态/动作清单。 */
|
|
2929
|
+
let _directorEngine = null;
|
|
2930
|
+
const loadDirectorEngine = async () => {
|
|
2931
|
+
if (_directorEngine) return _directorEngine;
|
|
2932
|
+
try {
|
|
2933
|
+
const url = new URL('./assets/director/engine.js', import.meta.url).href;
|
|
2934
|
+
_directorEngine = await import(url);
|
|
2935
|
+
} catch { _directorEngine = null; }
|
|
2936
|
+
return _directorEngine;
|
|
2937
|
+
};
|
|
2938
|
+
|
|
2422
2939
|
const methods = {
|
|
2423
2940
|
async ping() { return { ok: true, version: VERSION }; },
|
|
2424
2941
|
async llmOptions() { return listLlmOptions(); },
|
|
@@ -2455,6 +2972,30 @@ export function apply(ctx) {
|
|
|
2455
2972
|
* 客户端知道自己要删哪个项目,Host 按项目物理文件夹定位(与 projectDirGet 同一基准),
|
|
2456
2973
|
* 只删 projects/<folder>/craft 这一个子目录——绝不碰项目镜像、其他项目、数据根其他内容。
|
|
2457
2974
|
* 幂等:目录不存在也是成功;失败可见(removed=false + reason),不静默吞。 */
|
|
2975
|
+
/** 导演台场景规格:AI / 脚本写入,客户端打开导演台时自动套用(存 <projectDir>/craft/director-stage.json)。 */
|
|
2976
|
+
async directorStageSave(args) {
|
|
2977
|
+
const dir = await methods.projectDirGet({ projectId: args && args.projectId });
|
|
2978
|
+
if (!dir || !dir.ok) return { ok: false, error: (dir && dir.error) || '项目目录不可用' };
|
|
2979
|
+
const craftDir = path.join(dir.abs, 'craft');
|
|
2980
|
+
try { mkdirSync(craftDir, { recursive: true }); } catch { /* 交给写入报错 */ }
|
|
2981
|
+
const file = path.join(craftDir, 'director-stage.json');
|
|
2982
|
+
const spec = (args && args.spec) || null;
|
|
2983
|
+
if (!spec || typeof spec !== 'object') return { ok: false, error: '缺少 spec 对象' };
|
|
2984
|
+
const payload = { ok: true, savedAt: Date.now(), spec };
|
|
2985
|
+
try { writeFileSync(file, JSON.stringify(payload, null, 2), 'utf8'); }
|
|
2986
|
+
catch (e) { return { ok: false, error: '写入失败: ' + String((e && e.message) || e) }; }
|
|
2987
|
+
return { ok: true, projectId: String(args.projectId), file, bytes: Buffer.byteLength(JSON.stringify(payload)) };
|
|
2988
|
+
},
|
|
2989
|
+
async directorStageLoad(args) {
|
|
2990
|
+
const dir = await methods.projectDirGet({ projectId: args && args.projectId });
|
|
2991
|
+
if (!dir || !dir.ok) return { ok: false, error: (dir && dir.error) || '项目目录不可用' };
|
|
2992
|
+
const file = path.join(dir.abs, 'craft', 'director-stage.json');
|
|
2993
|
+
if (!existsSync(file)) return { ok: true, found: false, file };
|
|
2994
|
+
try {
|
|
2995
|
+
const doc = JSON.parse(readFileSync(file, 'utf8'));
|
|
2996
|
+
return { ok: true, found: true, file, savedAt: doc.savedAt || null, spec: doc.spec || null };
|
|
2997
|
+
} catch (e) { return { ok: false, found: false, file, error: '读取失败: ' + String((e && e.message) || e) }; }
|
|
2998
|
+
},
|
|
2458
2999
|
async projectCraftRemove(args) {
|
|
2459
3000
|
const root = dataRoot();
|
|
2460
3001
|
const pid = String((args && args.projectId) || '');
|
|
@@ -2627,6 +3168,10 @@ export function apply(ctx) {
|
|
|
2627
3168
|
async canvasRunNode(args) { return canvasRunNode(args); },
|
|
2628
3169
|
async canvasNodeStatus(args) { return canvasNodeStatus(args); },
|
|
2629
3170
|
async canvasOpen(args) { return canvasOpen(args); },
|
|
3171
|
+
async canvasesList(args) { return canvasesList(args); },
|
|
3172
|
+
async canvasCreate(args) { return canvasCreate(args); },
|
|
3173
|
+
async canvasRename(args) { return canvasRename(args); },
|
|
3174
|
+
async canvasDelete(args) { return canvasDelete(args); },
|
|
2630
3175
|
async assetDelete(args) {
|
|
2631
3176
|
const id = String((args && args.id) || '');
|
|
2632
3177
|
if (!id) return { ok: true };
|
|
@@ -2717,7 +3262,7 @@ export function apply(ctx) {
|
|
|
2717
3262
|
};
|
|
2718
3263
|
reg({
|
|
2719
3264
|
name: 'canvas_list_projects',
|
|
2720
|
-
description: '
|
|
3265
|
+
description: '列出无限画布的项目、每个项目下的画布(多画布:默认画布 + 子画布)及其节点(节点 id/类型/名称/状态)。用户提到画布项目、想看有哪些项目/画布、或要指定某个项目/画布/节点时使用。子画布用 canvasId 标识(缺省 = 默认画布)。',
|
|
2721
3266
|
parameters: { type: 'object', properties: { keyword: { type: 'string', description: '按项目名称过滤(可选)' } }, additionalProperties: false },
|
|
2722
3267
|
output: {
|
|
2723
3268
|
schema: { type: 'object', properties: { count: { type: 'integer' }, text: { type: 'string' } }, required: ['count', 'text'] },
|
|
@@ -2729,8 +3274,13 @@ export function apply(ctx) {
|
|
|
2729
3274
|
const list = projectsList().filter((p) => !kw || String(p.name).toLowerCase().includes(kw));
|
|
2730
3275
|
const lines = [];
|
|
2731
3276
|
for (const p of list) {
|
|
2732
|
-
|
|
3277
|
+
const canvases = Array.isArray(p.canvases) ? p.canvases : [];
|
|
3278
|
+
const defTxt = p.defaultCanvasDeleted
|
|
3279
|
+
? '默认画布已删除'
|
|
3280
|
+
: '默认画布「' + (p.defaultCanvasName || '默认画布') + '」canvasId=null';
|
|
3281
|
+
lines.push('- 「' + p.name + '」 id=' + p.id + ' · ' + defTxt + ' · ' + canvases.length + ' 个子画布 · ' + p.nodes.length + ' 节点 · 更新于 ' + new Date(p.updatedAt).toLocaleString());
|
|
2733
3282
|
for (const n of p.nodes) lines.push(' · [' + n.type + '] ' + (n.label || '(未命名)') + ' id=' + n.id + ' status=' + n.status);
|
|
3283
|
+
for (const c of canvases) lines.push(' ▸ 画布「' + (c.name || c.id) + '」 canvasId=' + c.id + '(用 canvasId 参数指定;资产与设定集按项目共享)');
|
|
2734
3284
|
}
|
|
2735
3285
|
const text = lines.length ? ('共 ' + list.length + ' 个项目:\n' + lines.join('\n')) : '画布上还没有项目。可调用 canvas_create_project 创建。';
|
|
2736
3286
|
return { count: list.length, text };
|
|
@@ -2767,6 +3317,7 @@ export function apply(ctx) {
|
|
|
2767
3317
|
type: 'object',
|
|
2768
3318
|
properties: {
|
|
2769
3319
|
projectId: { type: 'string', description: '项目 id(来自 canvas_list_projects 或 canvas_create_project)' },
|
|
3320
|
+
canvasId: { type: 'string', description: '可选:画布 id(项目下子画布;缺省 = 项目默认画布。多画布项目必须显式指定目标画布)' },
|
|
2770
3321
|
text: { type: 'string', description: '要写入的内容(按 target 与节点类型决定写哪里)' },
|
|
2771
3322
|
kind: { type: 'string', enum: ['script', 'text'], description: '新节点类型:script=剧本(默认),text=纯文本' },
|
|
2772
3323
|
target: { type: 'string', enum: ['idea', 'output'], description: '写哪一部分:idea=提示词/创作意图(默认),output=成稿' },
|
|
@@ -2793,6 +3344,7 @@ export function apply(ctx) {
|
|
|
2793
3344
|
type: 'object',
|
|
2794
3345
|
properties: {
|
|
2795
3346
|
projectId: { type: 'string', description: '项目 id' },
|
|
3347
|
+
canvasId: { type: 'string', description: '可选:画布 id(项目下的子画布;缺省 = 项目默认画布。来自 canvas_list_projects 的画布清单)' },
|
|
2796
3348
|
nodeId: { type: 'string', description: '节点 id(来自 canvas_list_projects)' },
|
|
2797
3349
|
},
|
|
2798
3350
|
required: ['projectId', 'nodeId'],
|
|
@@ -2812,13 +3364,14 @@ export function apply(ctx) {
|
|
|
2812
3364
|
type: 'object',
|
|
2813
3365
|
properties: {
|
|
2814
3366
|
projectId: { type: 'string', description: '项目 id' },
|
|
3367
|
+
canvasId: { type: 'string', description: '可选:画布 id(项目下的子画布;缺省 = 项目默认画布)' },
|
|
2815
3368
|
nodeId: { type: 'string', description: '节点 id' },
|
|
2816
3369
|
},
|
|
2817
3370
|
required: ['projectId', 'nodeId'],
|
|
2818
3371
|
additionalProperties: false,
|
|
2819
3372
|
},
|
|
2820
3373
|
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'] },
|
|
3374
|
+
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
3375
|
render: (args, value) => [{ type: 'text', text: String(value.text || '') }],
|
|
2823
3376
|
},
|
|
2824
3377
|
timeoutMs: 30000,
|
|
@@ -2870,7 +3423,7 @@ export function apply(ctx) {
|
|
|
2870
3423
|
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
3424
|
if (s.tailPick && s.tailPickAssetId) lines.push('尾帧最佳选帧: ' + Number(s.tailPick.t || 0).toFixed(2) + 's 处(得分 ' + s.tailPick.score + ')资产 ' + s.tailPickAssetId);
|
|
2872
3425
|
return {
|
|
2873
|
-
ok: true, status: s.status, assetId: s.assetId || undefined,
|
|
3426
|
+
ok: true, type: s.type, status: s.status, assetId: s.assetId || undefined,
|
|
2874
3427
|
prompt: s.prompt || undefined, ratio: s.ratio || undefined, size: s.size || undefined,
|
|
2875
3428
|
duration: s.duration == null ? undefined : s.duration, resolution: s.resolution || undefined,
|
|
2876
3429
|
frameMode: s.frameMode || undefined, imageModel: s.imageModel || undefined, videoModel: s.videoModel || undefined,
|
|
@@ -2890,12 +3443,13 @@ export function apply(ctx) {
|
|
|
2890
3443
|
type: 'object',
|
|
2891
3444
|
properties: {
|
|
2892
3445
|
projectId: { type: 'string', description: '项目 id' },
|
|
3446
|
+
canvasId: { type: 'string', description: '可选:画布 id(项目下的子画布;缺省 = 项目默认画布)' },
|
|
2893
3447
|
nodeId: { type: 'string', description: '节点 id(来自 canvas_list_projects)' },
|
|
2894
3448
|
patch: {
|
|
2895
3449
|
type: 'object',
|
|
2896
3450
|
description: '要修改的字段(只传需要改的,其余保持不动)',
|
|
2897
3451
|
properties: {
|
|
2898
|
-
prompt: { type: 'string', description: '
|
|
3452
|
+
prompt: { type: 'string', description: '图片/视频节点的提示词(对话框内容)' },
|
|
2899
3453
|
idea: { type: 'string', description: '剧本节点的创作意图' },
|
|
2900
3454
|
output: { type: 'string', description: '剧本节点的成稿' },
|
|
2901
3455
|
text: { type: 'string', description: '文本节点的正文' },
|
|
@@ -2934,6 +3488,7 @@ export function apply(ctx) {
|
|
|
2934
3488
|
type: 'object',
|
|
2935
3489
|
properties: {
|
|
2936
3490
|
projectId: { type: 'string', description: '项目 id' },
|
|
3491
|
+
canvasId: { type: 'string', description: '可选:画布 id(项目下的子画布;缺省 = 项目默认画布)' },
|
|
2937
3492
|
nodeId: { type: 'string', description: '节点 id' },
|
|
2938
3493
|
waitSeconds: { type: 'number', description: '可选:等待秒数(0=只触发;上限 600)' },
|
|
2939
3494
|
},
|
|
@@ -2956,7 +3511,7 @@ export function apply(ctx) {
|
|
|
2956
3511
|
description: '删除无限画布项目中的一个节点(连同它的连线)。用于清理误创建的节点。',
|
|
2957
3512
|
parameters: {
|
|
2958
3513
|
type: 'object',
|
|
2959
|
-
properties: { projectId: { type: 'string', description: '项目 id' }, nodeId: { type: 'string', description: '节点 id' } },
|
|
3514
|
+
properties: { projectId: { type: 'string', description: '项目 id' }, canvasId: { type: 'string', description: '可选:画布 id(缺省 = 项目默认画布)' }, nodeId: { type: 'string', description: '节点 id' } },
|
|
2960
3515
|
required: ['projectId', 'nodeId'],
|
|
2961
3516
|
additionalProperties: false,
|
|
2962
3517
|
},
|
|
@@ -2972,7 +3527,7 @@ export function apply(ctx) {
|
|
|
2972
3527
|
description: '一键重排画布节点布局(消除重叠):按「行」排列——横向数据流决定列(文本→分镜表→视频→资产),竖向关系(衔接 prev / 首帧 frame)不参与分列;每行 = 一个横向连通组(如「场N文本 ↔ 场N分镜表」同行对齐),行高取行内最高节点、行间保留间距。节点重叠、或批量建点后布局混乱时调用。',
|
|
2973
3528
|
parameters: {
|
|
2974
3529
|
type: 'object',
|
|
2975
|
-
properties: { projectId: { type: 'string', description: '项目 id' } },
|
|
3530
|
+
properties: { projectId: { type: 'string', description: '项目 id' }, canvasId: { type: 'string', description: '可选:画布 id(缺省 = 项目默认画布)' } },
|
|
2976
3531
|
required: ['projectId'],
|
|
2977
3532
|
additionalProperties: false,
|
|
2978
3533
|
},
|
|
@@ -2983,6 +3538,76 @@ export function apply(ctx) {
|
|
|
2983
3538
|
return { ok: true, projectId: r.projectId, text: '✅ 已重排 ' + r.moved + ' 个节点(' + r.rows + ' 行,无重叠)。' };
|
|
2984
3539
|
},
|
|
2985
3540
|
});
|
|
3541
|
+
reg({
|
|
3542
|
+
name: 'canvas_director_stage',
|
|
3543
|
+
description: '3D 导演台(预演场景)的总控工具:用一段 JSON 规格直接生成/更新整场预演,无需手动搭场景。\n' +
|
|
3544
|
+
'action=apply:把 spec 写入项目并落盘(客户端打开导演台时自动套用;再点「导出视频」即得白模预演片)。\n' +
|
|
3545
|
+
'action=get:读回当前项目的规格。\n' +
|
|
3546
|
+
'action=help:返回规格 schema 与可用姿态/预设清单。\n' +
|
|
3547
|
+
'spec 结构:{ title, duration, env{preset,bg,ground,grid,exposure,tonemap}, ' +
|
|
3548
|
+
'characters[{name,pos:[x,y,z],rotY,h,pose,color,material{roughness,metallic,specular},path:[[x,z]...],pathFace,poseAnim[{t,pose}],loop,loopPeriod,keys[{t,pos,rotY}]}], ' +
|
|
3549
|
+
'props[{preset,pos,scale,color}], cameras[{name,pos,target,fov,keys[{t,pos,target}]}], shots[{camera,start,end}] }。\n' +
|
|
3550
|
+
'pose 取值见 action=help 的 poses;道具 preset 见 presets。坐标两维数组 [x,z] 自动补 y=0。',
|
|
3551
|
+
parameters: {
|
|
3552
|
+
type: 'object',
|
|
3553
|
+
properties: {
|
|
3554
|
+
action: { type: 'string', enum: ['apply', 'get', 'help', 'brief'], description: '操作类型' },
|
|
3555
|
+
brief: { type: 'string', description: '一句话描述(action=brief 时用,如「两人在巷子里打斗,8秒」);会套用打斗/追逐/对话模板生成规格并落盘' },
|
|
3556
|
+
template: { type: 'string', enum: ['fight', 'chase', 'dialogue'], description: '强制指定模板(不传则按 brief 关键词自动判断)' },
|
|
3557
|
+
duration: { type: 'number', description: '时长秒数(不传则从 brief 里抓「N秒」,否则用模板默认值)' },
|
|
3558
|
+
projectId: { type: 'string', description: '项目 id' },
|
|
3559
|
+
spec: { type: 'object', description: '场景规格(action=apply 时必填)' },
|
|
3560
|
+
},
|
|
3561
|
+
required: ['action'],
|
|
3562
|
+
additionalProperties: false,
|
|
3563
|
+
},
|
|
3564
|
+
output: textOutput,
|
|
3565
|
+
timeoutMs: 30000,
|
|
3566
|
+
async execute(args) {
|
|
3567
|
+
const action = String((args && args.action) || 'get');
|
|
3568
|
+
if (action === 'help') {
|
|
3569
|
+
const eng = await loadDirectorEngine();
|
|
3570
|
+
return {
|
|
3571
|
+
ok: true,
|
|
3572
|
+
text: '✅ 导演台规格说明\n' +
|
|
3573
|
+
'· 姿态 id:none, arms_up, punch, kick, flip, fly(轻功), crouch(下蹲), fight, walk, run, jump, hit, fall, walk_a, walk_b, run_a, run_b\n' +
|
|
3574
|
+
'· 道具 preset:box, sphere, cylinder, cone, plane, capsule, torus, pyramid, disc, arc\n' +
|
|
3575
|
+
'· 循环动作:poseAnim 首尾 pose 相同即可循环(如 walk_a→walk_b→walk_a)\n' +
|
|
3576
|
+
'· 路径:path 为二维地面点数组,物体沿路径运动并按切线自动转向(pathFace:false 关闭)\n' +
|
|
3577
|
+
'· 角色一律使用资产库的真实关节人偶(assetId=xbot):characters[].preset 可填 actors 列表里的体型,未填默认 m_standard',
|
|
3578
|
+
poses: (eng && eng.BONE_POSES ? eng.BONE_POSES.map((p) => ({ id: p.id, zh: p.zh, en: p.en })) : []),
|
|
3579
|
+
presets: ['box', 'sphere', 'cylinder', 'cone', 'plane', 'capsule', 'torus', 'pyramid', 'disc', 'arc'],
|
|
3580
|
+
actors: (eng && eng.ACTOR_PRESETS ? eng.ACTOR_PRESETS.map((p) => ({ preset: p.id, zh: p.zh, en: p.en, h: p.h })) : []),
|
|
3581
|
+
sequences: (eng && eng.ACTION_SEQUENCES ? eng.ACTION_SEQUENCES.map((s2) => ({ id: s2.id, zh: s2.zh, loop: !!s2.loop })) : []),
|
|
3582
|
+
};
|
|
3583
|
+
}
|
|
3584
|
+
if (action === 'brief') {
|
|
3585
|
+
const brief = String((args && args.brief) || '').trim();
|
|
3586
|
+
if (!brief) throw new Error('action=brief 需要 brief 一句话描述');
|
|
3587
|
+
const eng2 = await loadDirectorEngine();
|
|
3588
|
+
if (!eng2 || typeof eng2.specFromBrief !== 'function') throw new Error('引擎未就绪');
|
|
3589
|
+
const spec2 = eng2.specFromBrief(brief, { template: args.template, duration: args.duration });
|
|
3590
|
+
const r4 = await methods.directorStageSave({ projectId: args.projectId, spec: spec2 });
|
|
3591
|
+
if (!r4.ok) throw new Error(r4.error || '保存失败');
|
|
3592
|
+
return {
|
|
3593
|
+
ok: true, projectId: String(args.projectId || ''), spec: spec2,
|
|
3594
|
+
text: '✅ 已按「' + (spec2.title || brief) + '」生成预演规格并写入项目(' + (spec2.characters || []).length + ' 角色 / ' + (spec2.cameras || []).length + ' 机位 / ' + (spec2.shots || []).length + ' 分镜 / ' + spec2.duration + 's)。打开或刷新 3D 导演台即可看到;点「导出视频」出片。',
|
|
3595
|
+
};
|
|
3596
|
+
}
|
|
3597
|
+
if (action === 'get') {
|
|
3598
|
+
const r2 = await methods.directorStageLoad({ projectId: args.projectId });
|
|
3599
|
+
return { ok: !!r2.ok, projectId: String(args.projectId || ''), spec: r2.spec || undefined, text: r2.found ? '✅ 已读取导演台规格(savedAt=' + new Date(r2.savedAt || Date.now()).toISOString() + ')' : '(该项目还没有导演台规格)' };
|
|
3600
|
+
}
|
|
3601
|
+
if (!args || !args.spec || typeof args.spec !== 'object') throw new Error('action=apply 需要 spec 对象');
|
|
3602
|
+
const r3 = await methods.directorStageSave({ projectId: args.projectId, spec: args.spec });
|
|
3603
|
+
if (!r3.ok) throw new Error(r3.error || '保存失败');
|
|
3604
|
+
return {
|
|
3605
|
+
ok: true, projectId: String(args.projectId || ''), file: r3.file,
|
|
3606
|
+
text: '✅ 导演台规格已写入(' + r3.bytes + ' 字节)。打开/刷新画布的 3D 导演台会自动套用该场景;点「导出视频」即可产出白模预演片。',
|
|
3607
|
+
};
|
|
3608
|
+
},
|
|
3609
|
+
});
|
|
3610
|
+
|
|
2986
3611
|
reg({
|
|
2987
3612
|
name: 'canvas_delete_project',
|
|
2988
3613
|
description: '删除无限画布上的一个项目(含其全部节点)。用户明确要求删除项目、或清理误创建的空项目时使用;删除前请先用 canvas_list_projects 确认 id。',
|
|
@@ -3055,12 +3680,13 @@ export function apply(ctx) {
|
|
|
3055
3680
|
});
|
|
3056
3681
|
reg({
|
|
3057
3682
|
name: 'canvas_create_node',
|
|
3058
|
-
description: '在项目画布上新建指定类型的节点(image
|
|
3683
|
+
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
3684
|
parameters: {
|
|
3060
3685
|
type: 'object',
|
|
3061
3686
|
properties: {
|
|
3062
3687
|
projectId: { type: 'string', description: '项目 id' },
|
|
3063
|
-
|
|
3688
|
+
canvasId: { type: 'string', description: '可选:画布 id(项目下的子画布;缺省 = 项目默认画布)' },
|
|
3689
|
+
type: { type: 'string', enum: ['script', 'text', 'image', 'video', 'storyboard', 'finalcut', 'image-gen', 'image-asset', 'video-gen', 'video-asset'], description: '节点类型(image/video 为合并类型;旧名 image-gen 等仍接受并自动映射)' },
|
|
3064
3690
|
name: { type: 'string', description: '节点名字(建议填写,如"角色-陈拾 参考图")' },
|
|
3065
3691
|
prompt: { type: 'string', description: '提示词(图片/视频用画面提示词;剧本用创作意图;文本用正文)' },
|
|
3066
3692
|
size: { type: 'string', description: '可选:图片清晰度 1K/2K/4K' },
|
|
@@ -3080,20 +3706,30 @@ export function apply(ctx) {
|
|
|
3080
3706
|
timeoutMs: 30000,
|
|
3081
3707
|
async execute(args) {
|
|
3082
3708
|
const r = canvasCreateNode(args);
|
|
3083
|
-
|
|
3709
|
+
const kindHint = r.type === 'image'
|
|
3710
|
+
? '(图片节点:有 assetId 即资产;空节点在画布上点开生成面板写提示词后生成)'
|
|
3711
|
+
: r.type === 'video'
|
|
3712
|
+
? '(视频节点:有 assetId 即资产;空节点在画布上点开生成面板写提示词后生成)'
|
|
3713
|
+
: '';
|
|
3714
|
+
return {
|
|
3715
|
+
ok: true, projectId: r.projectId, nodeId: r.nodeId, label: r.label || undefined,
|
|
3716
|
+
text: '✅ 已新建[' + r.type + ']节点「' + (r.label || r.nodeId) + '」(id=' + r.nodeId + ')。' + kindHint
|
|
3717
|
+
+ (r.mergedType ? '\n注:旧类型 ' + r.mergedType : '') + (r.note ? '\n' + r.note : ''),
|
|
3718
|
+
};
|
|
3084
3719
|
},
|
|
3085
3720
|
});
|
|
3086
3721
|
reg({
|
|
3087
3722
|
name: 'canvas_connect',
|
|
3088
|
-
description: '在项目画布上把两个节点连成工作流连线(剧本 → 分镜表 → 图片/视频 →
|
|
3723
|
+
description: '在项目画布上把两个节点连成工作流连线(剧本 → 分镜表 → 图片/视频 → 成片)。【工作流铁律】分镜表的内容来自连线的上游剧本节点,不要把剧本全文复制进分镜表节点;图片/视频节点没有上游提示词时才在 prompt 里写内容,有上游时靠连线取。端口类型必须匹配(text→text、image→image、video→video),不匹配会报错;单线输入端口(script/prompt/frame/in)重复连线会替换旧线,ref 类端口允许多条。目标端口缺省时按语义自动选:文本→分镜表 script、分镜表→分镜表 prev、文本→图片/视频 prompt、图片→图片/视频 ref、视频→视频 frame / 成片 in。',
|
|
3089
3724
|
parameters: {
|
|
3090
3725
|
type: 'object',
|
|
3091
3726
|
properties: {
|
|
3092
3727
|
projectId: { type: 'string', description: '项目 id' },
|
|
3728
|
+
canvasId: { type: 'string', description: '可选:画布 id(项目下的子画布;缺省 = 项目默认画布)' },
|
|
3093
3729
|
fromNode: { type: 'string', description: '源节点 id(连出方)' },
|
|
3094
3730
|
fromPort: { type: 'string', description: '可选:源端口,默认该节点第一个输出端口(剧本/文本/图片/视频都是 out)' },
|
|
3095
3731
|
toNode: { type: 'string', description: '目标节点 id(连入方)' },
|
|
3096
|
-
toPort: { type: 'string', description: '
|
|
3732
|
+
toPort: { type: 'string', description: '可选:目标端口,缺省按语义自动选择(分镜表=script/prev、图片=prompt/ref、视频=prompt/ref/frame、成片=in)' },
|
|
3097
3733
|
},
|
|
3098
3734
|
required: ['projectId', 'fromNode', 'toNode'],
|
|
3099
3735
|
additionalProperties: false,
|
|
@@ -3108,7 +3744,7 @@ export function apply(ctx) {
|
|
|
3108
3744
|
reg({
|
|
3109
3745
|
name: 'canvas_open',
|
|
3110
3746
|
description: '打开无限画布页面并跳到指定项目(画布侧会在几秒内自动切换)。用户想查看/编辑画布时使用。',
|
|
3111
|
-
parameters: { type: 'object', properties: { projectId: { type: 'string', description: '要打开的项目 id(缺省只打开画布页)' } }, additionalProperties: false },
|
|
3747
|
+
parameters: { type: 'object', properties: { projectId: { type: 'string', description: '要打开的项目 id(缺省只打开画布页)' }, canvasId: { type: 'string', description: '可选:要打开的画布 id(缺省 = 项目默认画布)' } }, additionalProperties: false },
|
|
3112
3748
|
output: textOutput,
|
|
3113
3749
|
timeoutMs: 30000,
|
|
3114
3750
|
async execute(args) {
|
|
@@ -3278,7 +3914,7 @@ export function apply(ctx) {
|
|
|
3278
3914
|
path: `/${NAME}/api/file`,
|
|
3279
3915
|
async handler(req, res) {
|
|
3280
3916
|
try {
|
|
3281
|
-
if (req.method !== 'GET') { res.statusCode = 405; res.end('仅接受 GET'); return; }
|
|
3917
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') { res.statusCode = 405; res.end('仅接受 GET/HEAD'); return; }
|
|
3282
3918
|
const root = dataRoot();
|
|
3283
3919
|
if (!root) { res.statusCode = 404; res.end('未设置数据根目录'); return; }
|
|
3284
3920
|
const u = new URL(req.url, 'http://localhost');
|
|
@@ -3291,6 +3927,13 @@ export function apply(ctx) {
|
|
|
3291
3927
|
res.setHeader('content-type', mimeOf(abs));
|
|
3292
3928
|
res.setHeader('accept-ranges', 'bytes');
|
|
3293
3929
|
res.setHeader('cache-control', 'no-cache');
|
|
3930
|
+
// HEAD:只回应元信息(node fetch HEAD 探针用),不读文件、不占解码/内存
|
|
3931
|
+
if (req.method === 'HEAD') {
|
|
3932
|
+
res.statusCode = 200;
|
|
3933
|
+
res.setHeader('content-length', String(st.size));
|
|
3934
|
+
res.end();
|
|
3935
|
+
return;
|
|
3936
|
+
}
|
|
3294
3937
|
const m = /^bytes=(\d*)-(\d*)$/.exec(String(req.headers.range || ''));
|
|
3295
3938
|
if (m && (m[1] !== '' || m[2] !== '')) {
|
|
3296
3939
|
let start;
|
|
@@ -3317,6 +3960,45 @@ export function apply(ctx) {
|
|
|
3317
3960
|
}
|
|
3318
3961
|
},
|
|
3319
3962
|
}),
|
|
3963
|
+
// 视频静态封面(poster)落盘:body 为 jpeg/png 字节,写入 `<rel>.poster.jpg`。
|
|
3964
|
+
// 有 poster 后画布视频节点默认只画一张 img(浏览器磁盘缓存、零视频解码),播放时才挂 <video>。
|
|
3965
|
+
ctx.webServer.register({
|
|
3966
|
+
kind: 'exact',
|
|
3967
|
+
path: `/${NAME}/api/poster`,
|
|
3968
|
+
async handler(req, res) {
|
|
3969
|
+
res.setHeader('content-type', 'application/json; charset=utf-8');
|
|
3970
|
+
try {
|
|
3971
|
+
if (req.method !== 'POST' && req.method !== 'PUT') {
|
|
3972
|
+
res.statusCode = 405;
|
|
3973
|
+
res.end(JSON.stringify({ error: '仅接受 POST/PUT' }));
|
|
3974
|
+
return;
|
|
3975
|
+
}
|
|
3976
|
+
const root = dataRoot();
|
|
3977
|
+
if (!root) throw new Error('未设置数据根目录');
|
|
3978
|
+
const u = new URL(req.url, 'http://localhost');
|
|
3979
|
+
const rawRel = String(u.searchParams.get('rel') || '').trim();
|
|
3980
|
+
if (!rawRel) throw new Error('缺少 rel');
|
|
3981
|
+
if (/\.poster\.jpg$/i.test(rawRel)) throw new Error('rel 不应包含 poster 后缀');
|
|
3982
|
+
const rel = physicalRel(rawRel) || rawRel; // 逻辑路径(pid)→ 物理路径(项目名文件夹)
|
|
3983
|
+
const { abs, clean } = safeJoin(root, rel + '.poster.jpg'); // safeJoin 会拒绝 ../ 与绝对路径
|
|
3984
|
+
mkdirSync(path.dirname(abs), { recursive: true });
|
|
3985
|
+
const chunks = [];
|
|
3986
|
+
let size = 0;
|
|
3987
|
+
const MAX_POSTER = 8 * 1024 * 1024; // 封面只应是缩略 JPEG,8MB 足够且防无界写盘
|
|
3988
|
+
for await (const c of req) {
|
|
3989
|
+
size += c.length;
|
|
3990
|
+
if (size > MAX_POSTER) { res.statusCode = 413; res.end(JSON.stringify({ error: 'poster 过大(上限 8MB)' })); return; }
|
|
3991
|
+
chunks.push(c);
|
|
3992
|
+
}
|
|
3993
|
+
if (!size) throw new Error('poster 内容为空');
|
|
3994
|
+
await fs.writeFile(abs, Buffer.concat(chunks));
|
|
3995
|
+
res.end(JSON.stringify({ ok: true, rel: clean, size }));
|
|
3996
|
+
} catch (e) {
|
|
3997
|
+
res.statusCode = 400;
|
|
3998
|
+
res.end(JSON.stringify({ error: String((e && e.message) || e) }));
|
|
3999
|
+
}
|
|
4000
|
+
},
|
|
4001
|
+
}),
|
|
3320
4002
|
// CAS 对象库读路径(0.18):0.18.0–0.18.4 上传的媒体仍在 objects/ 里,必须继续可读。
|
|
3321
4003
|
// IC-08 起写路径已退役(POST/PUT 一律 405),只有 GET。
|
|
3322
4004
|
ctx.webServer.register({
|