// Project store. Manifests live EITHER as JSON files under public/projects//project.json (default) // OR in a Postgres `projects` table when DATABASE_URL is set (see db.mjs). Media files (assets/audio) // always live on disk under public/projects// for now (object storage is a later step), so its // folder is still created in both modes. All manifest ops are async (Postgres is async-only). // // public/projects//assets/ ← imported B-roll / face-cam clips (this project only) // public/projects//audio/ ← base voiceover/source audio (import projects) import fs from "node:fs"; import path from "node:path"; import { execFileSync } from "node:child_process"; import { fileURLToPath } from "node:url"; import { pgEnabled, pgRead, pgWrite, pgExists, pgList, pgRemove } from "./db.mjs"; import { storageEnabled, deletePrefix, copyPrefix } from "./storage.mjs"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const ROOT = path.resolve(__dirname, ".."); export const PUBLIC = path.join(ROOT, "public"); const PROJECTS = path.join(PUBLIC, "projects"); fs.mkdirSync(PROJECTS, { recursive: true }); const ID_RE = /^p_[a-z0-9]+$/i; export const validId = (id) => ID_RE.test(String(id || "")); const dir = (id) => path.join(PROJECTS, id); const manifestPath = (id) => path.join(dir(id), "project.json"); const rid = (p) => p + Date.now().toString(36) + Math.random().toString(36).slice(2, 6); export const newId = () => rid("p_"); // the asset/audio folders live on disk in BOTH modes (media isn't in Postgres) function ensureDirs(id) { fs.mkdirSync(path.join(dir(id), "assets"), { recursive: true }); fs.mkdirSync(path.join(dir(id), "audio"), { recursive: true }); } const PG = () => pgEnabled(); // chosen per-call so .env load order never matters // serialize manifest writes per project id so read-modify-write ops can't interleave within the // process — fixes the autosave-vs-asset-append clobber and the ownerless-claim race. (Single-process // only; horizontal scaling later needs DB-level compare-and-set.) const _tail = new Map(); function withLock(id, fn) { const run = (_tail.get(id) || Promise.resolve()).then(fn, fn); const guarded = run.catch(() => {}); _tail.set(id, guarded); guarded.then(() => { if (_tail.get(id) === guarded) _tail.delete(id); }); return run; } // announce the active backend once, and warn if on-disk projects would be stranded by PG mode try { const onDisk = fs.existsSync(PROJECTS) ? fs.readdirSync(PROJECTS).filter((d) => validId(d) && fs.existsSync(manifestPath(d))).length : 0; console.log(`[projects] storage backend: ${PG() ? "postgres (DATABASE_URL set)" : "files (public/projects)"}`); if (PG() && onDisk > 0) console.warn(`[projects] WARNING: ${onDisk} project(s) exist as files on disk but Postgres mode is active — they will NOT appear until migrated into the DB.`); } catch { /* ignore */ } // --- backend-agnostic manifest primitives (async) --- export async function exists(id) { if (!validId(id)) return false; return PG() ? pgExists(id) : fs.existsSync(manifestPath(id)); } export async function read(id) { if (!validId(id)) return null; if (PG()) return pgRead(id); if (!fs.existsSync(manifestPath(id))) return null; try { return JSON.parse(fs.readFileSync(manifestPath(id), "utf8")); } catch { return null; } } export async function write(m) { ensureDirs(m.id); m.updatedAt = new Date().toISOString(); if (PG()) return pgWrite(m); fs.writeFileSync(manifestPath(m.id), JSON.stringify(m, null, 2)); return m; } const DEFAULTS = { name: "Untitled project", source: "script", audioSrc: "", words: [], motion: [], videos: [], tracks: [], assets: [], sections: [], designStyle: "obsidian-orbit", captionStyle: "tiktok-pop", captionsOn: true, lang: "en", }; // only persist known editor fields with the RIGHT type (ignore client junk / playhead / history, // and reject e.g. motion:"hacked" that would crash the loader). ownerId is server-controlled (not here). const FIELDS = Object.keys(DEFAULTS); const typeOk = (k, v) => Array.isArray(DEFAULTS[k]) ? Array.isArray(v) : typeof v === typeof DEFAULTS[k]; const pick = (o) => { const out = {}; for (const k of FIELDS) if (k in o && typeOk(k, o[k])) out[k] = o[k]; return out; }; // ownership: a project belongs to a user (manifest.ownerId). Legacy projects with no ownerId are // claimable by anyone — the first save stamps the owner — so existing local projects keep working. export async function owns(id, userId) { const m = await read(id); return !!m && (m.ownerId == null || m.ownerId === userId); } export async function create(input = {}, ownerId = null) { const id = input.id && validId(input.id) ? input.id : newId(); return withLock(id, async () => { // never overwrite an existing project via create() — a client-supplied id for a project that // already exists would otherwise clobber/hijack another tenant's manifest (the demo-seed path // guards with exists() before calling create, so it never reaches here). Returns null → 409. if (await exists(id)) return null; ensureDirs(id); const now = new Date().toISOString(); const m = { id, ...DEFAULTS, ...pick(input), ownerId, createdAt: now, updatedAt: now }; return write(m); }); } export async function save(id, patch, userId = null) { return withLock(id, async () => { const m = await read(id); if (!m) return null; if (m.ownerId != null && userId != null && m.ownerId !== userId) return null; // not the owner Object.assign(m, pick(patch || {})); if (m.ownerId == null && userId != null) m.ownerId = userId; // claim a legacy project on first save return write(m); }); } const summarize = (m) => ({ id: m.id, name: m.name, source: m.source, createdAt: m.createdAt, updatedAt: m.updatedAt, words: (m.words || []).length, assets: (m.assets || []).length }); export async function list(userId = null) { let manifests; if (PG()) { manifests = await pgList(userId); } else { manifests = !fs.existsSync(PROJECTS) ? [] : fs.readdirSync(PROJECTS) .filter((d) => validId(d) && fs.existsSync(manifestPath(d))) // ignore stray/non-project dirs (parity with PG) .map((d) => { try { return JSON.parse(fs.readFileSync(manifestPath(d), "utf8")); } catch { return null; } }) .filter((m) => m && (userId == null || m.ownerId == null || m.ownerId === userId)); } return manifests.map(summarize).sort((a, b) => String(b.updatedAt || "").localeCompare(String(a.updatedAt || ""))); } export async function remove(id, userId = null) { return withLock(id, async () => { if (!(await exists(id))) return false; if (userId != null && !(await owns(id, userId))) return false; // only the owner can delete if (PG()) await pgRemove(id); if (storageEnabled()) await deletePrefix(`projects/${id}/`); // delete this project's objects from the bucket fs.rmSync(dir(id), { recursive: true, force: true }); // also clears any on-disk asset/audio files return true; }); } // duplicate a project into a brand-new one owned by `userId`: copies the manifest AND all media bytes // (assets/ + audio/, on disk and/or in the bucket), and remaps every project-scoped path so the copy // is fully self-contained — editing/deleting the clone never touches the original's files. export async function clone(id, userId = null) { return withLock(id, async () => { const src = await read(id); if (!src) return null; if (src.ownerId != null && userId != null && src.ownerId !== userId) return null; // only the owner can clone const nid = newId(); ensureDirs(nid); // copy media bytes: object storage (bucket) and/or local disk (whichever holds them) if (storageEnabled()) await copyPrefix(`projects/${id}/`, `projects/${nid}/`); for (const sub of ["assets", "audio"]) { const from = path.join(dir(id), sub); if (fs.existsSync(from)) fs.cpSync(from, path.join(dir(nid), sub), { recursive: true }); } // remap every "projects//…" path (asset/audio/clip srcs) to the new id, then stamp a fresh identity const m = JSON.parse(JSON.stringify(src).split(`projects/${id}/`).join(`projects/${nid}/`)); m.id = nid; m.name = `${src.name || "Untitled"} (copy)`; m.ownerId = userId ?? src.ownerId ?? null; const now = new Date().toISOString(); m.createdAt = now; m.updatedAt = now; return write(m); // persists to disk/PG (write also stamps updatedAt) }); } // --- media-file helpers (always on disk; unchanged by the storage backend) --- export const probe = (file) => { try { const out = execFileSync("ffprobe", ["-v", "error", "-select_streams", "v:0", "-show_entries", "stream=width,height:format=duration", "-of", "json", file]).toString(); const j = JSON.parse(out); return { width: j.streams?.[0]?.width || 1280, height: j.streams?.[0]?.height || 720, durationMs: Math.round((+j.format?.duration || 5) * 1000) }; } catch { return { width: 1280, height: 720, durationMs: 5000 }; } }; // allocate a destination path for an uploaded file inside a project's assets/ or audio/ dir export function target(id, name, kind = "assets") { ensureDirs(id); const ext = (path.extname(name || "") || ".mp4").toLowerCase().replace(/[^.\w]/g, ""); const fid = rid(kind === "audio" ? "a_" : "v_") + ext; const rel = `projects/${id}/${kind}/${fid}`; return { file: path.join(PUBLIC, rel), src: rel }; } export const assetId = () => rid("as_");