Spaces:
Running
Running
| // Project store. Manifests live EITHER as JSON files under public/projects/<id>/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/<id>/ 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/<id>/assets/ ← imported B-roll / face-cam clips (this project only) | |
| // public/projects/<id>/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/<old>/…" 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_"); | |