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
+27
-6
@@ -10,7 +10,7 @@ import {
|
|||||||
} from './auth.js';
|
} from './auth.js';
|
||||||
import { HttpError, badRequest } from './http-error.js';
|
import { HttpError, badRequest } from './http-error.js';
|
||||||
|
|
||||||
export function createApp({ config, secret, store }) {
|
export function createApp({ config, secret, store, queue }) {
|
||||||
const app = express();
|
const app = express();
|
||||||
app.disable('x-powered-by');
|
app.disable('x-powered-by');
|
||||||
app.set('trust proxy', true);
|
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 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) => {
|
app.get('/api/projects', route(async (req, res) => {
|
||||||
res.json(await store.list());
|
res.json(await store.list());
|
||||||
}));
|
}));
|
||||||
|
|
||||||
app.post('/api/projects', route(async (req, res) => {
|
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) => {
|
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) => {
|
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);
|
await store.remove(req.params.id);
|
||||||
res.status(204).end();
|
res.status(204).end();
|
||||||
}));
|
}));
|
||||||
@@ -82,7 +84,7 @@ export function createApp({ config, secret, store }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
app.patch('/api/projects/:id/settings', route(async (req, res) => {
|
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) => {
|
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) => {
|
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) => {
|
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) => {
|
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) => {
|
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 }));
|
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.' }));
|
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
|
// eslint-disable-next-line no-unused-vars -- Express erkennt Fehlerbehandler an vier Parametern
|
||||||
|
|||||||
+3
-1
@@ -7,6 +7,7 @@ import { loadSessionSecret } from './auth.js';
|
|||||||
import { loadConfig } from './config.js';
|
import { loadConfig } from './config.js';
|
||||||
import { probe } from './media.js';
|
import { probe } from './media.js';
|
||||||
import { createProjectStore } from './projects.js';
|
import { createProjectStore } from './projects.js';
|
||||||
|
import { createRenderQueue } from './renderQueue.js';
|
||||||
|
|
||||||
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
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 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);
|
const server = http.createServer(app);
|
||||||
|
|
||||||
if (config.isProduction) {
|
if (config.isProduction) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
+63
-1
@@ -5,11 +5,24 @@ import path from 'node:path';
|
|||||||
import { after, before, test } from 'node:test';
|
import { after, before, test } from 'node:test';
|
||||||
import { createApp } from '../server/app.js';
|
import { createApp } from '../server/app.js';
|
||||||
import { createProjectStore } from '../server/projects.js';
|
import { createProjectStore } from '../server/projects.js';
|
||||||
|
import { createRenderQueue } from '../server/renderQueue.js';
|
||||||
|
|
||||||
let server;
|
let server;
|
||||||
let baseUrl;
|
let baseUrl;
|
||||||
let cookie;
|
let cookie;
|
||||||
let dataDir;
|
let dataDir;
|
||||||
|
let releaseRender;
|
||||||
|
|
||||||
|
// Steuerbare Attrappe: meldet halben Fortschritt und wartet auf releaseRender().
|
||||||
|
async function fakeRender({ output, onProgress, signal }) {
|
||||||
|
onProgress(0.5);
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
releaseRender = resolve;
|
||||||
|
signal.addEventListener('abort', () => reject(new Error('Abgebrochen.')));
|
||||||
|
});
|
||||||
|
await fs.writeFile(output, 'VIDEO');
|
||||||
|
onProgress(1);
|
||||||
|
}
|
||||||
|
|
||||||
const fakeMedia = { duration: 42, hasVideo: true, width: 1080, height: 1920, rotation: 0, fps: 30, hasAudio: true };
|
const fakeMedia = { duration: 42, hasVideo: true, width: 1080, height: 1920, rotation: 0, fps: 30, hasAudio: true };
|
||||||
|
|
||||||
@@ -21,7 +34,8 @@ before(async () => {
|
|||||||
return fakeMedia;
|
return fakeMedia;
|
||||||
};
|
};
|
||||||
const store = createProjectStore({ dataDir, probe });
|
const store = createProjectStore({ dataDir, probe });
|
||||||
const app = createApp({ config: { pin: '4711' }, secret: 'geheim', store });
|
const queue = createRenderQueue({ store, render: fakeRender });
|
||||||
|
const app = createApp({ config: { pin: '4711' }, secret: 'geheim', store, queue });
|
||||||
server = app.listen(0);
|
server = app.listen(0);
|
||||||
await new Promise((resolve) => server.once('listening', resolve));
|
await new Promise((resolve) => server.once('listening', resolve));
|
||||||
baseUrl = `http://127.0.0.1:${server.address().port}`;
|
baseUrl = `http://127.0.0.1:${server.address().port}`;
|
||||||
@@ -187,3 +201,51 @@ test('Zusatzdateien anmelden, hochladen, ersetzen und löschen', async () => {
|
|||||||
res = await call('DELETE', `${base}/voice-0`);
|
res = await call('DELETE', `${base}/voice-0`);
|
||||||
assert.deepEqual((await res.json()).assets, {});
|
assert.deepEqual((await res.json()).assets, {});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function waitForRender(id, status) {
|
||||||
|
for (let i = 0; i < 100; i += 1) {
|
||||||
|
const project = await (await call('GET', `projects/${id}`)).json();
|
||||||
|
if (project.render.status === status) return project;
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||||
|
}
|
||||||
|
throw new Error(`Status ${status} nicht erreicht`);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('Export ohne fertige Clips wird abgelehnt', async () => {
|
||||||
|
const project = await createProject([clip('a.mp4', 5)]);
|
||||||
|
assert.equal(project.render.status, 'idle');
|
||||||
|
assert.equal((await call('POST', `projects/${project.id}/render`)).status, 400);
|
||||||
|
assert.equal((await call('GET', `projects/${project.id}/output`)).status, 404);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Export läuft, meldet Fortschritt, liefert die Datei und erkennt Änderungen', async () => {
|
||||||
|
const project = await createProject([clip('a.mp4', 5)]);
|
||||||
|
await upload(project.id, 0, 0, 'abcde');
|
||||||
|
|
||||||
|
assert.equal((await call('POST', `projects/${project.id}/render`)).status, 202);
|
||||||
|
const running = await waitForRender(project.id, 'running');
|
||||||
|
assert.equal(running.render.progress, 0.5);
|
||||||
|
assert.equal((await call('POST', `projects/${project.id}/render`)).status, 409);
|
||||||
|
|
||||||
|
releaseRender();
|
||||||
|
const done = await waitForRender(project.id, 'done');
|
||||||
|
assert.equal(done.render.outdated, false);
|
||||||
|
|
||||||
|
const download = await call('GET', `projects/${project.id}/output?download=1`);
|
||||||
|
assert.equal(await download.text(), 'VIDEO');
|
||||||
|
assert.match(download.headers.get('content-disposition'), /FuerDieCuts_2026-09-21_bis_2026-09-25\.mp4/);
|
||||||
|
|
||||||
|
const changed = await (await call('PATCH', `projects/${project.id}/settings`, { speedUp: false })).json();
|
||||||
|
assert.equal(changed.render.outdated, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Laufender Export lässt sich abbrechen', async () => {
|
||||||
|
const project = await createProject([clip('a.mp4', 5)]);
|
||||||
|
await upload(project.id, 0, 0, 'abcde');
|
||||||
|
await call('POST', `projects/${project.id}/render`);
|
||||||
|
await waitForRender(project.id, 'running');
|
||||||
|
assert.equal((await call('DELETE', `projects/${project.id}/render`)).status, 202);
|
||||||
|
const cancelled = await waitForRender(project.id, 'cancelled');
|
||||||
|
assert.equal(cancelled.render.error, null);
|
||||||
|
assert.equal((await call('DELETE', `projects/${project.id}/render`)).status, 404);
|
||||||
|
});
|
||||||
Reference in new issue
Block a user