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>
91 lines
3.2 KiB
JavaScript
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;
|
|
},
|
|
};
|
|
}
|