Files
ferdi2goandClaude Opus 5.5 6e497ffc45 Export im Hintergrund mit Fortschritt, Abbruch und Download
Exporte laufen nacheinander. Der Fortschritt steht im Speicher; ein nach
einem Neustart verwaister Export wird als unterbrochen gemeldet. Nach
Änderungen an Einstellungen oder Dateien gilt ein fertiges Video als
veraltet. Der Download trägt den Zeitraum im Dateinamen.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 10:54:31 +00:00

91 lines
3.2 KiB
JavaScript

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;
},
};
}