// 创意画布本地项目库:画布结构与生成媒体分开保存,避免大体积 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 };