diff --git a/server/app.js b/server/app.js index 316ec20..3d76a72 100644 --- a/server/app.js +++ b/server/app.js @@ -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 diff --git a/server/index.js b/server/index.js index c192a85..8249617 100644 --- a/server/index.js +++ b/server/index.js @@ -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) { diff --git a/server/renderQueue.js b/server/renderQueue.js new file mode 100644 index 0000000..7b879fd --- /dev/null +++ b/server/renderQueue.js @@ -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; + }, + }; +} diff --git a/test/projects.test.js b/test/projects.test.js index 34988e7..e1fb1b7 100644 --- a/test/projects.test.js +++ b/test/projects.test.js @@ -5,11 +5,24 @@ import path from 'node:path'; import { after, before, test } from 'node:test'; import { createApp } from '../server/app.js'; import { createProjectStore } from '../server/projects.js'; +import { createRenderQueue } from '../server/renderQueue.js'; let server; let baseUrl; let cookie; 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 }; @@ -21,7 +34,8 @@ before(async () => { return fakeMedia; }; 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); await new Promise((resolve) => server.once('listening', resolve)); 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`); 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); +});