Einstellungen und Zusatzdateien für den Schnitt ablegen
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) <noreply@anthropic.com>
This commit is contained in:
1 parent
d7102d04a4
commit
72e4b8ab7a
6 files changed
+340
-45
No files matched your search
+130
-37
@@ -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-<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;
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user