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>
This commit is contained in:
1 parent
03bfda09b2
commit
6e497ffc45
4 files changed
+183
-8
No files matched your search
@@ -0,0 +1,90 @@
|
||||
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;
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user