import fsp from 'node:fs/promises'; import path from 'node:path'; import { badRequest, conflict, notFound } from './http-error.js'; import { planRender, renderPlan } from './render.js'; // Exporte laufen nacheinander: ffmpeg lastet alle Kerne aus. export function createRenderQueue({ store, render = renderPlan }) { const jobs = new Map(); let chain = Promise.resolve(); const now = () => new Date().toISOString(); const setRender = (id, patch) => store.update(id, (project) => { project.render = { ...project.render, ...patch }; }); async function run(id) { const job = jobs.get(id); try { if (job.controller.signal.aborted) throw new Error('Abgebrochen.'); const project = await store.read(id); const plan = planRender(project); await setRender(id, { status: 'running', startedAt: now(), planKey: plan.key }); const dir = store.projectDir(id); await render({ plan, resolve: (target) => store.readyPath(project, target), workDir: path.join(dir, 'render'), output: path.join(dir, 'output.mp4'), signal: job.controller.signal, onProgress: (progress) => { job.progress = progress; }, }); await setRender(id, { status: 'done', finishedAt: now(), duration: plan.duration, error: null }); } catch (error) { const cancelled = job.controller.signal.aborted; await setRender(id, { status: cancelled ? 'cancelled' : 'error', finishedAt: now(), error: cancelled ? null : error.message, }).catch(() => {}); } finally { jobs.delete(id); } } return { async start(id) { if (jobs.has(id)) throw conflict('Das Video wird bereits erstellt.'); const plan = planRender(await store.read(id)); if (plan.clips.length === 0) throw badRequest('Keine fertig hochgeladenen Clips ausgewählt.'); jobs.set(id, { progress: 0, controller: new AbortController() }); await setRender(id, { status: 'queued', queuedAt: now(), error: null }); chain = chain.then(() => run(id)); }, cancel(id) { const job = jobs.get(id); if (!job) throw notFound('Es läuft kein Export.'); job.controller.abort(); }, isRunning: (id) => jobs.has(id), // Gespeicherter Stand plus Fortschritt aus dem Speicher. Steht ein Export noch // auf „läuft“, ohne dass er hier bekannt ist, wurde der Server neu gestartet. describe(project) { const saved = project.render ?? { status: 'idle' }; const job = jobs.get(project.id); if (job) return { ...saved, progress: job.progress }; if (saved.status === 'queued' || saved.status === 'running') { return { ...saved, status: 'error', error: 'Unterbrochen, weil der Server neu gestartet wurde.' }; } if (saved.status === 'done') { return { ...saved, outdated: planRender(project).key !== saved.planKey }; } return saved; }, async outputPath(project) { if (project.render?.status !== 'done') throw notFound('Noch kein fertiges Video.'); const file = path.join(store.projectDir(project.id), 'output.mp4'); await fsp.access(file).catch(() => { throw notFound('Noch kein fertiges Video.'); }); return file; }, }; }