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
+30
-3
@@ -75,15 +75,42 @@ export function createApp({ config, secret, store }) {
|
|||||||
res.status(204).end();
|
res.status(204).end();
|
||||||
}));
|
}));
|
||||||
|
|
||||||
app.put('/api/projects/:id/clips/:index', route(async (req, res) => {
|
const readOffset = (req) => {
|
||||||
const offset = Number(req.query.offset);
|
const offset = Number(req.query.offset);
|
||||||
if (!Number.isSafeInteger(offset) || offset < 0) throw badRequest('Ungültiger 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) => {
|
app.get('/api/projects/:id/clips/:index/file', route(async (req, res) => {
|
||||||
const project = await store.get(req.params.id);
|
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.' }));
|
app.use('/api', (req, res) => res.status(404).json({ error: 'Nicht gefunden.' }));
|
||||||
|
|||||||
+11
-4
@@ -16,7 +16,8 @@ function readRotation(stream) {
|
|||||||
return 0;
|
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) {
|
export async function probe(file) {
|
||||||
const { stdout } = await execFileAsync(
|
const { stdout } = await execFileAsync(
|
||||||
'ffprobe',
|
'ffprobe',
|
||||||
@@ -27,16 +28,22 @@ export async function probe(file) {
|
|||||||
const video = info.streams.find(
|
const video = info.streams.find(
|
||||||
(stream) => stream.codec_type === 'video' && !stream.disposition?.attached_pic,
|
(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 rotation = readRotation(video);
|
||||||
const sideways = Math.abs(rotation) % 180 === 90;
|
const sideways = Math.abs(rotation) % 180 === 90;
|
||||||
return {
|
return {
|
||||||
duration: Number(info.format?.duration ?? video.duration ?? 0),
|
duration,
|
||||||
|
hasVideo: true,
|
||||||
width: sideways ? video.height : video.width,
|
width: sideways ? video.height : video.width,
|
||||||
height: sideways ? video.width : video.height,
|
height: sideways ? video.width : video.height,
|
||||||
rotation,
|
rotation,
|
||||||
fps: Math.round(parseRate(video.avg_frame_rate || video.r_frame_rate) * 100) / 100,
|
fps: Math.round(parseRate(video.avg_frame_rate || video.r_frame_rate) * 100) / 100,
|
||||||
hasAudio: info.streams.some((stream) => stream.codec_type === 'audio'),
|
hasAudio,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
+130
-37
@@ -5,6 +5,7 @@ import path from 'node:path';
|
|||||||
import { Transform } from 'node:stream';
|
import { Transform } from 'node:stream';
|
||||||
import { pipeline } from 'node:stream/promises';
|
import { pipeline } from 'node:stream/promises';
|
||||||
import { HttpError, badRequest, conflict, notFound } from './http-error.js';
|
import { HttpError, badRequest, conflict, notFound } from './http-error.js';
|
||||||
|
import { defaultSettings, mergeSettings } from './settings.js';
|
||||||
|
|
||||||
export const MAX_CLIPS = 200;
|
export const MAX_CLIPS = 200;
|
||||||
export const MAX_CLIP_BYTES = 4 * 1024 ** 3;
|
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 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 DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
|
||||||
const VIDEO_EXTENSIONS = new Set(['mp4', 'mov', 'm4v', '3gp', 'avi', 'mkv', 'webm']);
|
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) {
|
function extensionOf(name) {
|
||||||
return name.split('.').pop().toLowerCase();
|
return name.split('.').pop().toLowerCase();
|
||||||
@@ -25,21 +37,25 @@ function optionalString(value, maxLength) {
|
|||||||
return value.slice(0, 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) {
|
function validateClip(input, index) {
|
||||||
const file = typeof input?.file === 'string' ? input.file : '';
|
const file = typeof input?.file === 'string' ? input.file : '';
|
||||||
if (!file || file.length > 255 || !VIDEO_EXTENSIONS.has(extensionOf(file))) {
|
if (!file || file.length > 255 || !VIDEO_EXTENSIONS.has(extensionOf(file))) {
|
||||||
throw badRequest(`Clip ${index + 1}: kein unterstütztes Video.`);
|
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)) {
|
if (typeof input.timestamp !== 'string' || !TIMESTAMP_PATTERN.test(input.timestamp)) {
|
||||||
throw badRequest(`Clip ${index + 1}: ungültiger Zeitstempel.`);
|
throw badRequest(`Clip ${index + 1}: ungültiger Zeitstempel.`);
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
index,
|
index,
|
||||||
file,
|
file,
|
||||||
size: input.size,
|
size: validateSize(input.size, `Clip ${index + 1}`),
|
||||||
timestamp: input.timestamp,
|
timestamp: input.timestamp,
|
||||||
sender: optionalString(input.sender, 200),
|
sender: optionalString(input.sender, 200),
|
||||||
caption: optionalString(input.caption, 2000) ?? '',
|
caption: optionalString(input.caption, 2000) ?? '',
|
||||||
@@ -58,6 +74,16 @@ function validateRange(range) {
|
|||||||
return { from, to };
|
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.
|
// Bricht ab, sobald mehr Bytes kommen als erlaubt.
|
||||||
function byteLimit(limit) {
|
function byteLimit(limit) {
|
||||||
let count = 0;
|
let count = 0;
|
||||||
@@ -82,7 +108,7 @@ async function fileSize(file) {
|
|||||||
export function createProjectStore({ dataDir, probe }) {
|
export function createProjectStore({ dataDir, probe }) {
|
||||||
const root = path.join(dataDir, 'projects');
|
const root = path.join(dataDir, 'projects');
|
||||||
const queues = new Map();
|
const queues = new Map();
|
||||||
const busyClips = new Set();
|
const busyUploads = new Set();
|
||||||
|
|
||||||
function projectDir(id) {
|
function projectDir(id) {
|
||||||
if (typeof id !== 'string' || !ID_PATTERN.test(id)) throw notFound();
|
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 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) {
|
async function read(id) {
|
||||||
try {
|
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) {
|
} catch (error) {
|
||||||
if (error.code === 'ENOENT') throw notFound();
|
if (error.code === 'ENOENT') throw notFound();
|
||||||
throw error;
|
throw error;
|
||||||
@@ -122,26 +175,28 @@ export function createProjectStore({ dataDir, probe }) {
|
|||||||
return next;
|
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) {
|
async function withProgress(project) {
|
||||||
|
const received = async (item, file) =>
|
||||||
|
item.status === 'pending' ? fileSize(`${file}.part`) : item.size;
|
||||||
const clips = await Promise.all(
|
const clips = await Promise.all(
|
||||||
project.clips.map(async (clip) => ({
|
project.clips.map(async (clip) => ({
|
||||||
...clip,
|
...clip,
|
||||||
received:
|
received: await received(clip, locate(project, { type: 'clip', index: clip.index }).file),
|
||||||
clip.status === 'pending'
|
|
||||||
? await fileSize(`${clipFile(project.id, clip)}.part`)
|
|
||||||
: clip.size,
|
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
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 {
|
return {
|
||||||
|
update,
|
||||||
|
read,
|
||||||
|
projectDir,
|
||||||
|
|
||||||
async create({ range, clips }) {
|
async create({ range, clips }) {
|
||||||
if (!Array.isArray(clips) || clips.length === 0) throw badRequest('Keine Clips ausgewählt.');
|
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.`);
|
if (clips.length > MAX_CLIPS) throw badRequest(`Höchstens ${MAX_CLIPS} Clips.`);
|
||||||
@@ -152,6 +207,8 @@ export function createProjectStore({ dataDir, probe }) {
|
|||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
range: validateRange(range),
|
range: validateRange(range),
|
||||||
clips: clips.map(validateClip),
|
clips: clips.map(validateClip),
|
||||||
|
assets: {},
|
||||||
|
settings: defaultSettings(),
|
||||||
};
|
};
|
||||||
await fsp.mkdir(path.join(projectDir(project.id), 'clips'), { recursive: true });
|
await fsp.mkdir(path.join(projectDir(project.id), 'clips'), { recursive: true });
|
||||||
await write(project);
|
await write(project);
|
||||||
@@ -194,52 +251,88 @@ export function createProjectStore({ dataDir, probe }) {
|
|||||||
await fsp.rm(dir, { recursive: true, force: true });
|
await fsp.rm(dir, { recursive: true, force: true });
|
||||||
},
|
},
|
||||||
|
|
||||||
clipPath(project, index) {
|
async updateSettings(id, patch) {
|
||||||
const clip = getClip(project, index);
|
const project = await update(id, (current) => {
|
||||||
if (clip.status !== 'ready') throw notFound('Clip ist noch nicht hochgeladen.');
|
current.settings = mergeSettings(current.settings, patch, current.clips.length);
|
||||||
return clipFile(project.id, clip);
|
});
|
||||||
|
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:
|
// 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.
|
// 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 project = await read(id);
|
||||||
const clip = getClip(project, index);
|
const { item, needs, file: final, pick } = locate(project, target);
|
||||||
if (clip.status !== 'pending') return { received: clip.size, status: clip.status };
|
if (item.status !== 'pending') return { received: item.size, status: item.status };
|
||||||
|
|
||||||
const key = `${id}/${clip.index}`;
|
const key = `${id}/${final}`;
|
||||||
if (busyClips.has(key)) throw conflict('Upload läuft bereits.', { received: null });
|
if (busyUploads.has(key)) throw conflict('Upload läuft bereits.', { received: null });
|
||||||
busyClips.add(key);
|
busyUploads.add(key);
|
||||||
try {
|
try {
|
||||||
const final = clipFile(id, clip);
|
|
||||||
const part = `${final}.part`;
|
const part = `${final}.part`;
|
||||||
const received = await fileSize(part);
|
const received = await fileSize(part);
|
||||||
if (offset !== received) throw conflict('Upload-Stand weicht ab.', { received });
|
if (offset !== received) throw conflict('Upload-Stand weicht ab.', { received });
|
||||||
|
|
||||||
// Zu viel Gesendetes wird gar nicht erst geschrieben; der Anfang bleibt gültig.
|
// 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' }));
|
await pipeline(stream, byteLimit(allowed), fs.createWriteStream(part, { flags: 'a' }));
|
||||||
|
|
||||||
const total = await fileSize(part);
|
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);
|
await fsp.rename(part, final);
|
||||||
let media = null;
|
let media = null;
|
||||||
let problem = null;
|
let problem = null;
|
||||||
try {
|
try {
|
||||||
media = await probe(final);
|
media = await probe(final);
|
||||||
|
if (needs === 'video' && !media.hasVideo) problem = 'Keine Videospur gefunden.';
|
||||||
|
if (needs === 'audio' && !media.hasAudio) problem = 'Keine Tonspur gefunden.';
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
problem = error.message;
|
problem = error.message;
|
||||||
}
|
}
|
||||||
const updated = await update(id, (current) => {
|
const updated = await update(id, (current) => {
|
||||||
const target = current.clips[clip.index];
|
const entry = pick(current);
|
||||||
target.status = media ? 'ready' : 'error';
|
entry.status = problem ? 'error' : 'ready';
|
||||||
target.media = media;
|
entry.media = problem ? null : media;
|
||||||
if (problem) target.error = problem;
|
if (problem) entry.error = problem;
|
||||||
});
|
});
|
||||||
return { received: total, status: updated.clips[clip.index].status };
|
return { received: total, status: pick(updated).status };
|
||||||
} finally {
|
} finally {
|
||||||
busyClips.delete(key);
|
busyUploads.delete(key);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
+47
-1
@@ -11,7 +11,7 @@ let baseUrl;
|
|||||||
let cookie;
|
let cookie;
|
||||||
let dataDir;
|
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 () => {
|
before(async () => {
|
||||||
dataDir = await fs.mkdtemp(path.join(os.tmpdir(), 'fdc-test-'));
|
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);
|
assert.equal((await call('GET', `projects/${project.id}`)).status, 404);
|
||||||
await assert.rejects(fs.stat(path.join(dataDir, 'projects', project.id)));
|
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, {});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
Reference in new issue
Block a user