import { test } from 'node:test'; import assert from 'node:assert/strict'; import type { LayoutProfile } from '#core/config.ts'; import { parseProbe, parseProgress } from './ffmpeg.ts'; import { micTrackArgs, verticalArgs, verticalFilter } from './vertical.ts'; // Das eingemessene Profil aus config/layout.yaml const PROFILE: LayoutProfile = { cam: { crop: { x: 0, y: 100, w: 440, h: 208 }, band: { w: 1080, h: 512 } }, game: { crop: { x: 440, y: 0, w: 828, h: 1080 }, band: { w: 1080, h: 1408 } }, }; test('Filtergraph nutzt die eingemessenen Werte', () => { const f = verticalFilter(PROFILE); assert.match(f, /crop=440:208:0:100/); assert.match(f, /crop=828:1080:440:0/); assert.match(f, /scale=1080:512/); assert.match(f, /scale=1080:1408/); assert.match(f, /vstack=inputs=2/); // setsar verhindert, dass ein krummes Pixelseitenverhaeltnis durchschlaegt assert.equal((f.match(/setsar=1/g) ?? []).length, 2); }); test('Baender ergeben zusammen die Zielhoehe', () => { assert.equal(PROFILE.cam.band.h + PROFILE.game.band.h, 1920); }); test('-sseof steht vor -i, sonst wird die ganze Datei dekodiert', () => { const args = verticalArgs({ input: '/work/raw.mkv', output: '/out/v.mp4', profile: PROFILE, tailSeconds: 45, }); const sseof = args.indexOf('-sseof'); const input = args.indexOf('-i'); assert.ok(sseof !== -1, '-sseof fehlt'); assert.ok(sseof < input, '-sseof muss vor -i stehen'); assert.equal(args[sseof + 1], '-45'); }); test('ohne tailSeconds kein -sseof', () => { const args = verticalArgs({ input: 'a.mkv', output: 'b.mp4', profile: PROFILE }); assert.equal(args.includes('-sseof'), false); }); test('Tonspur wird gewaehlt und darf fehlen', () => { const args = verticalArgs({ input: 'a.mkv', output: 'b.mp4', profile: PROFILE, audioTrack: 2, }); // Das Fragezeichen macht die Zuordnung optional - ohne das bricht ffmpeg ab, // wenn die Spur nicht existiert assert.ok(args.includes('0:a:2?'), `-map fehlt: ${args.join(' ')}`); }); test('Ausgabe ist fuer Handys geeignet kodiert', () => { const args = verticalArgs({ input: 'a.mkv', output: 'b.mp4', profile: PROFILE }); assert.ok(args.includes('yuv420p'), 'ohne yuv420p spielen viele Player nichts ab'); assert.ok(args.includes('+faststart'), 'ohne faststart laedt das Video traege'); }); test('Mikrofonspur wird als 16-kHz-Mono-WAV gezogen', () => { const args = micTrackArgs('/work/raw.mkv', '/work/mic.wav', 1); assert.ok(args.includes('0:a:1')); assert.ok(args.includes('16000')); assert.ok(args.includes('pcm_s16le')); assert.deepEqual(args.slice(-3), ['-c:a', 'pcm_s16le', '/work/mic.wav']); }); test('liest den Fortschritt aus der ffmpeg-Ausgabe', () => { assert.equal(parseProgress('frame=120\nout_time_ms=4500000\nspeed=1.2x'), 4.5); assert.equal(parseProgress('frame=120\nspeed=1.2x'), null); }); test('erkennt Aufloesung, Laenge und mehrere Tonspuren', () => { // So sieht eine OBS-Aufnahme mit getrennten Spuren aus const json = JSON.stringify({ format: { duration: '92.416000' }, streams: [ { index: 0, codec_type: 'video', codec_name: 'h264', width: 1920, height: 1080 }, { index: 1, codec_type: 'audio', codec_name: 'aac', channels: 2, tags: { title: 'Mischung' } }, { index: 2, codec_type: 'audio', codec_name: 'aac', channels: 1, tags: { title: 'Mikrofon' } }, { index: 3, codec_type: 'audio', codec_name: 'aac', channels: 2, tags: { title: 'Spiel' } }, ], }); const r = parseProbe(json); assert.equal(r.width, 1920); assert.equal(r.height, 1080); assert.ok(Math.abs(r.durationSeconds - 92.416) < 0.001); assert.equal(r.audio.length, 3); // Wichtig: die Nummerierung fuer -map 0:a:N zaehlt nur die Audiospuren, // nicht die Streams insgesamt assert.deepEqual(r.audio.map((a) => a.index), [0, 1, 2]); assert.equal(r.audio[1]?.title, 'Mikrofon'); }); test('kommt mit einer Datei ohne Ton klar', () => { const r = parseProbe(JSON.stringify({ format: { duration: '10' }, streams: [{ index: 0, codec_type: 'video', width: 1280, height: 720 }], })); assert.equal(r.audio.length, 0); assert.equal(r.width, 1280); });