test(media): transcribe real clips through ffmpeg

Generated clips (nothing binary committed) go through extractAudio: a video's
audio reaches the server as 16 kHz mono WAV, a 12-second recording is
uploaded in two ranges under a small limit, and a video without sound is
refused before anything is uploaded. Skipped where ffmpeg is missing, which
may include CI runners.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
This commit is contained in:
Claude 2026-10-03 14:13:12 +00:00
parent a02cbb5374
commit a61aa02a8c
No known key found for this signature in database

View File

@ -4,15 +4,19 @@ import { join } from "node:path";
import type { MediaJobContext, MediaJobInputFile, MediaJobOutputFile } from "@rakazo/adapter-kit";
import { MediaJobDeferredError } from "@rakazo/adapter-kit";
import type { MediaOutput, MediaSettings } from "@rakazo/contracts";
import { afterEach, describe, expect, it } from "vitest";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import { createOpenAiCompatibleTranscriptionProvider } from "../openai-compatible-transcription.js";
import {
EmulatorTranscriptionProvider,
OpenAiCompatibleTranscriptionServerEmulator,
} from "../transcription-emulator.js";
import { detectFfmpeg, runFfmpeg } from "./ffmpeg.js";
import type { TestClips } from "./media-test-support.js";
import { createTestClips, fakeMediaJobContext } from "./media-test-support.js";
import type { TranscriptionAudioTools } from "./transcription.js";
import {
createTranscriptionOperations,
ffmpegTranscriptionAudio,
planAudioChunks,
TRANSCRIPT_SUMMARY_MAX_CHARS,
transcribeChunks,
@ -326,3 +330,69 @@ describe("planAudioChunks", () => {
]);
});
});
const ffmpeg = await detectFfmpeg();
// CI runners without ffmpeg skip these; the unit tests above cover the same paths with fakes.
describe.skipIf(!ffmpeg)("transcribe with ffmpeg", () => {
let clips: TestClips;
let longTone: string;
beforeAll(async () => {
clips = await createTestClips();
longTone = join(clips.dir, "long-tone.m4a");
await runFfmpeg([
...["-f", "lavfi", "-i", "sine=frequency=330:sample_rate=44100:duration=12"],
...["-c:a", "aac", `file:${longTone}`],
]);
});
afterAll(async () => {
if (clips) await rm(clips.dir, { recursive: true, force: true });
});
function ffmpegOperation(maxBytes?: number) {
return operation(TRANSCRIPTION, { audio: ffmpegTranscriptionAudio, maxBytes });
}
it("transcribes a video's audio as 16 kHz mono WAV", async () => {
const { registration, server } = ffmpegOperation();
const job = await fakeMediaJobContext(
{ artifactId: "video-1" },
{ "video-1": { path: clips.landscape, name: "landscape.mp4", mimeType: "video/mp4" } },
);
dirs.push(job.tempDir);
await registration.handler.run(job.context);
// Three seconds at 32,000 bytes per second plus the WAV header.
expect(server.requests).toEqual([
expect.objectContaining({ fileName: "landscape.wav", fileType: "audio/wav" }),
]);
expect(Math.abs(server.requests[0]!.fileSize - 3 * 32_000)).toBeLessThan(2_000);
expect(job.outputs.map((output) => output.name)).toEqual(["landscape.srt", "landscape.vtt"]);
});
it("uploads a long recording in ranges below the server's limit", async () => {
const { registration, server } = ffmpegOperation(1024);
const job = await fakeMediaJobContext(
{ artifactId: "audio-1" },
{ "audio-1": { path: longTone, name: "long-tone.m4a", mimeType: "audio/mp4" } },
);
dirs.push(job.tempDir);
await registration.handler.run(job.context);
expect(server.requests).toHaveLength(2);
expect(Math.abs(server.requests[0]!.fileSize - 10 * 32_000)).toBeLessThan(2_000);
expect(Math.abs(server.requests[1]!.fileSize - 2 * 32_000)).toBeLessThan(2_000);
});
it("refuses a video without sound", async () => {
const { registration, server } = ffmpegOperation();
const job = await fakeMediaJobContext(
{ artifactId: "video-2" },
{ "video-2": { path: clips.silentPortrait, name: "silent.mp4", mimeType: "video/mp4" } },
);
dirs.push(job.tempDir);
await expect(registration.handler.run(job.context)).rejects.toThrow(
"The file has no audio track.",
);
expect(server.requests).toEqual([]);
});
});