304 lines
12 KiB
JavaScript
304 lines
12 KiB
JavaScript
|
|
// 创意画布本地项目库:画布结构与生成媒体分开保存,避免大体积 data URL 污染存档。
|
||
|
|
const { app } = require("electron");
|
||
|
|
const fs = require("fs");
|
||
|
|
const path = require("path");
|
||
|
|
const crypto = require("crypto");
|
||
|
|
|
||
|
|
const STORE_VERSION = 1;
|
||
|
|
const ID_RE = /^[a-zA-Z0-9_-]{8,80}$/;
|
||
|
|
const MEDIA_RE = /^[a-zA-Z0-9_.-]{8,180}$/;
|
||
|
|
|
||
|
|
function rootDir() {
|
||
|
|
return path.join(app.getPath("userData"), "canvas-workspaces");
|
||
|
|
}
|
||
|
|
|
||
|
|
function indexPath() {
|
||
|
|
return path.join(rootDir(), "index.json");
|
||
|
|
}
|
||
|
|
|
||
|
|
function workspaceDir(id) {
|
||
|
|
assertId(id);
|
||
|
|
return path.join(rootDir(), id);
|
||
|
|
}
|
||
|
|
|
||
|
|
function workspacePath(id) {
|
||
|
|
return path.join(workspaceDir(id), "canvas.json");
|
||
|
|
}
|
||
|
|
|
||
|
|
function assertId(id) {
|
||
|
|
if (!ID_RE.test(String(id || ""))) throw new Error("非法画布标识");
|
||
|
|
}
|
||
|
|
|
||
|
|
function atomicWrite(file, content) {
|
||
|
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||
|
|
const tmp = file + "." + process.pid + ".tmp";
|
||
|
|
fs.writeFileSync(tmp, content);
|
||
|
|
try {
|
||
|
|
fs.renameSync(tmp, file);
|
||
|
|
} catch (err) {
|
||
|
|
// Windows 不一定允许 rename 覆盖现有文件,明确兜底后再完成替换。
|
||
|
|
if (process.platform !== "win32" || !["EEXIST", "EPERM", "EACCES"].includes(err && err.code)) {
|
||
|
|
try { fs.rmSync(tmp, { force: true }); } catch {}
|
||
|
|
throw err;
|
||
|
|
}
|
||
|
|
fs.rmSync(file, { force: true });
|
||
|
|
fs.renameSync(tmp, file);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
async function atomicWriteAsync(file, content) {
|
||
|
|
await fs.promises.mkdir(path.dirname(file), { recursive: true });
|
||
|
|
const tmp = file + "." + process.pid + "." + Date.now() + ".tmp";
|
||
|
|
await fs.promises.writeFile(tmp, content);
|
||
|
|
try {
|
||
|
|
await fs.promises.rename(tmp, file);
|
||
|
|
} catch (err) {
|
||
|
|
if (process.platform !== "win32" || !["EEXIST", "EPERM", "EACCES"].includes(err && err.code)) {
|
||
|
|
try { await fs.promises.rm(tmp, { force: true }); } catch {}
|
||
|
|
throw err;
|
||
|
|
}
|
||
|
|
await fs.promises.rm(file, { force: true });
|
||
|
|
await fs.promises.rename(tmp, file);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function safeJson(file, fallback) {
|
||
|
|
try {
|
||
|
|
return JSON.parse(fs.readFileSync(file, "utf8"));
|
||
|
|
} catch {
|
||
|
|
return fallback;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function normalizeTitle(value) {
|
||
|
|
const title = String(value || "未命名画布").replace(/[\r\n]+/g, " ").trim();
|
||
|
|
return (title || "未命名画布").slice(0, 60);
|
||
|
|
}
|
||
|
|
|
||
|
|
function readIndex() {
|
||
|
|
const data = safeJson(indexPath(), { version: STORE_VERSION, workspaces: [] });
|
||
|
|
const workspaces = Array.isArray(data.workspaces) ? data.workspaces : [];
|
||
|
|
return { version: STORE_VERSION, workspaces };
|
||
|
|
}
|
||
|
|
|
||
|
|
function writeIndex(index) {
|
||
|
|
atomicWrite(indexPath(), JSON.stringify(index, null, 2) + "\n");
|
||
|
|
}
|
||
|
|
|
||
|
|
function summary(workspace) {
|
||
|
|
const out = workspace && workspace.outputs && typeof workspace.outputs === "object" ? workspace.outputs : {};
|
||
|
|
const assetIds = new Set();
|
||
|
|
for (const value of Object.values(out)) {
|
||
|
|
if (value && value.assetId) assetIds.add(value.assetId);
|
||
|
|
for (const item of value && Array.isArray(value.history) ? value.history : []) {
|
||
|
|
if (item && item.assetId) assetIds.add(item.assetId);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
const materials = workspace && workspace.materials && typeof workspace.materials === "object" ? workspace.materials : {};
|
||
|
|
for (const value of Object.values(materials)) {
|
||
|
|
if (!value || typeof value !== "object") continue;
|
||
|
|
for (const slot of ["image", "first", "last"]) {
|
||
|
|
if (value[slot] && value[slot].assetId) assetIds.add(value[slot].assetId);
|
||
|
|
}
|
||
|
|
for (const item of Array.isArray(value.refs) ? value.refs : []) {
|
||
|
|
if (item && item.assetId) assetIds.add(item.assetId);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
const nodes = workspace && workspace.canvas && workspace.canvas.drawflow && workspace.canvas.drawflow.drawflow;
|
||
|
|
const data = nodes && nodes.Home && nodes.Home.data;
|
||
|
|
return { nodeCount: data ? Object.keys(data).length : 0, mediaCount: assetIds.size };
|
||
|
|
}
|
||
|
|
|
||
|
|
function updateIndex(workspace) {
|
||
|
|
const index = readIndex();
|
||
|
|
const meta = {
|
||
|
|
id: workspace.id,
|
||
|
|
name: normalizeTitle(workspace.name),
|
||
|
|
createdAt: workspace.createdAt,
|
||
|
|
updatedAt: workspace.updatedAt,
|
||
|
|
...summary(workspace),
|
||
|
|
};
|
||
|
|
const existing = index.workspaces.findIndex((item) => item.id === meta.id);
|
||
|
|
if (existing >= 0) index.workspaces[existing] = meta;
|
||
|
|
else index.workspaces.push(meta);
|
||
|
|
index.workspaces.sort((a, b) => String(b.updatedAt || "").localeCompare(String(a.updatedAt || "")));
|
||
|
|
writeIndex(index);
|
||
|
|
}
|
||
|
|
|
||
|
|
function list() {
|
||
|
|
return readIndex().workspaces.sort((a, b) => String(b.updatedAt || "").localeCompare(String(a.updatedAt || "")));
|
||
|
|
}
|
||
|
|
|
||
|
|
function create(name) {
|
||
|
|
const now = new Date().toISOString();
|
||
|
|
const id = "cv_" + crypto.randomUUID().replace(/-/g, "");
|
||
|
|
const workspace = {
|
||
|
|
version: STORE_VERSION,
|
||
|
|
id,
|
||
|
|
name: normalizeTitle(name),
|
||
|
|
createdAt: now,
|
||
|
|
updatedAt: now,
|
||
|
|
canvas: { drawflow: { drawflow: { Home: { data: {} } } }, module: "Home", zoom: 1, canvasX: 0, canvasY: 0, addCount: 0 },
|
||
|
|
nodeState: {},
|
||
|
|
outputs: {},
|
||
|
|
materials: {},
|
||
|
|
};
|
||
|
|
atomicWrite(workspacePath(id), JSON.stringify(workspace, null, 2) + "\n");
|
||
|
|
updateIndex(workspace);
|
||
|
|
return workspace;
|
||
|
|
}
|
||
|
|
|
||
|
|
function load(id) {
|
||
|
|
assertId(id);
|
||
|
|
const workspace = safeJson(workspacePath(id), null);
|
||
|
|
if (!workspace || workspace.id !== id) throw new Error("画布记录不存在或已损坏");
|
||
|
|
return workspace;
|
||
|
|
}
|
||
|
|
|
||
|
|
function rename(id, name) {
|
||
|
|
assertId(id);
|
||
|
|
const title = String(name || "").replace(/[\r\n]+/g, " " ).trim();
|
||
|
|
if (!title) throw new Error("画布名称不能为空");
|
||
|
|
const workspace = load(id);
|
||
|
|
workspace.name = normalizeTitle(title);
|
||
|
|
workspace.updatedAt = new Date().toISOString();
|
||
|
|
atomicWrite(workspacePath(id), JSON.stringify(workspace, null, 2) + "\n");
|
||
|
|
updateIndex(workspace);
|
||
|
|
return { id, name: workspace.name, updatedAt: workspace.updatedAt, ...summary(workspace) };
|
||
|
|
}
|
||
|
|
|
||
|
|
function save(payload) {
|
||
|
|
if (!payload || typeof payload !== "object") throw new Error("画布保存数据无效");
|
||
|
|
assertId(payload.id);
|
||
|
|
const previous = load(payload.id);
|
||
|
|
const now = new Date().toISOString();
|
||
|
|
const workspace = {
|
||
|
|
version: STORE_VERSION,
|
||
|
|
id: payload.id,
|
||
|
|
name: normalizeTitle(payload.name || previous.name),
|
||
|
|
createdAt: previous.createdAt || now,
|
||
|
|
updatedAt: now,
|
||
|
|
canvas: payload.canvas && typeof payload.canvas === "object" ? payload.canvas : previous.canvas,
|
||
|
|
nodeState: payload.nodeState && typeof payload.nodeState === "object" ? payload.nodeState : {},
|
||
|
|
outputs: payload.outputs && typeof payload.outputs === "object" ? payload.outputs : {},
|
||
|
|
materials: payload.materials && typeof payload.materials === "object" ? payload.materials : {},
|
||
|
|
};
|
||
|
|
atomicWrite(workspacePath(workspace.id), JSON.stringify(workspace, null, 2) + "\n");
|
||
|
|
updateIndex(workspace);
|
||
|
|
return { id: workspace.id, name: workspace.name, updatedAt: workspace.updatedAt, ...summary(workspace) };
|
||
|
|
}
|
||
|
|
|
||
|
|
function dataUrlParts(dataUrl) {
|
||
|
|
const match = /^data:([^;,]+)?(;base64)?,(.*)$/is.exec(String(dataUrl || ""));
|
||
|
|
if (!match) throw new Error("媒体缓存失败:非法 data URL");
|
||
|
|
const mime = match[1] || "application/octet-stream";
|
||
|
|
const data = Buffer.from(match[3], match[2] ? "base64" : "utf8");
|
||
|
|
if (!data.length) throw new Error("媒体缓存失败:内容为空");
|
||
|
|
return { mime, data };
|
||
|
|
}
|
||
|
|
|
||
|
|
function extension(mime, kind) {
|
||
|
|
if (/png/i.test(mime)) return "png";
|
||
|
|
if (/jpe?g/i.test(mime)) return "jpg";
|
||
|
|
if (/webp/i.test(mime)) return "webp";
|
||
|
|
if (/gif/i.test(mime)) return "gif";
|
||
|
|
if (/mp4/i.test(mime) || kind === "video" || kind === "grokVideo") return "mp4";
|
||
|
|
if (/wav/i.test(mime)) return "wav";
|
||
|
|
if (/ogg|opus/i.test(mime)) return "opus";
|
||
|
|
if (/mpeg|mp3/i.test(mime) || kind === "tts") return "mp3";
|
||
|
|
return "bin";
|
||
|
|
}
|
||
|
|
|
||
|
|
function mergeOutputs(snapshotOutputs, storedOutputs) {
|
||
|
|
return {
|
||
|
|
...(snapshotOutputs && typeof snapshotOutputs === "object" ? snapshotOutputs : {}),
|
||
|
|
...(storedOutputs && typeof storedOutputs === "object" ? storedOutputs : {}),
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
function mergeMaterials(snapshotMaterials, storedMaterials) {
|
||
|
|
const snapshot = snapshotMaterials && typeof snapshotMaterials === "object" ? snapshotMaterials : {};
|
||
|
|
const stored = storedMaterials && typeof storedMaterials === "object" ? storedMaterials : {};
|
||
|
|
const merged = { ...snapshot };
|
||
|
|
for (const [nodeId, storedRefs] of Object.entries(stored)) {
|
||
|
|
const snapshotRefs = snapshot[nodeId] && typeof snapshot[nodeId] === "object" ? snapshot[nodeId] : {};
|
||
|
|
const diskRefs = storedRefs && typeof storedRefs === "object" ? storedRefs : {};
|
||
|
|
const refs = [];
|
||
|
|
const seen = new Set();
|
||
|
|
for (const item of [...(Array.isArray(snapshotRefs.refs) ? snapshotRefs.refs : []), ...(Array.isArray(diskRefs.refs) ? diskRefs.refs : [])]) {
|
||
|
|
if (!item || !item.assetId || seen.has(item.assetId)) continue;
|
||
|
|
seen.add(item.assetId);
|
||
|
|
refs.push(item);
|
||
|
|
}
|
||
|
|
merged[nodeId] = { ...snapshotRefs, ...diskRefs, refs };
|
||
|
|
}
|
||
|
|
return merged;
|
||
|
|
}
|
||
|
|
|
||
|
|
async function cacheMedia({ workspaceId, nodeId, kind, dataUrl, role, slot, sourceUrl, meta, workspaceState }) {
|
||
|
|
assertId(workspaceId);
|
||
|
|
if (!/^\d+$/.test(String(nodeId || ""))) throw new Error("非法节点标识");
|
||
|
|
const { mime, data } = dataUrlParts(dataUrl);
|
||
|
|
const hash = crypto.createHash("sha256").update(data).digest("hex").slice(0, 16);
|
||
|
|
const assetId = `node-${nodeId}-${Date.now()}-${hash}.${extension(mime, kind)}`;
|
||
|
|
const file = path.join(workspaceDir(workspaceId), "media", assetId);
|
||
|
|
await atomicWriteAsync(file, data);
|
||
|
|
// 文件写入期间可能触发画布自动保存。落盘后重新读取,始终从最新存档合并媒体索引。
|
||
|
|
const previous = load(workspaceId);
|
||
|
|
const workspace = workspaceState && workspaceState.id === workspaceId
|
||
|
|
? {
|
||
|
|
...previous,
|
||
|
|
name: normalizeTitle(workspaceState.name || previous.name),
|
||
|
|
canvas: workspaceState.canvas || previous.canvas,
|
||
|
|
nodeState: workspaceState.nodeState || previous.nodeState,
|
||
|
|
outputs: mergeOutputs(workspaceState.outputs, previous.outputs),
|
||
|
|
materials: mergeMaterials(workspaceState.materials, previous.materials),
|
||
|
|
}
|
||
|
|
: previous;
|
||
|
|
const ref = { assetId };
|
||
|
|
if (role === "output") {
|
||
|
|
workspace.outputs = workspace.outputs && typeof workspace.outputs === "object" ? workspace.outputs : {};
|
||
|
|
const previousOutput = workspace.outputs[nodeId];
|
||
|
|
const history = previousOutput && Array.isArray(previousOutput.history) ? previousOutput.history.slice() : [];
|
||
|
|
if (previousOutput && previousOutput.assetId && previousOutput.assetId !== assetId) {
|
||
|
|
history.push({
|
||
|
|
kind: previousOutput.kind,
|
||
|
|
assetId: previousOutput.assetId,
|
||
|
|
sourceUrl: previousOutput.sourceUrl || "",
|
||
|
|
meta: previousOutput.meta || {},
|
||
|
|
cachedAt: previousOutput.cachedAt || workspace.updatedAt || workspace.createdAt,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
workspace.outputs[nodeId] = {
|
||
|
|
kind, assetId, sourceUrl: String(sourceUrl || ""),
|
||
|
|
meta: meta && typeof meta === "object" ? meta : {},
|
||
|
|
cachedAt: new Date().toISOString(),
|
||
|
|
history,
|
||
|
|
};
|
||
|
|
} else if (role === "material") {
|
||
|
|
workspace.materials = workspace.materials && typeof workspace.materials === "object" ? workspace.materials : {};
|
||
|
|
const refs = (workspace.materials[nodeId] = workspace.materials[nodeId] || { refs: [] });
|
||
|
|
if (slot === "refs") refs.refs = (Array.isArray(refs.refs) ? refs.refs : []).concat(ref);
|
||
|
|
else if (["image", "first", "last"].includes(slot)) refs[slot] = ref;
|
||
|
|
}
|
||
|
|
if (role === "output" || role === "material") {
|
||
|
|
workspace.updatedAt = new Date().toISOString();
|
||
|
|
atomicWrite(workspacePath(workspaceId), JSON.stringify(workspace, null, 2) + "\n");
|
||
|
|
updateIndex(workspace);
|
||
|
|
}
|
||
|
|
return { assetId, mime, bytes: data.length };
|
||
|
|
}
|
||
|
|
|
||
|
|
async function readMedia({ workspaceId, assetId }) {
|
||
|
|
assertId(workspaceId);
|
||
|
|
if (!MEDIA_RE.test(String(assetId || ""))) throw new Error("非法媒体标识");
|
||
|
|
const file = path.join(workspaceDir(workspaceId), "media", assetId);
|
||
|
|
try { await fs.promises.access(file, fs.constants.R_OK); }
|
||
|
|
catch { throw new Error("本地缓存媒体不存在"); }
|
||
|
|
const ext = path.extname(assetId).slice(1).toLowerCase();
|
||
|
|
const mime = { png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", webp: "image/webp", gif: "image/gif", mp4: "video/mp4", wav: "audio/wav", mp3: "audio/mpeg", opus: "audio/opus" }[ext] || "application/octet-stream";
|
||
|
|
return { dataUrl: `data:${mime};base64,${(await fs.promises.readFile(file)).toString("base64")}`, mime };
|
||
|
|
}
|
||
|
|
|
||
|
|
module.exports = { list, create, load, rename, save, cacheMedia, readMedia };
|