diff --git a/server/media.js b/server/media.js index 6f8f3c5..58d08c1 100644 --- a/server/media.js +++ b/server/media.js @@ -16,6 +16,21 @@ function readRotation(stream) { return 0; } +// Länge aus dem Ende des letzten Pakets, wenn der Dateikopf keine nennt. +async function measureDuration(file) { + const { stdout } = await execFileAsync( + 'ffprobe', + ['-v', 'error', '-show_entries', 'packet=pts_time,duration_time', '-of', 'csv=p=0', file], + { maxBuffer: 64 * 1024 * 1024 }, + ); + let end = 0; + for (const line of stdout.split('\n')) { + const [pts, duration] = line.split(',').map(Number); + if (Number.isFinite(pts)) end = Math.max(end, pts + (Number.isFinite(duration) ? duration : 0)); + } + return end > 0 ? end : null; +} + // Liefert die Eckdaten einer Video- oder Audiodatei; Breite und Höhe so, // wie das Video angezeigt wird. export async function probe(file) { @@ -28,10 +43,14 @@ export async function probe(file) { const video = info.streams.find( (stream) => stream.codec_type === 'video' && !stream.disposition?.attached_pic, ); - const hasAudio = info.streams.some((stream) => stream.codec_type === 'audio'); + const audio = info.streams.find((stream) => stream.codec_type === 'audio'); + const hasAudio = Boolean(audio); if (!video && !hasAudio) throw new Error('Weder Bild noch Ton gefunden.'); - const duration = Number(info.format?.duration ?? video?.duration ?? 0); + // Browser-Aufnahmen (WebM) tragen oft keine Gesamtlänge im Kopf. + const duration = + [info.format?.duration, video?.duration, audio?.duration].map(Number).find(Number.isFinite) ?? + (await measureDuration(file)); if (!video) { return { duration, hasVideo: false, width: null, height: null, rotation: 0, fps: 0, hasAudio }; } diff --git a/server/projects.js b/server/projects.js index 5d45fef..b94e802 100644 --- a/server/projects.js +++ b/server/projects.js @@ -320,6 +320,7 @@ export function createProjectStore({ dataDir, probe }) { 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; diff --git a/test/media.test.js b/test/media.test.js index 7ee71be..aae5ea1 100644 --- a/test/media.test.js +++ b/test/media.test.js @@ -26,6 +26,9 @@ before(() => { // Wie ein Hochkant-Handyvideo: quer gespeichert, mit Drehung im Dateikopf ffmpeg('-i', path.join(dir, 'quer.mp4'), '-an', '-c', 'copy', '-metadata:s:v:0', 'rotate=90', path.join(dir, 'hochkant.mp4')); + // Wie eine Browser-Aufnahme: WebM ohne Gesamtlänge im Kopf + ffmpeg('-f', 'lavfi', '-i', 'sine', '-t', '2', '-c:a', 'libopus', '-f', 'webm', '-live', '1', + path.join(dir, 'aufnahme.webm')); }); after(() => { @@ -54,3 +57,10 @@ test('Datei ohne Video wird abgelehnt', { skip: !hasFfmpeg && 'ffmpeg fehlt' }, fs.writeFileSync(file, 'kein Video'); await assert.rejects(probe(file)); }); + +test('Browser-Aufnahme ohne Längenangabe wird ausgemessen', { skip: !hasFfmpeg && 'ffmpeg fehlt' }, async () => { + const media = await probe(path.join(dir, 'aufnahme.webm')); + assert.equal(media.hasVideo, false); + assert.equal(media.hasAudio, true); + assert.ok(Math.abs(media.duration - 2) < 0.1, `Länge ${media.duration}`); +});