From 72e4b8ab7a9f2e6eaafb1b5e9c1619cef8c1640e Mon Sep 17 00:00:00 2001 From: Ferdinand Urban Date: Tue, 29 Sep 2026 10:48:53 +0000 Subject: [PATCH] =?UTF-8?q?Einstellungen=20und=20Zusatzdateien=20f=C3=BCr?= =?UTF-8?q?=20den=20Schnitt=20ablegen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Intro, Outro, Backing Track und Voice-overs pro Clip laufen über denselben Upload in Stücken wie die Clips. Die Schnitt-Einstellungen werden feldweise geprüft; ffprobe akzeptiert jetzt auch reine Tonspuren. Co-Authored-By: Claude Opus 5.5 (1M context) --- server/app.js | 33 ++++++++- server/media.js | 15 +++- server/projects.js | 167 ++++++++++++++++++++++++++++++++---------- server/settings.js | 76 +++++++++++++++++++ test/projects.test.js | 48 +++++++++++- test/settings.test.js | 46 ++++++++++++ 6 files changed, 340 insertions(+), 45 deletions(-) create mode 100644 server/settings.js create mode 100644 test/settings.test.js diff --git a/server/app.js b/server/app.js index 0ad421a..316ec20 100644 --- a/server/app.js +++ b/server/app.js @@ -75,15 +75,42 @@ export function createApp({ config, secret, store }) { res.status(204).end(); })); - app.put('/api/projects/:id/clips/:index', route(async (req, res) => { + const readOffset = (req) => { const offset = Number(req.query.offset); if (!Number.isSafeInteger(offset) || offset < 0) throw badRequest('Ungültiger Offset.'); - res.json(await store.appendChunk(req.params.id, req.params.index, offset, req)); + return offset; + }; + + app.patch('/api/projects/:id/settings', route(async (req, res) => { + res.json(await store.updateSettings(req.params.id, req.body)); + })); + + app.put('/api/projects/:id/clips/:index', route(async (req, res) => { + const target = { type: 'clip', index: req.params.index }; + res.json(await store.appendChunk(req.params.id, target, readOffset(req), req)); })); app.get('/api/projects/:id/clips/:index/file', route(async (req, res) => { const project = await store.get(req.params.id); - res.sendFile(store.clipPath(project, req.params.index)); + res.sendFile(store.readyPath(project, { type: 'clip', index: req.params.index })); + })); + + 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)); + })); + + app.put('/api/projects/:id/assets/:slot', route(async (req, res) => { + const target = { type: 'asset', slot: req.params.slot }; + res.json(await store.appendChunk(req.params.id, target, readOffset(req), req)); + })); + + app.delete('/api/projects/:id/assets/:slot', route(async (req, res) => { + res.json(await store.removeAsset(req.params.id, req.params.slot)); + })); + + app.get('/api/projects/:id/assets/:slot/file', route(async (req, res) => { + const project = await store.get(req.params.id); + res.sendFile(store.readyPath(project, { type: 'asset', slot: req.params.slot })); })); app.use('/api', (req, res) => res.status(404).json({ error: 'Nicht gefunden.' })); diff --git a/server/media.js b/server/media.js index c9751d5..6f8f3c5 100644 --- a/server/media.js +++ b/server/media.js @@ -16,7 +16,8 @@ function readRotation(stream) { return 0; } -// Liefert die Eckdaten eines Videos, Breite und Höhe so, wie es angezeigt wird. +// Liefert die Eckdaten einer Video- oder Audiodatei; Breite und Höhe so, +// wie das Video angezeigt wird. export async function probe(file) { const { stdout } = await execFileAsync( 'ffprobe', @@ -27,16 +28,22 @@ export async function probe(file) { const video = info.streams.find( (stream) => stream.codec_type === 'video' && !stream.disposition?.attached_pic, ); - if (!video) throw new Error('Keine Videospur gefunden.'); + const hasAudio = info.streams.some((stream) => stream.codec_type === 'audio'); + if (!video && !hasAudio) throw new Error('Weder Bild noch Ton gefunden.'); + const duration = Number(info.format?.duration ?? video?.duration ?? 0); + if (!video) { + return { duration, hasVideo: false, width: null, height: null, rotation: 0, fps: 0, hasAudio }; + } const rotation = readRotation(video); const sideways = Math.abs(rotation) % 180 === 90; return { - duration: Number(info.format?.duration ?? video.duration ?? 0), + duration, + hasVideo: true, width: sideways ? video.height : video.width, height: sideways ? video.width : video.height, rotation, fps: Math.round(parseRate(video.avg_frame_rate || video.r_frame_rate) * 100) / 100, - hasAudio: info.streams.some((stream) => stream.codec_type === 'audio'), + hasAudio, }; } diff --git a/server/projects.js b/server/projects.js index 58e2fd4..5d45fef 100644 --- a/server/projects.js +++ b/server/projects.js @@ -5,6 +5,7 @@ import path from 'node:path'; import { Transform } from 'node:stream'; import { pipeline } from 'node:stream/promises'; import { HttpError, badRequest, conflict, notFound } from './http-error.js'; +import { defaultSettings, mergeSettings } from './settings.js'; export const MAX_CLIPS = 200; export const MAX_CLIP_BYTES = 4 * 1024 ** 3; @@ -14,6 +15,17 @@ const ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12 const TIMESTAMP_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/; const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/; const VIDEO_EXTENSIONS = new Set(['mp4', 'mov', 'm4v', '3gp', 'avi', 'mkv', 'webm']); +const AUDIO_EXTENSIONS = new Set([ + 'mp3', 'm4a', 'aac', 'wav', 'ogg', 'oga', 'opus', 'flac', 'weba', 'webm', 'mp4', +]); + +// Zusatzdateien: Intro und Outro sind Videos, Musik und Voice-overs Ton. +const ASSET_SLOTS = { + intro: { needs: 'video', extensions: VIDEO_EXTENSIONS }, + outro: { needs: 'video', extensions: VIDEO_EXTENSIONS }, + music: { needs: 'audio', extensions: AUDIO_EXTENSIONS }, + voice: { needs: 'audio', extensions: AUDIO_EXTENSIONS }, +}; function extensionOf(name) { return name.split('.').pop().toLowerCase(); @@ -25,21 +37,25 @@ function optionalString(value, maxLength) { return value.slice(0, maxLength); } +function validateSize(size, label) { + if (!Number.isSafeInteger(size) || size < 1 || size > MAX_CLIP_BYTES) { + throw badRequest(`${label}: ungültige Dateigröße.`); + } + return size; +} + function validateClip(input, index) { const file = typeof input?.file === 'string' ? input.file : ''; if (!file || file.length > 255 || !VIDEO_EXTENSIONS.has(extensionOf(file))) { throw badRequest(`Clip ${index + 1}: kein unterstütztes Video.`); } - if (!Number.isSafeInteger(input.size) || input.size < 1 || input.size > MAX_CLIP_BYTES) { - throw badRequest(`Clip ${index + 1}: ungültige Dateigröße.`); - } if (typeof input.timestamp !== 'string' || !TIMESTAMP_PATTERN.test(input.timestamp)) { throw badRequest(`Clip ${index + 1}: ungültiger Zeitstempel.`); } return { index, file, - size: input.size, + size: validateSize(input.size, `Clip ${index + 1}`), timestamp: input.timestamp, sender: optionalString(input.sender, 200), caption: optionalString(input.caption, 2000) ?? '', @@ -58,6 +74,16 @@ function validateRange(range) { return { from, to }; } +// "intro", "outro", "music" oder "voice-" +function parseSlot(slot, project) { + const match = /^(intro|outro|music|voice)(?:-(\d+))?$/.exec(String(slot)); + if (!match) throw notFound('Unbekannte Datei.'); + const [, kind, index] = match; + if ((kind === 'voice') !== (index !== undefined)) throw notFound('Unbekannte Datei.'); + if (kind === 'voice' && !project.clips[Number(index)]) throw notFound('Clip nicht gefunden.'); + return { kind, ...ASSET_SLOTS[kind] }; +} + // Bricht ab, sobald mehr Bytes kommen als erlaubt. function byteLimit(limit) { let count = 0; @@ -82,7 +108,7 @@ async function fileSize(file) { export function createProjectStore({ dataDir, probe }) { const root = path.join(dataDir, 'projects'); const queues = new Map(); - const busyClips = new Set(); + const busyUploads = new Set(); function projectDir(id) { if (typeof id !== 'string' || !ID_PATTERN.test(id)) throw notFound(); @@ -90,11 +116,38 @@ export function createProjectStore({ dataDir, probe }) { } const projectFile = (id) => path.join(projectDir(id), 'project.json'); - const clipFile = (id, clip) => path.join(projectDir(id), 'clips', `${clip.index}.${extensionOf(clip.file)}`); + + // Ein Upload-Ziel ist ein Clip oder eine Zusatzdatei; beide haben file, size, status. + function locate(project, target) { + if (target.type === 'clip') { + const index = String(target.index); + const item = project.clips[Number(index)]; + if (!/^\d+$/.test(index) || !item) throw notFound('Clip nicht gefunden.'); + return { + item, + needs: 'video', + file: path.join(projectDir(project.id), 'clips', `${item.index}.${extensionOf(item.file)}`), + pick: (current) => current.clips[item.index], + }; + } + const { needs } = parseSlot(target.slot, project); + const item = project.assets?.[target.slot]; + if (!item) throw notFound('Datei nicht gefunden.'); + return { + item, + needs, + file: path.join(projectDir(project.id), 'assets', `${target.slot}.${extensionOf(item.file)}`), + pick: (current) => current.assets[target.slot], + }; + } async function read(id) { try { - return JSON.parse(await fsp.readFile(projectFile(id), 'utf8')); + const project = JSON.parse(await fsp.readFile(projectFile(id), 'utf8')); + // Projekte aus älteren Ständen ergänzen + project.settings = { ...defaultSettings(), ...project.settings }; + project.assets ??= {}; + return project; } catch (error) { if (error.code === 'ENOENT') throw notFound(); throw error; @@ -122,26 +175,28 @@ export function createProjectStore({ dataDir, probe }) { return next; } - function getClip(project, index) { - const clip = project.clips[Number(index)]; - if (!/^\d+$/.test(String(index)) || !clip) throw notFound('Clip nicht gefunden.'); - return clip; - } - async function withProgress(project) { + const received = async (item, file) => + item.status === 'pending' ? fileSize(`${file}.part`) : item.size; const clips = await Promise.all( project.clips.map(async (clip) => ({ ...clip, - received: - clip.status === 'pending' - ? await fileSize(`${clipFile(project.id, clip)}.part`) - : clip.size, + received: await received(clip, locate(project, { type: 'clip', index: clip.index }).file), })), ); - return { ...project, clips }; + const assets = {}; + for (const slot of Object.keys(project.assets)) { + const { item, file } = locate(project, { type: 'asset', slot }); + assets[slot] = { ...item, received: await received(item, file) }; + } + return { ...project, clips, assets }; } return { + update, + read, + projectDir, + async create({ range, clips }) { if (!Array.isArray(clips) || clips.length === 0) throw badRequest('Keine Clips ausgewählt.'); if (clips.length > MAX_CLIPS) throw badRequest(`Höchstens ${MAX_CLIPS} Clips.`); @@ -152,6 +207,8 @@ export function createProjectStore({ dataDir, probe }) { updatedAt: now, range: validateRange(range), clips: clips.map(validateClip), + assets: {}, + settings: defaultSettings(), }; await fsp.mkdir(path.join(projectDir(project.id), 'clips'), { recursive: true }); await write(project); @@ -194,52 +251,88 @@ export function createProjectStore({ dataDir, probe }) { await fsp.rm(dir, { recursive: true, force: true }); }, - clipPath(project, index) { - const clip = getClip(project, index); - if (clip.status !== 'ready') throw notFound('Clip ist noch nicht hochgeladen.'); - return clipFile(project.id, clip); + async updateSettings(id, patch) { + const project = await update(id, (current) => { + current.settings = mergeSettings(current.settings, patch, current.clips.length); + }); + return withProgress(project); + }, + + // Meldet eine neue Zusatzdatei an; eine vorhandene im selben Platz wird ersetzt. + async createAsset(id, slot, { file, size } = {}) { + const project = await read(id); + const { extensions } = parseSlot(slot, project); + if (typeof file !== 'string' || !file || file.length > 255 || !extensions.has(extensionOf(file))) { + throw badRequest('Dateityp wird hier nicht unterstützt.'); + } + validateSize(size, 'Datei'); + if (project.assets[slot]) await this.removeAsset(id, slot); + await fsp.mkdir(path.join(projectDir(id), 'assets'), { recursive: true }); + const updated = await update(id, (current) => { + current.assets[slot] = { file, size, status: 'pending', revision: crypto.randomUUID() }; + }); + return withProgress(updated); + }, + + async removeAsset(id, slot) { + const project = await read(id); + const { file } = locate(project, { type: 'asset', slot }); + await fsp.rm(file, { force: true }); + await fsp.rm(`${file}.part`, { force: true }); + const updated = await update(id, (current) => { + delete current.assets[slot]; + }); + return withProgress(updated); + }, + + // Pfad einer fertig hochgeladenen Datei, für Vorschau und Schnitt. + readyPath(project, target) { + const { item, file } = locate(project, target); + if (item.status !== 'ready') throw notFound('Datei ist noch nicht hochgeladen.'); + return file; }, // Hängt ein Stück an. Die Größe der Teildatei ist der maßgebliche Stand: // bricht eine Übertragung ab, bleibt ein gültiger Anfang liegen. - async appendChunk(id, index, offset, stream) { + async appendChunk(id, target, offset, stream) { const project = await read(id); - const clip = getClip(project, index); - if (clip.status !== 'pending') return { received: clip.size, status: clip.status }; + const { item, needs, file: final, pick } = locate(project, target); + if (item.status !== 'pending') return { received: item.size, status: item.status }; - const key = `${id}/${clip.index}`; - if (busyClips.has(key)) throw conflict('Upload läuft bereits.', { received: null }); - busyClips.add(key); + const key = `${id}/${final}`; + if (busyUploads.has(key)) throw conflict('Upload läuft bereits.', { received: null }); + busyUploads.add(key); try { - const final = clipFile(id, clip); const part = `${final}.part`; const received = await fileSize(part); if (offset !== received) throw conflict('Upload-Stand weicht ab.', { received }); // Zu viel Gesendetes wird gar nicht erst geschrieben; der Anfang bleibt gültig. - const allowed = Math.min(MAX_CHUNK_BYTES, clip.size - received); + const allowed = Math.min(MAX_CHUNK_BYTES, item.size - received); await pipeline(stream, byteLimit(allowed), fs.createWriteStream(part, { flags: 'a' })); const total = await fileSize(part); - if (total < clip.size) return { received: total, status: 'pending' }; + if (total < item.size) return { received: total, status: 'pending' }; await fsp.rename(part, final); let media = null; let problem = null; try { media = await probe(final); + if (needs === 'video' && !media.hasVideo) problem = 'Keine Videospur gefunden.'; + if (needs === 'audio' && !media.hasAudio) problem = 'Keine Tonspur gefunden.'; } catch (error) { problem = error.message; } const updated = await update(id, (current) => { - const target = current.clips[clip.index]; - target.status = media ? 'ready' : 'error'; - target.media = media; - if (problem) target.error = problem; + const entry = pick(current); + entry.status = problem ? 'error' : 'ready'; + entry.media = problem ? null : media; + if (problem) entry.error = problem; }); - return { received: total, status: updated.clips[clip.index].status }; + return { received: total, status: pick(updated).status }; } finally { - busyClips.delete(key); + busyUploads.delete(key); } }, }; diff --git a/server/settings.js b/server/settings.js new file mode 100644 index 0000000..bfc94ae --- /dev/null +++ b/server/settings.js @@ -0,0 +1,76 @@ +import { badRequest } from './http-error.js'; + +export const TARGET_SECONDS = 10; + +export function defaultSettings() { + return { + intro: { enabled: false, volume: 100 }, + outro: { enabled: false, volume: 100 }, + speedUp: true, + muteClips: true, + clipVolume: 100, + music: { enabled: false, volume: 60 }, + voiceVolume: 100, + // Pro Clip-Index: Lautstärke des Voice-overs, 0–100 + voiceVolumes: {}, + // Clip-Indizes, die nicht ins Video sollen + excluded: [], + }; +} + +function volume(value, name) { + if (!Number.isInteger(value) || value < 0 || value > 100) { + throw badRequest(`${name}: Lautstärke muss zwischen 0 und 100 liegen.`); + } + return value; +} + +function flag(value, name) { + if (typeof value !== 'boolean') throw badRequest(`${name}: ungültiger Wert.`); + return value; +} + +function clipIndex(value, clipCount) { + const index = Number(value); + if (!Number.isInteger(index) || index < 0 || index >= clipCount) { + throw badRequest('Unbekannter Clip.'); + } + return index; +} + +// Übernimmt nur bekannte Felder aus patch und prüft jedes; der Rest bleibt. +export function mergeSettings(current, patch, clipCount) { + if (!patch || typeof patch !== 'object' || Array.isArray(patch)) { + throw badRequest('Ungültige Einstellungen.'); + } + const next = structuredClone({ ...defaultSettings(), ...current }); + + for (const part of ['intro', 'outro', 'music']) { + if (patch[part] === undefined) continue; + const value = patch[part]; + if (!value || typeof value !== 'object') throw badRequest(`${part}: ungültiger Wert.`); + if (value.enabled !== undefined) next[part].enabled = flag(value.enabled, part); + if (value.volume !== undefined) next[part].volume = volume(value.volume, part); + } + for (const key of ['speedUp', 'muteClips']) { + if (patch[key] !== undefined) next[key] = flag(patch[key], key); + } + for (const key of ['clipVolume', 'voiceVolume']) { + if (patch[key] !== undefined) next[key] = volume(patch[key], key); + } + if (patch.voiceVolumes !== undefined) { + if (!patch.voiceVolumes || typeof patch.voiceVolumes !== 'object') { + throw badRequest('voiceVolumes: ungültiger Wert.'); + } + for (const [key, value] of Object.entries(patch.voiceVolumes)) { + next.voiceVolumes[clipIndex(key, clipCount)] = volume(value, 'Voice-over'); + } + } + if (patch.excluded !== undefined) { + if (!Array.isArray(patch.excluded)) throw badRequest('excluded: ungültiger Wert.'); + next.excluded = [...new Set(patch.excluded.map((value) => clipIndex(value, clipCount)))].sort( + (a, b) => a - b, + ); + } + return next; +} diff --git a/test/projects.test.js b/test/projects.test.js index 56d8fcc..34988e7 100644 --- a/test/projects.test.js +++ b/test/projects.test.js @@ -11,7 +11,7 @@ let baseUrl; let cookie; let dataDir; -const fakeMedia = { duration: 42, 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 }; before(async () => { dataDir = await fs.mkdtemp(path.join(os.tmpdir(), 'fdc-test-')); @@ -141,3 +141,49 @@ test('Projekt löschen entfernt die Dateien', async () => { assert.equal((await call('GET', `projects/${project.id}`)).status, 404); await assert.rejects(fs.stat(path.join(dataDir, 'projects', project.id))); }); + +test('Einstellungen speichern und prüfen', async () => { + const project = await createProject([clip('a.mp4', 5), clip('b.mp4', 5)]); + assert.equal(project.settings.muteClips, true); + + let res = await call('PATCH', `projects/${project.id}/settings`, { music: { enabled: true, volume: 40 } }); + const updated = await res.json(); + assert.deepEqual(updated.settings.music, { enabled: true, volume: 40 }); + + res = await call('PATCH', `projects/${project.id}/settings`, { excluded: [5] }); + assert.equal(res.status, 400); +}); + +test('Zusatzdateien anmelden, hochladen, ersetzen und löschen', async () => { + const project = await createProject([clip('a.mp4', 5)]); + const base = `projects/${project.id}/assets`; + + // Falscher Dateityp für den Platz + assert.equal((await call('POST', `${base}/music`, { file: 'intro.txt', size: 4 })).status, 400); + // Unbekannter Platz und Voice-over zu einem Clip, den es nicht gibt + assert.equal((await call('POST', `${base}/abspann`, { file: 'a.mp3', size: 4 })).status, 404); + assert.equal((await call('POST', `${base}/voice-3`, { file: 'a.webm', size: 4 })).status, 404); + + let res = await call('POST', `${base}/voice-0`, { file: 'aufnahme.webm', size: 4 }); + assert.equal(res.status, 201); + assert.equal((await res.json()).assets['voice-0'].status, 'pending'); + + const put = (slot, offset, text) => + call('PUT', `${base}/${slot}?offset=${offset}`, new TextEncoder().encode(text), { + 'Content-Type': 'application/octet-stream', + }); + res = await put('voice-0', 0, 'stim'); + assert.equal((await res.json()).status, 'ready'); + assert.equal(await (await call('GET', `${base}/voice-0/file`)).text(), 'stim'); + + // Ersetzen verwirft die alte Datei + await call('POST', `${base}/voice-0`, { file: 'neu.m4a', size: 3 }); + const replaced = await (await call('GET', `projects/${project.id}`)).json(); + assert.equal(replaced.assets['voice-0'].file, 'neu.m4a'); + assert.equal(replaced.assets['voice-0'].received, 0); + const files = await fs.readdir(path.join(dataDir, 'projects', project.id, 'assets')); + assert.deepEqual(files, []); + + res = await call('DELETE', `${base}/voice-0`); + assert.deepEqual((await res.json()).assets, {}); +}); diff --git a/test/settings.test.js b/test/settings.test.js new file mode 100644 index 0000000..2efb12d --- /dev/null +++ b/test/settings.test.js @@ -0,0 +1,46 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { defaultSettings, mergeSettings } from '../server/settings.js'; + +test('Vorgaben: beschleunigen und stummschalten an, Intro, Outro und Musik aus', () => { + const settings = defaultSettings(); + assert.equal(settings.speedUp, true); + assert.equal(settings.muteClips, true); + assert.equal(settings.intro.enabled, false); + assert.equal(settings.music.volume, 60); +}); + +test('Teiländerungen lassen den Rest stehen', () => { + const merged = mergeSettings(defaultSettings(), { music: { volume: 35 } }, 5); + assert.equal(merged.music.volume, 35); + assert.equal(merged.music.enabled, false); + assert.equal(merged.intro.volume, 100); +}); + +test('Voice-over-Lautstärke pro Clip und ausgeschlossene Clips', () => { + const merged = mergeSettings(defaultSettings(), { voiceVolumes: { 2: 80 }, excluded: [3, 1, 3] }, 5); + assert.deepEqual(merged.voiceVolumes, { 2: 80 }); + assert.deepEqual(merged.excluded, [1, 3]); +}); + +test('Ungültige Werte werden abgelehnt', () => { + const invalid = [ + { music: { volume: 101 } }, + { clipVolume: -1 }, + { clipVolume: 50.5 }, + { speedUp: 'ja' }, + { voiceVolumes: { 9: 50 } }, + { excluded: [7] }, + [], + ]; + for (const patch of invalid) { + assert.throws(() => mergeSettings(defaultSettings(), patch, 5), { status: 400 }); + } +}); + +test('Unbekannte Felder werden ignoriert', () => { + const merged = mergeSettings(defaultSettings(), { hacker: true, intro: { enabled: true, file: '/etc/passwd' } }, 1); + assert.equal(merged.hacker, undefined); + assert.equal(merged.intro.file, undefined); + assert.equal(merged.intro.enabled, true); +});