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:
ferdi2goandClaude Opus 5.5 committed 2026-09-29 10:54:31 +00:00
1 parent 03bfda09b2
commit 6e497ffc45
4 files changed
+183 -8

No files matched your search

+27 -6
View File
@@ -10,7 +10,7 @@ import {
} from './auth.js';
import { HttpError, badRequest } from './http-error.js';
export function createApp({ config, secret, store }) {
export function createApp({ config, secret, store, queue }) {
const app = express();
app.disable('x-powered-by');
app.set('trust proxy', true);
@@ -57,20 +57,22 @@ export function createApp({ config, secret, store }) {
});
const route = (handler) => (req, res, next) => Promise.resolve(handler(req, res)).catch(next);
const present = (project) => ({ ...project, render: queue.describe(project) });
app.get('/api/projects', route(async (req, res) => {
res.json(await store.list());
}));
app.post('/api/projects', route(async (req, res) => {
res.status(201).json(await store.create(req.body ?? {}));
res.status(201).json(present(await store.create(req.body ?? {})));
}));
app.get('/api/projects/:id', route(async (req, res) => {
res.json(await store.get(req.params.id));
res.json(present(await store.get(req.params.id)));
}));
app.delete('/api/projects/:id', route(async (req, res) => {
if (queue.isRunning(req.params.id)) queue.cancel(req.params.id);
await store.remove(req.params.id);
res.status(204).end();
}));
@@ -82,7 +84,7 @@ export function createApp({ config, secret, store }) {
};
app.patch('/api/projects/:id/settings', route(async (req, res) => {
res.json(await store.updateSettings(req.params.id, req.body));
res.json(present(await store.updateSettings(req.params.id, req.body)));
}));
app.put('/api/projects/:id/clips/:index', route(async (req, res) => {
@@ -96,7 +98,7 @@ export function createApp({ config, secret, store }) {
}));
app.post('/api/projects/:id/assets/:slot', route(async (req, res) => {
res.status(201).json(await store.createAsset(req.params.id, req.params.slot, req.body));
res.status(201).json(present(await store.createAsset(req.params.id, req.params.slot, req.body)));
}));
app.put('/api/projects/:id/assets/:slot', route(async (req, res) => {
@@ -105,7 +107,7 @@ export function createApp({ config, secret, store }) {
}));
app.delete('/api/projects/:id/assets/:slot', route(async (req, res) => {
res.json(await store.removeAsset(req.params.id, req.params.slot));
res.json(present(await store.removeAsset(req.params.id, req.params.slot)));
}));
app.get('/api/projects/:id/assets/:slot/file', route(async (req, res) => {
@@ -113,6 +115,25 @@ export function createApp({ config, secret, store }) {
res.sendFile(store.readyPath(project, { type: 'asset', slot: req.params.slot }));
}));
app.post('/api/projects/:id/render', route(async (req, res) => {
await queue.start(req.params.id);
res.status(202).json(present(await store.get(req.params.id)));
}));
app.delete('/api/projects/:id/render', route(async (req, res) => {
queue.cancel(req.params.id);
res.status(202).json(present(await store.get(req.params.id)));
}));
// ?download=1 speichert die Datei, sonst Wiedergabe im Browser
app.get('/api/projects/:id/output', route(async (req, res) => {
const project = await store.get(req.params.id);
const file = await queue.outputPath(project);
if (req.query.download === undefined) return res.sendFile(file);
const { from, to } = project.range;
res.download(file, from && to ? `FuerDieCuts_${from}_bis_${to}.mp4` : 'FuerDieCuts.mp4');
}));
app.use('/api', (req, res) => res.status(404).json({ error: 'Nicht gefunden.' }));
// eslint-disable-next-line no-unused-vars -- Express erkennt Fehlerbehandler an vier Parametern
+3 -1
View File
@@ -7,6 +7,7 @@ import { loadSessionSecret } from './auth.js';
import { loadConfig } from './config.js';
import { probe } from './media.js';
import { createProjectStore } from './projects.js';
import { createRenderQueue } from './renderQueue.js';
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
@@ -25,7 +26,8 @@ if (!config.pin) {
}
const store = createProjectStore({ dataDir: config.dataDir, probe });
const app = createApp({ config, secret: loadSessionSecret(config), store });
const queue = createRenderQueue({ store });
const app = createApp({ config, secret: loadSessionSecret(config), store, queue });
const server = http.createServer(app);
if (config.isProduction) {
+90
View File
@@ -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;
},
};
}