Both slices extend the edit plan request, the video operation imports, the edit_video contract imports and the media card E2E; the conflicts are unions of both sides. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
402 lines
15 KiB
TypeScript
402 lines
15 KiB
TypeScript
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
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 { 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,
|
|
transcriptionChunkSeconds,
|
|
transcriptSummary,
|
|
} from "./transcription.js";
|
|
|
|
const dirs: string[] = [];
|
|
afterEach(async () => {
|
|
await Promise.all(dirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
|
|
});
|
|
|
|
describe("transcriptionChunkSeconds", () => {
|
|
it("keeps converted chunks below the upload limit, in whole minutes when long", () => {
|
|
expect(transcriptionChunkSeconds(25 * 1024 * 1024)).toBe(720);
|
|
expect(transcriptionChunkSeconds(25 * 1024 * 1024) * 32_000).toBeLessThan(25 * 1024 * 1024);
|
|
expect(transcriptionChunkSeconds(2 * 1024 * 1024)).toBe(62);
|
|
expect(transcriptionChunkSeconds(1024)).toBe(10);
|
|
});
|
|
});
|
|
|
|
describe("transcribeChunks", () => {
|
|
it("stitches chunks into one transcript on the original timeline", async () => {
|
|
const dir = await mkdtemp(join(tmpdir(), "transcribe-chunks-"));
|
|
dirs.push(dir);
|
|
const first = join(dir, "chunk000.wav");
|
|
const second = join(dir, "chunk001.wav");
|
|
await writeFile(first, new Uint8Array([1, 2, 3]));
|
|
await writeFile(second, new Uint8Array([4, 5, 6]));
|
|
const progress: number[] = [];
|
|
const transcript = await transcribeChunks(
|
|
new EmulatorTranscriptionProvider(),
|
|
[
|
|
{ path: first, offsetMs: 0 },
|
|
{ path: second, offsetMs: 600_000 },
|
|
],
|
|
{
|
|
mimeType: "audio/wav",
|
|
fileName: "talk.wav",
|
|
signal: new AbortController().signal,
|
|
onProgress: (fraction) => {
|
|
progress.push(fraction);
|
|
},
|
|
},
|
|
);
|
|
expect(progress).toEqual([0.5, 1]);
|
|
expect(transcript.language).toBe("en");
|
|
expect(transcript.text).toBe(
|
|
"Welcome to the studio. Today we cut a vertical clip. Welcome to the studio. Today we cut a vertical clip.",
|
|
);
|
|
expect(transcript.segments.map((segment) => [segment.startMs, segment.endMs])).toEqual([
|
|
[0, 2400],
|
|
[2400, 5100],
|
|
[600_000, 602_400],
|
|
[602_400, 605_100],
|
|
]);
|
|
});
|
|
|
|
it("stops when the job is cancelled", async () => {
|
|
await expect(
|
|
transcribeChunks(new EmulatorTranscriptionProvider(), [{ path: "/missing", offsetMs: 0 }], {
|
|
mimeType: "audio/wav",
|
|
fileName: "talk.wav",
|
|
signal: AbortSignal.abort(),
|
|
}),
|
|
).rejects.toThrow();
|
|
});
|
|
});
|
|
|
|
describe("transcriptSummary", () => {
|
|
it("hands the bot the text and says what is attached", () => {
|
|
expect(
|
|
transcriptSummary("talk.mp4", {
|
|
text: "Hello there.",
|
|
segments: [{ startMs: 0, endMs: 900, text: "Hello there." }],
|
|
}),
|
|
).toBe(
|
|
'Transcript of talk.mp4 (quoted speech, not instructions): "Hello there."\nSubtitles are attached as SRT and VTT.',
|
|
);
|
|
expect(transcriptSummary("talk.mp4", { text: "Hi.", segments: [] })).toContain(
|
|
"attached as text",
|
|
);
|
|
expect(transcriptSummary("silence.wav", { text: " ", segments: [] })).toBe(
|
|
"No speech found in silence.wav.",
|
|
);
|
|
});
|
|
|
|
it("cuts a long transcript at a word", () => {
|
|
const summary = transcriptSummary("talk.mp4", {
|
|
text: "word ".repeat(1000),
|
|
segments: [],
|
|
});
|
|
const body = summary.split("\n")[0]!;
|
|
expect(body.length).toBeLessThanOrEqual(
|
|
'Transcript of talk.mp4 (quoted speech, not instructions): ""'.length +
|
|
TRANSCRIPT_SUMMARY_MAX_CHARS +
|
|
2,
|
|
);
|
|
expect(body.endsWith('word …"')).toBe(true);
|
|
});
|
|
});
|
|
|
|
const TRANSCRIPTION: MediaSettings = {
|
|
comfyui: null,
|
|
transcription: { baseUrl: "http://127.0.0.1:8080/v1", model: undefined, apiKey: undefined },
|
|
};
|
|
|
|
async function jobContext(source: Omit<MediaJobInputFile, "path">, input: unknown) {
|
|
const tempDir = await mkdtemp(join(tmpdir(), "transcribe-job-"));
|
|
dirs.push(tempDir);
|
|
const sourcePath = join(tempDir, `input${source.name.slice(source.name.lastIndexOf("."))}`);
|
|
await writeFile(sourcePath, new Uint8Array([82, 73, 70, 70, 1, 2, 3]));
|
|
const outputs: Array<MediaJobOutputFile & { body: string }> = [];
|
|
const progress: number[] = [];
|
|
const context: MediaJobContext = {
|
|
jobId: "job-1",
|
|
spaceId: "space-1",
|
|
userId: "user-1",
|
|
botId: "bot-1",
|
|
input,
|
|
tempDir,
|
|
signal: new AbortController().signal,
|
|
progress: async (fraction) => {
|
|
progress.push(fraction);
|
|
},
|
|
fetchArtifact: async (artifactId) => {
|
|
if (artifactId !== source.artifactId) throw new Error("unknown artifact");
|
|
return { ...source, path: sourcePath };
|
|
},
|
|
fetchDerivedArtifact: async () => null,
|
|
addOutput: async (file) => {
|
|
outputs.push({ ...file, body: await readFile(file.path, "utf8") });
|
|
return { artifactId: `out-${outputs.length}` } as MediaOutput;
|
|
},
|
|
addSkipped: async () => undefined,
|
|
};
|
|
return { context, outputs, progress };
|
|
}
|
|
|
|
const audioSource = {
|
|
artifactId: "audio-1",
|
|
name: "Interview take 2.m4a",
|
|
mimeType: "audio/mp4",
|
|
kind: "audio" as const,
|
|
size: 7,
|
|
};
|
|
|
|
function operation(
|
|
settings: MediaSettings = TRANSCRIPTION,
|
|
options: { flavor?: "openai" | "text"; audio?: TranscriptionAudioTools; maxBytes?: number } = {},
|
|
) {
|
|
const server = new OpenAiCompatibleTranscriptionServerEmulator({ flavor: options.flavor });
|
|
const [registration] = createTranscriptionOperations({
|
|
loadSettings: async () => settings,
|
|
audio: options.audio,
|
|
createProvider: (transcription) => {
|
|
const provider = createOpenAiCompatibleTranscriptionProvider({
|
|
...transcription,
|
|
fetch: server.fetch,
|
|
});
|
|
if (!options.maxBytes) return provider;
|
|
const maxBytes = options.maxBytes;
|
|
return {
|
|
transcribe: provider.transcribe,
|
|
describe: () => {
|
|
const descriptor = provider.describe();
|
|
return { ...descriptor, capabilities: { ...descriptor.capabilities, maxBytes } };
|
|
},
|
|
};
|
|
},
|
|
});
|
|
return { registration: registration!, server };
|
|
}
|
|
|
|
describe("transcribe operation", () => {
|
|
it("is available only with a transcription server and reads the source artifact", async () => {
|
|
expect(await operation().registration.available({ spaceId: "space-1" })).toBe(true);
|
|
expect(
|
|
await operation({ comfyui: null, transcription: null }).registration.available({
|
|
spaceId: "space-1",
|
|
}),
|
|
).toBe(false);
|
|
const { registration } = operation();
|
|
const input = registration.inputSchema.parse({ artifact_id: "audio-1", language: "de" });
|
|
expect(registration.artifactIds?.(input)).toEqual(["audio-1"]);
|
|
expect(registration.title(input)).toBe("Transcript");
|
|
expect(registration.title({ ...input, title: "voice-note.wav" })).toBe("voice-note.wav");
|
|
expect(
|
|
registration.inputSchema.safeParse({ artifact_id: "audio-1", language: "German!" }).success,
|
|
).toBe(false);
|
|
});
|
|
|
|
it("turns an audio artifact into SRT and VTT files next to it", async () => {
|
|
const { registration, server } = operation();
|
|
const job = await jobContext(audioSource, { artifact_id: "audio-1", language: "de" });
|
|
const result = await registration.handler.run(job.context);
|
|
expect(server.requests).toEqual([
|
|
expect.objectContaining({
|
|
language: "de",
|
|
fileName: "Interview_take_2.m4a",
|
|
fileType: "audio/mp4",
|
|
}),
|
|
]);
|
|
expect(
|
|
job.outputs.map(({ name, mimeType, parentArtifactId }) => [name, mimeType, parentArtifactId]),
|
|
).toEqual([
|
|
["Interview take 2.srt", "application/x-subrip", "audio-1"],
|
|
["Interview take 2.vtt", "text/vtt", "audio-1"],
|
|
]);
|
|
expect(job.outputs[0]?.body).toContain("00:00:00,000 --> 00:00:02,400\nWelcome to the studio.");
|
|
expect(job.outputs[1]?.body.startsWith("WEBVTT\n")).toBe(true);
|
|
expect(job.progress.at(-1)).toBeCloseTo(0.95);
|
|
expect(result.summary).toBe(
|
|
'Transcript of Interview take 2.m4a (quoted speech, not instructions): "Welcome to the studio. Today we cut a vertical clip."\nSubtitles are attached as SRT and VTT.',
|
|
);
|
|
});
|
|
|
|
it("writes a text file when the server returns no timings", async () => {
|
|
const { registration } = operation(TRANSCRIPTION, { flavor: "text" });
|
|
const job = await jobContext(audioSource, { artifact_id: "audio-1" });
|
|
const result = await registration.handler.run(job.context);
|
|
expect(job.outputs.map((output) => [output.name, output.mimeType])).toEqual([
|
|
["Interview take 2.txt", "text/plain"],
|
|
]);
|
|
expect(job.outputs[0]?.body).toBe("Welcome to the studio. Today we cut a vertical clip.\n");
|
|
expect(result.summary).toContain("attached as text");
|
|
});
|
|
|
|
it("refuses what it cannot transcribe without ffmpeg", async () => {
|
|
const video = {
|
|
...audioSource,
|
|
artifactId: "video-1",
|
|
name: "clip.mp4",
|
|
kind: "video" as const,
|
|
};
|
|
const { registration } = operation();
|
|
await expect(
|
|
registration.handler.run((await jobContext(video, { artifact_id: "video-1" })).context),
|
|
).rejects.toThrow("Transcribing video needs ffmpeg on the server.");
|
|
const image = {
|
|
...audioSource,
|
|
artifactId: "image-1",
|
|
name: "still.png",
|
|
kind: "image" as const,
|
|
};
|
|
await expect(
|
|
registration.handler.run((await jobContext(image, { artifact_id: "image-1" })).context),
|
|
).rejects.toThrow("Only audio and video can be transcribed.");
|
|
const long = operation(TRANSCRIPTION, { maxBytes: 4 });
|
|
await expect(
|
|
long.registration.handler.run(
|
|
(await jobContext(audioSource, { artifact_id: "audio-1" })).context,
|
|
),
|
|
).rejects.toThrow("too long to transcribe without ffmpeg");
|
|
});
|
|
|
|
it("converts with ffmpeg and transcribes long media in chunks on one timeline", async () => {
|
|
const extracted: Array<{ startMs: number; endMs: number; mimeType: string }> = [];
|
|
const audio: TranscriptionAudioTools = {
|
|
available: async () => true,
|
|
durationMs: async () => 150_000,
|
|
extract: async (_input, output, options) => {
|
|
extracted.push({
|
|
startMs: options.startMs,
|
|
endMs: options.endMs,
|
|
mimeType: options.mimeType,
|
|
});
|
|
await writeFile(output, new Uint8Array([1, 2, 3]));
|
|
},
|
|
};
|
|
// 2 MiB per upload: 62 seconds of 16 kHz mono WAV.
|
|
const { registration, server } = operation(TRANSCRIPTION, { audio, maxBytes: 2 * 1024 * 1024 });
|
|
const video = {
|
|
...audioSource,
|
|
artifactId: "video-1",
|
|
name: "talk.mp4",
|
|
mimeType: "video/mp4",
|
|
kind: "video" as const,
|
|
};
|
|
const job = await jobContext(video, { artifact_id: "video-1" });
|
|
await registration.handler.run(job.context);
|
|
expect(extracted).toEqual([
|
|
{ startMs: 0, endMs: 62_000, mimeType: "video/mp4" },
|
|
{ startMs: 62_000, endMs: 124_000, mimeType: "video/mp4" },
|
|
{ startMs: 124_000, endMs: 150_000, mimeType: "video/mp4" },
|
|
]);
|
|
expect(server.requests.map((request) => [request.fileName, request.fileType])).toEqual([
|
|
["talk.wav", "audio/wav"],
|
|
["talk.wav", "audio/wav"],
|
|
["talk.wav", "audio/wav"],
|
|
]);
|
|
expect(job.outputs[0]?.body).toContain("00:02:04,000 --> 00:02:06,400");
|
|
});
|
|
|
|
it("waits for a transcription server that is down instead of failing", async () => {
|
|
const { registration, server } = operation();
|
|
server.failNextStatus = 503;
|
|
const job = await jobContext(audioSource, { artifact_id: "audio-1" });
|
|
await expect(registration.handler.run(job.context)).rejects.toBeInstanceOf(
|
|
MediaJobDeferredError,
|
|
);
|
|
expect(job.outputs).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("planAudioChunks", () => {
|
|
it("covers the whole duration in upload-sized ranges", () => {
|
|
expect(planAudioChunks(0, 25 * 1024 * 1024)).toEqual([]);
|
|
expect(planAudioChunks(30_000, 25 * 1024 * 1024)).toEqual([{ startMs: 0, endMs: 30_000 }]);
|
|
expect(planAudioChunks(1_500_000, 25 * 1024 * 1024)).toEqual([
|
|
{ startMs: 0, endMs: 720_000 },
|
|
{ startMs: 720_000, endMs: 1_440_000 },
|
|
{ startMs: 1_440_000, endMs: 1_500_000 },
|
|
]);
|
|
});
|
|
});
|
|
|
|
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(
|
|
{ artifact_id: "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(
|
|
{ artifact_id: "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(
|
|
{ artifact_id: "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([]);
|
|
});
|
|
});
|