diff --git a/packages/adapters/src/media/transcription.test.ts b/packages/adapters/src/media/transcription.test.ts index 158acff..d39c55e 100644 --- a/packages/adapters/src/media/transcription.test.ts +++ b/packages/adapters/src/media/transcription.test.ts @@ -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([]); + }); +});