WebM aus dem MediaRecorder nennt oft keine Gesamtlänge; ffprobe liest sie dann aus dem letzten Paket. Videos ohne lesbare Länge werden als Fehler markiert, weil sich ihr Tempo sonst nicht berechnen lässt. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
341 lines
12 KiB
JavaScript
341 lines
12 KiB
JavaScript
import crypto from 'node:crypto';
|
|
import fs from 'node:fs';
|
|
import fsp from 'node:fs/promises';
|
|
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;
|
|
export const MAX_CHUNK_BYTES = 16 * 1024 * 1024;
|
|
|
|
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();
|
|
}
|
|
|
|
function optionalString(value, maxLength) {
|
|
if (value === null || value === undefined) return null;
|
|
if (typeof value !== 'string') throw badRequest('Ungültige Angabe.');
|
|
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 (typeof input.timestamp !== 'string' || !TIMESTAMP_PATTERN.test(input.timestamp)) {
|
|
throw badRequest(`Clip ${index + 1}: ungültiger Zeitstempel.`);
|
|
}
|
|
return {
|
|
index,
|
|
file,
|
|
size: validateSize(input.size, `Clip ${index + 1}`),
|
|
timestamp: input.timestamp,
|
|
sender: optionalString(input.sender, 200),
|
|
caption: optionalString(input.caption, 2000) ?? '',
|
|
status: 'pending',
|
|
};
|
|
}
|
|
|
|
function validateRange(range) {
|
|
const from = range?.from ?? null;
|
|
const to = range?.to ?? null;
|
|
for (const value of [from, to]) {
|
|
if (value !== null && (typeof value !== 'string' || !DATE_PATTERN.test(value))) {
|
|
throw badRequest('Ungültiger Zeitraum.');
|
|
}
|
|
}
|
|
return { from, to };
|
|
}
|
|
|
|
// "intro", "outro", "music" oder "voice-<Clip-Index>"
|
|
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;
|
|
return new Transform({
|
|
transform(chunk, encoding, callback) {
|
|
count += chunk.length;
|
|
if (count > limit) return callback(new HttpError(413, 'Zu viele Daten.'));
|
|
callback(null, chunk);
|
|
},
|
|
});
|
|
}
|
|
|
|
async function fileSize(file) {
|
|
try {
|
|
return (await fsp.stat(file)).size;
|
|
} catch (error) {
|
|
if (error.code === 'ENOENT') return 0;
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
export function createProjectStore({ dataDir, probe }) {
|
|
const root = path.join(dataDir, 'projects');
|
|
const queues = new Map();
|
|
const busyUploads = new Set();
|
|
|
|
function projectDir(id) {
|
|
if (typeof id !== 'string' || !ID_PATTERN.test(id)) throw notFound();
|
|
return path.join(root, id);
|
|
}
|
|
|
|
const projectFile = (id) => path.join(projectDir(id), 'project.json');
|
|
|
|
// 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 {
|
|
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;
|
|
}
|
|
}
|
|
|
|
async function write(project) {
|
|
const file = projectFile(project.id);
|
|
const temp = `${file}.tmp`;
|
|
await fsp.writeFile(temp, JSON.stringify(project, null, 2));
|
|
await fsp.rename(temp, file);
|
|
}
|
|
|
|
// Änderungen an einem Projekt laufen nacheinander, damit keine verloren geht.
|
|
function update(id, change) {
|
|
const previous = queues.get(id) ?? Promise.resolve();
|
|
const next = previous.then(async () => {
|
|
const project = await read(id);
|
|
await change(project);
|
|
project.updatedAt = new Date().toISOString();
|
|
await write(project);
|
|
return project;
|
|
});
|
|
queues.set(id, next.catch(() => {}));
|
|
return next;
|
|
}
|
|
|
|
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: await received(clip, locate(project, { type: 'clip', index: clip.index }).file),
|
|
})),
|
|
);
|
|
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.`);
|
|
const now = new Date().toISOString();
|
|
const project = {
|
|
id: crypto.randomUUID(),
|
|
createdAt: now,
|
|
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);
|
|
return withProgress(project);
|
|
},
|
|
|
|
async get(id) {
|
|
return withProgress(await read(id));
|
|
},
|
|
|
|
async list() {
|
|
let entries = [];
|
|
try {
|
|
entries = await fsp.readdir(root);
|
|
} catch (error) {
|
|
if (error.code !== 'ENOENT') throw error;
|
|
}
|
|
const projects = [];
|
|
for (const entry of entries) {
|
|
if (!ID_PATTERN.test(entry)) continue;
|
|
try {
|
|
const project = await read(entry);
|
|
projects.push({
|
|
id: project.id,
|
|
createdAt: project.createdAt,
|
|
range: project.range,
|
|
clipCount: project.clips.length,
|
|
readyCount: project.clips.filter((clip) => clip.status === 'ready').length,
|
|
});
|
|
} catch {
|
|
// Unvollständig angelegte Projekte überspringen
|
|
}
|
|
}
|
|
return projects.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
|
},
|
|
|
|
async remove(id) {
|
|
const dir = projectDir(id);
|
|
await read(id);
|
|
await fsp.rm(dir, { recursive: true, force: true });
|
|
},
|
|
|
|
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, target, offset, stream) {
|
|
const project = await read(id);
|
|
const { item, needs, file: final, pick } = locate(project, target);
|
|
if (item.status !== 'pending') return { received: item.size, status: item.status };
|
|
|
|
const key = `${id}/${final}`;
|
|
if (busyUploads.has(key)) throw conflict('Upload läuft bereits.', { received: null });
|
|
busyUploads.add(key);
|
|
try {
|
|
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, item.size - received);
|
|
await pipeline(stream, byteLimit(allowed), fs.createWriteStream(part, { flags: 'a' }));
|
|
|
|
const total = await fileSize(part);
|
|
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.';
|
|
else if (needs === 'video' && !(media.duration > 0)) problem = 'Länge des Videos nicht lesbar.';
|
|
if (needs === 'audio' && !media.hasAudio) problem = 'Keine Tonspur gefunden.';
|
|
} catch (error) {
|
|
problem = error.message;
|
|
}
|
|
const updated = await update(id, (current) => {
|
|
const entry = pick(current);
|
|
entry.status = problem ? 'error' : 'ready';
|
|
entry.media = problem ? null : media;
|
|
if (problem) entry.error = problem;
|
|
});
|
|
return { received: total, status: pick(updated).status };
|
|
} finally {
|
|
busyUploads.delete(key);
|
|
}
|
|
},
|
|
};
|
|
}
|