Merge transcription, subtitles, the Studio specialist and the media card

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:29:36 +00:00
commit 46349acbd1
No known key found for this signature in database
39 changed files with 3157 additions and 14 deletions

View File

@ -157,6 +157,27 @@ describe("consent grants", () => {
expect(JSON.stringify(status)).not.toContain("comfy-key");
expect((await aiConsentStatus(deps, actor, { uses: ["voice"] })).recipients).toHaveLength(1);
});
it("discloses the transcription server for dictation when the voice provider cannot transcribe", async () => {
const { deps, actor } = setup();
vi.mocked(deps.prisma.integrationProviderConfig.findUnique).mockResolvedValue({
id: "media",
ciphertext: "fake-media-ciphertext-voice",
} as never);
deps.secrets = {
load: vi.fn(() =>
JSON.stringify({ comfyui: null, transcription: { baseUrl: "http://127.0.0.1:8080/v1" } }),
),
} as unknown as RouterDeps["secrets"];
vi.mocked(deps.prisma.spaceVoicePreference.findFirst).mockResolvedValue({
credential: { provider: "kokoro" },
voiceId: "af_heart",
} as never);
const status = await aiConsentStatus(deps, actor, { uses: ["voice"] });
expect(status.recipients.map((recipient) => [recipient.name, recipient.use])).toEqual([
["Local voice", "voice"],
["Transcription (http://127.0.0.1:8080)", "voice"],
]);
});
it("returns an operator policy without requiring a hosted provider", async () => {
const { deps, actor } = setup();
deps.env.privacyPolicyUrl = "https://example.com/privacy";

View File

@ -9,6 +9,7 @@ import {
parseModelSecret,
selectConfiguredModel,
toStringRecord,
voiceCatalogEntry,
} from "@rakazo/adapters";
import type { Actor, AiConsentQuery, AiConsentStatus, AiRecipient } from "@rakazo/contracts";
import { AI_DATA_DISCLOSURES, AI_DISCLOSURE_VERSION, AI_PRIVACY_URL } from "@rakazo/contracts";
@ -186,6 +187,21 @@ export async function aiConsentStatus(
}
for (const voice of voices)
add(aiRecipient({ provider: voice.credential.provider, use: "voice" }));
if (uses.includes("voice")) {
// Dictation goes to the owner's transcription server when the voice provider cannot transcribe.
const transcription = (await loadMediaSettings(deps)).transcription;
const credential = transcription ? await findDefaultVoiceCredential(deps.prisma, actor) : null;
if (transcription && !(credential && voiceCatalogEntry(credential.provider)?.transcribe)) {
add(
aiRecipient({
provider: MEDIA_TRANSCRIPTION_PROVIDER_ID,
use: "voice",
baseUrl: transcription.baseUrl,
modelId: transcription.model,
}),
);
}
}
if (includesMemory) {
if (memoryOwner) {
const memory = await deps.prisma.spaceMemoryConfig.findUnique({

View File

@ -0,0 +1,34 @@
import type { MediaCapabilities } from "@rakazo/adapter-kit";
import type { Actor } from "@rakazo/contracts";
import { describe, expect, it } from "vitest";
import type { MediaRouteDeps } from "./media-jobs.js";
import { getMediaCapabilities } from "./media-jobs.js";
const actor: Actor = {
userId: "user-1",
spaceId: "space-1",
email: "user@rakazo.test",
isDeploymentOwner: false,
};
describe("media capabilities", () => {
it("answers for the actor's space", async () => {
const asked: string[] = [];
const deps = {
media: {
capabilities: async (spaceId: string) => {
asked.push(spaceId);
return { transcribe: true, export_video: false } as MediaCapabilities;
},
},
} as unknown as MediaRouteDeps;
await expect(getMediaCapabilities(deps, actor)).resolves.toMatchObject({ transcribe: true });
expect(asked).toEqual(["space-1"]);
});
it("reports nothing available without a media runtime", async () => {
const capabilities = await getMediaCapabilities({} as MediaRouteDeps, actor);
expect(Object.values(capabilities)).toHaveLength(6);
expect(Object.values(capabilities).every((value) => value === false)).toBe(true);
});
});

View File

@ -1,5 +1,5 @@
import { ORPCError } from "@orpc/server";
import type { MediaRuntime } from "@rakazo/adapter-kit";
import type { MediaCapabilities, MediaRuntime } from "@rakazo/adapter-kit";
import type { EncryptedSecretStore } from "@rakazo/adapters";
import {
loadMediaSettings,
@ -9,6 +9,7 @@ import {
saveMediaSettings,
} from "@rakazo/adapters";
import type { Actor, MediaJobStartInput, MediaSettingsUpdate } from "@rakazo/contracts";
import { MediaOperationSchema } from "@rakazo/contracts";
import type { PrismaClient } from "@rakazo/db";
import { resolveThreadTarget } from "./thread-target.js";
@ -92,6 +93,17 @@ export async function startMediaJob(deps: MediaRouteDeps, actor: Actor, input: M
}
}
/** Booleans only: which operations the actor's space can run, never how they are configured. */
export async function getMediaCapabilities(
deps: MediaRouteDeps,
actor: Actor,
): Promise<MediaCapabilities> {
if (deps.media) return deps.media.capabilities(actor.spaceId);
return Object.fromEntries(
MediaOperationSchema.options.map((operation) => [operation, false]),
) as MediaCapabilities;
}
async function ownedJobId(deps: MediaRouteDeps, actor: Actor, jobId: string): Promise<string> {
const job = await deps.prisma.mediaJob.findFirst({
where: { id: jobId, spaceId: actor.spaceId, userId: actor.userId },

View File

@ -169,6 +169,7 @@ import {
import { buildMcpUpdateMaterial } from "./mcp-material.js";
import {
cancelMediaJob,
getMediaCapabilities,
getMediaJob,
getMediaSettings,
startMediaJob,
@ -229,6 +230,7 @@ import {
listVoiceCatalog,
loadDefaultVoiceCredential,
loadVoiceCredential,
mediaTranscriptionSettings,
persistVoiceCredential,
prepareVoice,
toVoiceCredential,
@ -3499,6 +3501,9 @@ export function createRouter(deps: RouterDeps) {
updateMediaSettings(deps, context.actor, input),
),
},
capabilities: authed.media.capabilities.handler(({ context }) =>
getMediaCapabilities(deps, context.actor),
),
jobs: {
start: authed.media.jobs.start.handler(({ context, input }) =>
startMediaJob(deps, context.actor, input),
@ -4814,8 +4819,11 @@ export function createRouter(deps: RouterDeps) {
voice: {
catalog: authed.voice.catalog.handler(async () => listVoiceCatalog()),
status: authed.voice.status.handler(async ({ context }) => {
const cred = await findDefaultVoiceCredential(deps.prisma, context.actor);
return toVoiceStatus(cred);
const [cred, server] = await Promise.all([
findDefaultVoiceCredential(deps.prisma, context.actor),
mediaTranscriptionSettings(deps),
]);
return toVoiceStatus(cred, { mediaTranscription: Boolean(server) });
}),
credentials: authed.voice.credentials.handler(async ({ context }) => {
const rows = await deps.prisma.userVoiceCredential.findMany({
@ -4877,7 +4885,9 @@ export function createRouter(deps: RouterDeps) {
{ isolationLevel: Prisma.TransactionIsolationLevel.Serializable },
),
);
return toVoiceStatus(cred);
return toVoiceStatus(cred, {
mediaTranscription: Boolean(await mediaTranscriptionSettings(deps)),
});
}),
voices: authed.voice.voices.handler(async ({ context, input }) => {
const loaded = await loadDefaultVoiceCredential(deps, context.actor);

View File

@ -0,0 +1,91 @@
import type { Server } from "node:http";
import { createServer } from "node:http";
import type { AddressInfo } from "node:net";
import { NoVoiceConfigured } from "@rakazo/adapters";
import type { Actor } from "@rakazo/contracts";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import type { VoiceDeps } from "./voice.js";
import { toVoiceStatus, transcribeVoice } from "./voice.js";
const actor: Actor = {
userId: "user-1",
spaceId: "space-1",
email: "user@rakazo.test",
isDeploymentOwner: false,
};
/** A user without a voice credential, on a deployment whose media settings are `settings`. */
function deps(settings: object | null, ciphertext: string): VoiceDeps {
return {
prisma: {
spaceVoicePreference: { findFirst: async () => null },
integrationProviderConfig: {
findUnique: async () => (settings ? { id: "media", ciphertext } : null),
},
},
secrets: { load: () => JSON.stringify(settings) },
} as unknown as VoiceDeps;
}
describe("dictation through the self-hosted transcription server", () => {
let server: Server;
let baseUrl = "";
const uploads: string[] = [];
beforeAll(async () => {
server = createServer((request, response) => {
const chunks: Buffer[] = [];
request.on("data", (chunk: Buffer) => chunks.push(chunk));
request.on("end", () => {
uploads.push(`${request.url} ${Buffer.concat(chunks).toString("latin1")}`);
response.setHeader("content-type", "application/json");
response.end(JSON.stringify({ text: " Remind me to call Ada. " }));
});
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}/v1`;
});
afterAll(async () => {
await new Promise((resolve) => server.close(resolve));
});
it("transcribes when no voice provider can", async () => {
const result = await transcribeVoice(
deps({ comfyui: null, transcription: { baseUrl } }, "media-a"),
actor,
{ audio: new Uint8Array([1, 2, 3]), mimeType: "audio/webm;codecs=opus" },
);
expect(result).toEqual({ text: "Remind me to call Ada." });
expect(uploads[0]).toContain("/v1/audio/transcriptions");
expect(uploads[0]).toContain('filename="audio.webm"');
});
it("keeps asking for a voice key when no server is set up either", async () => {
await expect(
transcribeVoice(deps(null, "none"), actor, {
audio: new Uint8Array([1]),
mimeType: "audio/webm",
}),
).rejects.toBeInstanceOf(NoVoiceConfigured);
});
it("refuses an empty recording before uploading it", async () => {
const before = uploads.length;
await expect(
transcribeVoice(deps({ comfyui: null, transcription: { baseUrl } }, "media-b"), actor, {
audio: new Uint8Array(),
mimeType: "audio/webm",
}),
).rejects.toThrow("That recording is empty or too large.");
expect(uploads).toHaveLength(before);
});
it("reports server dictation in the voice status", () => {
expect(toVoiceStatus(null).transcribe).toBe(false);
expect(toVoiceStatus(null, { mediaTranscription: true })).toMatchObject({
configured: false,
transcribe: true,
});
});
});

View File

@ -1,10 +1,12 @@
import { ORPCError } from "@orpc/server";
import type { AdapterContext } from "@rakazo/adapter-kit";
import {
createOpenAiCompatibleTranscriptionProvider,
createVoiceProvider,
type EncryptedSecretStore,
isVoiceProviderId,
listVoiceCatalog,
loadMediaSettings,
MAX_SPEAK_CHARS,
MAX_TRANSCRIBE_BYTES,
NoVoiceConfigured,
@ -51,12 +53,20 @@ export function catalogEntry(provider: string) {
return voiceCatalogEntry(provider);
}
export function toVoiceStatus(cred: { provider: string; voiceId: string } | null): VoiceStatus {
/** The deployment's self-hosted transcription server, when the owner set one up. */
export async function mediaTranscriptionSettings(deps: VoiceDeps) {
return (await loadMediaSettings(deps).catch(() => null))?.transcription ?? null;
}
export function toVoiceStatus(
cred: { provider: string; voiceId: string } | null,
options: { mediaTranscription?: boolean } = {},
): VoiceStatus {
const entry = cred ? catalogEntry(cred.provider) : undefined;
return {
configured: Boolean(cred),
ready: Boolean(cred?.voiceId),
transcribe: Boolean(entry?.transcribe && cred),
transcribe: Boolean(entry?.transcribe && cred) || Boolean(options.mediaTranscription),
provider: cred?.provider ?? null,
voiceId: cred?.voiceId ?? "",
};
@ -277,9 +287,22 @@ export async function transcribeVoice(
input: { audio: Uint8Array; mimeType: string; signal?: AbortSignal },
) {
const loaded = await loadDefaultVoiceCredential(deps, actor);
if (!loaded) throw new NoVoiceConfigured("key");
const provider = createVoiceProvider(loaded.cred.provider);
if (!provider.transcribe) {
const provider = loaded ? createVoiceProvider(loaded.cred.provider) : null;
if (!loaded || !provider?.transcribe) {
// Without a voice provider that transcribes, the self-hosted media server can.
const server = await mediaTranscriptionSettings(deps);
if (server) {
if (input.audio.byteLength === 0 || input.audio.byteLength > MAX_TRANSCRIBE_BYTES) {
throw new ORPCError("BAD_REQUEST", { message: "That recording is empty or too large." });
}
const transcript = await createOpenAiCompatibleTranscriptionProvider(server).transcribe({
audio: input.audio,
mimeType: input.mimeType || "audio/webm",
signal: input.signal,
});
return { text: transcript.text };
}
if (!loaded) throw new NoVoiceConfigured("key");
throw new ORPCError("BAD_REQUEST", {
message: "This voice provider does not transcribe audio. Use on-device dictation instead.",
});

View File

@ -0,0 +1,211 @@
import type { Page } from "@playwright/test";
import { expect, test } from "@playwright/test";
import {
activeBotId,
captureScreenshot,
completeOnboarding,
fetchRpcEnvelope,
rpc,
signup,
} from "./helpers";
type MediaBlock = Record<string, unknown> & { kind: "media" };
type Snapshot = {
botId?: string;
threadId: string;
messages: Array<{ seq: number } & Record<string, unknown>>;
};
/** One second of a quiet 440 Hz tone as 8 kHz mono 16-bit WAV, small enough to inline. */
function toneWavBase64(): string {
const rate = 8000;
const samples = rate;
const buffer = Buffer.alloc(44 + samples * 2);
buffer.write("RIFF", 0);
buffer.writeUInt32LE(36 + samples * 2, 4);
buffer.write("WAVEfmt ", 8);
buffer.writeUInt32LE(16, 16);
buffer.writeUInt16LE(1, 20);
buffer.writeUInt16LE(1, 22);
buffer.writeUInt32LE(rate, 24);
buffer.writeUInt32LE(rate * 2, 28);
buffer.writeUInt16LE(2, 32);
buffer.writeUInt16LE(16, 34);
buffer.write("data", 36);
buffer.writeUInt32LE(samples * 2, 40);
for (let index = 0; index < samples; index += 1) {
buffer.writeInt16LE(
Math.round(Math.sin((2 * Math.PI * 440 * index) / rate) * 3000),
44 + index * 2,
);
}
return buffer.toString("base64");
}
/** A small grey gradient still, drawn by the browser so no binary fixture is needed. */
async function stillPngBase64(page: Page): Promise<string> {
return page.evaluate(() => {
const canvas = document.createElement("canvas");
canvas.width = 320;
canvas.height = 180;
const context = canvas.getContext("2d")!;
const gradient = context.createLinearGradient(0, 0, 320, 180);
gradient.addColorStop(0, "rgb(40, 40, 40)");
gradient.addColorStop(1, "rgb(200, 200, 200)");
context.fillStyle = gradient;
context.fillRect(0, 0, 320, 180);
return canvas.toDataURL("image/png").split(",")[1]!;
});
}
/**
* Media jobs run on a GPU server or ffmpeg that CI does not have, so the card is seeded: real
* artifacts are uploaded, and the bot thread's snapshot (from bootstrap on load, threads/get on
* refresh) carries a bot message with the job's card.
*/
async function seedMediaMessage(page: Page, botId: string, block: () => MediaBlock) {
const seed = (snapshot: Snapshot | undefined) => {
if (snapshot?.botId !== botId) return;
const seq = Math.max(0, ...snapshot.messages.map((message) => message.seq)) + 1;
snapshot.messages.push({
id: `media-message-${seq}`,
threadId: snapshot.threadId,
seq,
role: "bot",
botId,
blocks: [block()],
createdAt: new Date().toISOString(),
});
};
for (const procedure of ["bootstrap", "threads/get"]) {
await page.route(`**/rpc/${procedure}`, async (route) => {
const fetched = await fetchRpcEnvelope(route);
const json = fetched.body.json as (Snapshot & { thread?: Snapshot }) | undefined;
seed(procedure === "bootstrap" ? json?.thread : json);
// Fresh headers: the original content length no longer matches the patched body.
await route.fulfill({
status: fetched.status,
contentType: "application/json",
body: JSON.stringify(fetched.body),
});
});
}
}
test("renders a media job card while it runs and when it is done", async ({ page }, testInfo) => {
const stamp = Date.now();
await signup(page, `media-card-${stamp}@rakazo.test`, "password12", "Media Card");
await completeOnboarding(page);
const botId = activeBotId(page);
const still = await rpc<{ id: string; size: number }>(page, "artifacts/create", {
botId,
name: "still.png",
mimeType: "image/png",
contentBase64: await stillPngBase64(page),
});
const voice = await rpc<{ id: string; size: number }>(page, "artifacts/create", {
botId,
name: "voice-note.wav",
mimeType: "audio/wav",
contentBase64: toneWavBase64(),
});
let state: "running" | "done" = "running";
await seedMediaMessage(page, botId, () =>
state === "running"
? {
kind: "media",
jobId: "media-job-1",
operation: "edit_video",
status: "running",
title: "Cut the pauses and reframe for 9:16",
progress: 0.4,
outputs: [],
}
: {
kind: "media",
jobId: "media-job-1",
operation: "edit_video",
status: "done",
title: "Cut the pauses and reframe for 9:16",
outputs: [
{
artifactId: still.id,
kind: "image",
mimeType: "image/png",
name: "still.png",
size: still.size,
width: 320,
height: 180,
},
{
artifactId: voice.id,
kind: "audio",
mimeType: "audio/wav",
name: "voice-note.wav",
size: voice.size,
durationMs: 1000,
},
],
},
);
// CI has no transcription server; the card asks what the space can run.
await page.route("**/rpc/media/capabilities", (route) =>
route.fulfill({
contentType: "application/json",
body: JSON.stringify({
json: {
generate_image: false,
generate_video: false,
edit_image: false,
edit_video: false,
export_video: true,
transcribe: true,
},
}),
}),
);
const started: unknown[] = [];
await page.route("**/rpc/media/jobs/start", async (route) => {
started.push((route.request().postDataJSON() as { json: unknown }).json);
await route.fulfill({
contentType: "application/json",
body: JSON.stringify({ json: { jobId: "media-job-2", messageId: "media-message-next" } }),
});
});
await page.reload();
const card = page.getByTestId("media-card");
await expect(card).toBeVisible({ timeout: 30_000 });
await expect(card).toHaveAttribute("data-status", "running");
await expect(card).toContainText("Cut the pauses and reframe for 9:16");
await expect(card).toContainText("Working…");
await expect(card.getByRole("progressbar")).toHaveAttribute("aria-valuenow", "40");
await captureScreenshot(page, testInfo, "media-card-running");
state = "done";
await page.reload();
await expect(card).toHaveAttribute("data-status", "done", { timeout: 30_000 });
await expect(card).toContainText("Done");
await expect(card.getByRole("progressbar")).toHaveCount(0);
await expect(card.getByRole("img", { name: "still.png" })).toBeVisible();
await expect(card.locator("audio")).toHaveCount(1);
await expect(card.getByRole("link", { name: "Download voice-note.wav" })).toHaveAttribute(
"download",
"voice-note.wav",
);
await expect(card.getByRole("button", { name: "Transcribe" })).toBeVisible();
// Export applies to video only.
await expect(card.getByRole("button", { name: "Export" })).toHaveCount(0);
await captureScreenshot(page, testInfo, "media-card-done");
await card.getByRole("button", { name: "Transcribe" }).click();
await expect.poll(() => started.length).toBe(1);
expect(started[0]).toEqual({
botId,
messageId: expect.stringMatching(/^media-message-\d+$/),
operation: "transcribe",
input: { artifactId: voice.id, title: "voice-note.wav" },
});
});

View File

@ -0,0 +1,195 @@
import type { MediaJobBlock } from "@rakazo/contracts";
import type { ReactNode } from "react";
import { renderToString } from "react-dom/server";
import { describe, expect, it, vi } from "vitest";
vi.mock("@lingui/react/macro", () => {
const t = (parts: TemplateStringsArray, ...values: unknown[]) =>
parts.reduce((text, part, index) => `${text}${part}${values[index] ?? ""}`, "");
return { useLingui: () => ({ t }), Trans: ({ children }: { children: ReactNode }) => children };
});
vi.mock("../../pages/shell/message-cards", () => ({
ArtifactImage: ({ artifactId, name }: { artifactId: string; name: string }) => (
<img data-artifact={artifactId} alt={name} />
),
}));
vi.mock("../ArtifactFileCard", () => ({
ArtifactFileCard: ({ name }: { name: string }) => <span data-file-card="">{name}</span>,
}));
vi.mock("./MediaPlayer", () => ({
MediaPlayer: ({ artifactId, kind }: { artifactId: string; kind: string }) => (
<span data-player={kind} data-artifact={artifactId} />
),
}));
vi.mock("../../lib/rpc", () => ({ rpc: {}, selectedSpaceId: () => null }));
vi.mock("../../lib/artifact-url", () => ({
artifactUrl: (artifactId: string, options?: { download?: boolean }) =>
`/artifact/${artifactId}${options?.download ? "?download=1" : ""}`,
}));
import { exportPresetsFor, isActiveMediaStatus, MediaCardView } from "./MediaCard";
const target = { groupId: "group-1" };
const video = {
artifactId: "video-1",
kind: "video" as const,
mimeType: "video/mp4",
name: "clip.mp4",
size: 2048,
width: 1920,
height: 1080,
};
function block(overrides: Partial<MediaJobBlock>): MediaJobBlock {
return {
kind: "media",
jobId: "job-1",
operation: "edit_video",
status: "done",
title: "Cut the intro",
outputs: [],
...overrides,
};
}
const everything = { export: true, transcribe: true };
const noop = async () => undefined;
describe("media card", () => {
it.each([
["queued", "Queued"],
["waiting", "Waiting for GPU"],
["running", "Working…"],
["done", "Done"],
["failed", "Failed"],
["cancelled", "Cancelled"],
] as const)("labels a %s job as %s", (status, label) => {
const html = renderToString(<MediaCardView block={block({ status })} target={target} />);
expect(html).toContain(`data-status="${status}"`);
expect(html).toContain(label);
expect(html).toContain("Cut the intro");
});
it("shows progress and a cancel action while the job runs, and no result actions", () => {
const html = renderToString(
<MediaCardView
block={block({ status: "running", progress: 0.42, outputs: [video] })}
target={target}
available={everything}
onAction={noop}
/>,
);
expect(html).toContain('role="progressbar"');
expect(html).toContain('aria-valuenow="42"');
expect(html).toContain("width:42%");
expect(html).toContain("Cancel");
expect(html).not.toContain("Export");
expect(html).not.toContain("Transcribe");
});
it("leaves the progress value out when the backend does not report one", () => {
const html = renderToString(
<MediaCardView block={block({ status: "waiting" })} target={target} />,
);
expect(html).toContain('role="progressbar"');
expect(html).not.toContain("aria-valuenow");
});
it("offers download, export and transcribe for a finished video", () => {
const html = renderToString(
<MediaCardView
block={block({ outputs: [video] })}
target={target}
available={everything}
onAction={noop}
/>,
);
expect(html).toContain('data-player="video"');
expect(html).toContain('href="/artifact/video-1?download=1"');
expect(html).toContain('download="clip.mp4"');
expect(html).toContain('aria-label="Download clip.mp4"');
expect(html).toContain("Export");
expect(html).toContain("Transcribe");
expect(html).not.toContain("progressbar");
expect(html).not.toContain("Cancel");
});
it("offers only what the space can run and what fits the output", () => {
const audio = { ...video, artifactId: "audio-1", kind: "audio" as const, name: "take.m4a" };
const onlyTranscribe = renderToString(
<MediaCardView
block={block({ outputs: [audio] })}
target={target}
available={{ export: true, transcribe: true }}
onAction={noop}
/>,
);
expect(onlyTranscribe).toContain('data-player="audio"');
expect(onlyTranscribe).toContain("Transcribe");
expect(onlyTranscribe).not.toContain("Export");
const nothingAvailable = renderToString(
<MediaCardView block={block({ outputs: [video] })} target={target} onAction={noop} />,
);
expect(nothingAvailable).toContain("Download clip.mp4");
expect(nothingAvailable).not.toContain("Export");
expect(nothingAvailable).not.toContain("Transcribe");
});
it("renders images as thumbnails and subtitle files as file cards", () => {
const html = renderToString(
<MediaCardView
block={block({
operation: "transcribe",
outputs: [
{ ...video, artifactId: "image-1", kind: "image", name: "still.png" },
{ ...video, artifactId: "srt-1", kind: "file", name: "clip.srt" },
{ ...video, artifactId: "vtt-1", kind: "file", name: "clip.vtt" },
],
})}
target={target}
available={everything}
onAction={noop}
/>,
);
expect(html).toContain('data-artifact="image-1"');
expect(html).toContain("clip.srt");
expect(html).toContain("clip.vtt");
expect(html).not.toContain("Transcribe");
});
it("shows why a job failed", () => {
const html = renderToString(
<MediaCardView
block={block({ status: "failed", error: "GPU server is unreachable" })}
target={target}
/>,
);
expect(html).toContain("GPU server is unreachable");
});
});
describe("exportPresetsFor", () => {
it("leaves out the shape the video already has, labelled by aspect ratio elsewhere", () => {
expect(exportPresetsFor({ width: 1920, height: 1080 })).toEqual([
"vertical",
"square",
"portrait",
"classic",
"cinema",
]);
expect(exportPresetsFor({ width: 1080, height: 1920, preset: "vertical" })).not.toContain(
"vertical",
);
expect(exportPresetsFor({})).toHaveLength(6);
});
});
describe("isActiveMediaStatus", () => {
it("treats queued, waiting and running as active", () => {
expect(["queued", "waiting", "running"].every((s) => isActiveMediaStatus(s as never))).toBe(
true,
);
expect(isActiveMediaStatus("done")).toBe(false);
});
});

View File

@ -0,0 +1,390 @@
import { useLingui } from "@lingui/react/macro";
import type {
MediaJobBlock,
MediaJobStatus,
MediaOperation,
MediaOutput,
MediaPresetId,
} from "@rakazo/contracts";
import { MEDIA_JOB_ACTIVE_STATUSES, MEDIA_PRESETS, MediaPresetIdSchema } from "@rakazo/contracts";
import {
Button,
buttonVariants,
cn,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@rakazo/ui-web";
import type { LucideIcon } from "lucide-react";
import { Captions, ChevronDown, Download, Film, ImageIcon, Scissors, Sparkles } from "lucide-react";
import { useEffect, useId, useState } from "react";
import type { ArtifactTarget } from "../../lib/artifact-open";
import { artifactUrl } from "../../lib/artifact-url";
import { rpc, selectedSpaceId } from "../../lib/rpc";
import { ArtifactImage } from "../../pages/shell/message-cards";
import { ArtifactFileCard } from "../ArtifactFileCard";
import { Shimmer } from "../ai/primitives";
import { MediaPlayer } from "./MediaPlayer";
const CARD_CLASS =
"w-[min(480px,96%)] rounded-[18px] border border-border bg-card/75 p-3.5 text-start shadow-xs";
export type MediaCardAction =
| { kind: "export"; artifactId: string; preset: MediaPresetId }
| { kind: "transcribe"; artifactId: string; name: string }
| { kind: "cancel" };
/** Which follow-up jobs the space can run; actions it cannot run are not offered. */
export interface MediaCardAvailability {
export: boolean;
transcribe: boolean;
}
const NOTHING_AVAILABLE: MediaCardAvailability = { export: false, transcribe: false };
const OPERATION_ICONS: Record<MediaOperation, LucideIcon> = {
generate_image: Sparkles,
generate_video: Sparkles,
edit_image: ImageIcon,
edit_video: Scissors,
export_video: Film,
transcribe: Captions,
};
export function isActiveMediaStatus(status: MediaJobStatus): boolean {
return (MEDIA_JOB_ACTIVE_STATUSES as readonly MediaJobStatus[]).includes(status);
}
/** Formats a video can be exported to: every preset except the shape it already has. */
export function exportPresetsFor(output: Pick<MediaOutput, "width" | "height" | "preset">) {
const ratio = output.width && output.height ? output.width / output.height : null;
return MediaPresetIdSchema.options.filter((id) => {
if (id === output.preset) return false;
const preset = MEDIA_PRESETS[id];
return ratio === null || Math.abs(preset.width / preset.height - ratio) > 0.01;
});
}
function StatusLabel({ status }: { status: MediaJobStatus }) {
const { t } = useLingui();
if (status === "running") {
return (
<span className="text-ui-body-sm font-medium">
<Shimmer>{t`Working…`}</Shimmer>
</span>
);
}
const label =
status === "queued"
? t`Queued`
: status === "waiting"
? t`Waiting for GPU`
: status === "done"
? t`Done`
: status === "failed"
? t`Failed`
: t`Cancelled`;
return (
<span
className={cn(
"text-ui-body-sm font-medium",
status === "done"
? "text-success"
: status === "failed"
? "text-destructive"
: "text-muted-foreground",
)}
>
{label}
</span>
);
}
function OutputActions({
output,
available,
busy,
onAction,
}: {
output: MediaOutput;
available: MediaCardAvailability;
busy: boolean;
onAction?: (action: MediaCardAction) => void;
}) {
const { t } = useLingui();
const name = output.name;
const presets = output.kind === "video" ? exportPresetsFor(output) : [];
return (
<div className="flex flex-wrap items-center gap-1.5">
<a
href={artifactUrl(output.artifactId, { download: true })}
download={name}
aria-label={t`Download ${name}`}
title={t`Download ${name}`}
className={cn(
buttonVariants({ variant: "ghost", size: "icon-sm" }),
"text-muted-foreground",
)}
>
<Download />
</a>
{onAction && available.export && presets.length > 0 ? (
<DropdownMenu>
<DropdownMenuTrigger
disabled={busy}
render={<Button variant="outline" size="sm" data-testid="media-export" />}
>
<Film />
{t`Export`}
<ChevronDown />
</DropdownMenuTrigger>
<DropdownMenuContent className="w-auto min-w-24">
{presets.map((preset) => (
<DropdownMenuItem
key={preset}
onClick={() => onAction({ kind: "export", artifactId: output.artifactId, preset })}
>
{MEDIA_PRESETS[preset].aspect}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
) : null}
{onAction && available.transcribe && (output.kind === "video" || output.kind === "audio") ? (
<Button
variant="outline"
size="sm"
disabled={busy}
onClick={() =>
onAction({ kind: "transcribe", artifactId: output.artifactId, name: output.name })
}
>
<Captions />
{t`Transcribe`}
</Button>
) : null}
</div>
);
}
/**
* The live card of a media job: what it makes, how far it is, and the results with the
* follow-ups that fit them (export other formats, transcribe). Nothing else is shown.
*/
export function MediaCardView({
block,
target,
available = NOTHING_AVAILABLE,
onAction,
}: {
block: MediaJobBlock;
target: ArtifactTarget;
available?: MediaCardAvailability;
/** Starts a follow-up job or cancels this one; rejects with a message to show. */
onAction?: (action: MediaCardAction) => Promise<void>;
}) {
const { t } = useLingui();
const titleId = useId();
const [busy, setBusy] = useState(false);
const [actionError, setActionError] = useState<string | null>(null);
const active = isActiveMediaStatus(block.status);
const Icon = OPERATION_ICONS[block.operation];
const percent = Math.round((block.progress ?? 0) * 100);
const images = block.outputs.filter((output) => output.kind === "image");
const others = block.outputs.filter((output) => output.kind !== "image");
const act = onAction
? (action: MediaCardAction) => {
setBusy(true);
setActionError(null);
void onAction(action)
.catch((error: unknown) =>
setActionError(error instanceof Error && error.message ? error.message : t`Failed`),
)
.finally(() => setBusy(false));
}
: undefined;
return (
<div className="my-2 flex justify-start">
<div data-testid="media-card" data-status={block.status} className={CARD_CLASS}>
<div className="flex items-center justify-between gap-2">
<span className="flex min-w-0 items-center gap-2.5">
<Icon aria-hidden="true" className="size-icon-md shrink-0 text-muted-foreground" />
<span
id={titleId}
className="truncate text-ui-body font-medium text-foreground"
dir="auto"
title={block.title}
>
{block.title}
</span>
</span>
<span className="flex shrink-0 items-center gap-1.5">
<StatusLabel status={block.status} />
{active && act ? (
<Button
variant="ghost"
size="xs"
disabled={busy}
onClick={() => act({ kind: "cancel" })}
>
{t`Cancel`}
</Button>
) : null}
</span>
</div>
{active ? (
<div
role="progressbar"
aria-labelledby={titleId}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={block.progress === undefined ? undefined : percent}
className="mt-2.5 h-1 overflow-hidden rounded-full bg-muted"
>
<div
className={cn(
"h-full rounded-full bg-primary transition-[width] duration-500",
block.progress === undefined && "w-1/3 animate-pulse",
)}
style={block.progress === undefined ? undefined : { width: `${percent}%` }}
/>
</div>
) : null}
{block.status === "failed" && block.error ? (
<p className="mt-2 text-ui-body-sm text-destructive" dir="auto">
{block.error}
</p>
) : null}
{images.length > 0 ? (
<div className="mt-3 flex flex-wrap gap-2">
{images.map((output) => (
<ArtifactImage
key={output.artifactId}
target={target}
artifactId={output.artifactId}
name={output.name}
/>
))}
</div>
) : null}
{others.map((output) => (
<div key={output.artifactId} className="mt-3 flex flex-col gap-1.5">
{output.kind === "video" || output.kind === "audio" ? (
<>
<MediaPlayer
artifactId={output.artifactId}
kind={output.kind}
name={output.name}
posterArtifactId={output.posterArtifactId}
width={output.width}
height={output.height}
/>
{active ? null : (
<OutputActions output={output} available={available} busy={busy} onAction={act} />
)}
</>
) : (
<ArtifactFileCard
target={target}
artifactId={output.artifactId}
name={output.name}
mimeType={output.mimeType}
size={output.size}
/>
)}
</div>
))}
{actionError ? (
<p role="alert" className="mt-2 text-ui-body-sm text-destructive" dir="auto">
{actionError}
</p>
) : null}
</div>
</div>
);
}
const CAPABILITIES_TTL_MS = 60_000;
let capabilities:
| { spaceId: string | null; at: number; value: Promise<MediaCardAvailability> }
| undefined;
/** What the space can run, shared by every card on screen and refreshed after a minute. */
function loadAvailability(): Promise<MediaCardAvailability> {
const spaceId = selectedSpaceId();
if (
!capabilities ||
capabilities.spaceId !== spaceId ||
Date.now() - capabilities.at > CAPABILITIES_TTL_MS
) {
capabilities = {
spaceId,
at: Date.now(),
value: rpc.media
.capabilities()
.then((value) => ({
export: value.export_video === true,
transcribe: value.transcribe === true,
}))
.catch(() => NOTHING_AVAILABLE),
};
}
return capabilities.value;
}
function useAvailability(enabled: boolean): MediaCardAvailability {
const [available, setAvailable] = useState(NOTHING_AVAILABLE);
useEffect(() => {
if (!enabled) return;
let current = true;
void loadAvailability().then((value) => {
if (current) setAvailable(value);
});
return () => {
current = false;
};
}, [enabled]);
return available;
}
/** The media card in a conversation, with its follow-up actions wired to the media job RPCs. */
export function MediaCard({
block,
target,
messageId,
}: {
block: MediaJobBlock;
target: ArtifactTarget;
/** The message carrying the card; a follow-up job is posted by the same bot. */
messageId: string;
}) {
// Follow-ups apply to video and audio of a settled job only; other cards never ask.
const available = useAvailability(
!isActiveMediaStatus(block.status) &&
block.outputs.some((output) => output.kind === "video" || output.kind === "audio"),
);
const onAction = async (action: MediaCardAction) => {
if (action.kind === "cancel") {
await rpc.media.jobs.cancel({ jobId: block.jobId });
return;
}
await rpc.media.jobs.start({
...target,
messageId,
...(action.kind === "export"
? {
operation: "export_video",
input: { artifact_id: action.artifactId, presets: [action.preset] },
}
: // The new card is titled with the file it transcribes.
{
operation: "transcribe",
input: { artifactId: action.artifactId, title: action.name },
}),
});
};
return <MediaCardView block={block} target={target} available={available} onAction={onAction} />;
}

View File

@ -168,6 +168,7 @@ import { ComputerUpdateProgress } from "../components/ComputerUpdateProgress";
import { DataTrustPanel } from "../components/DataTrustPanel";
import { MainInbox } from "../components/MainInbox";
import { MessageHoverMetadata } from "../components/MessageHoverMetadata";
import { MediaCard } from "../components/media/MediaCard";
import { MediaPlayer } from "../components/media/MediaPlayer";
import { OoGroupStage, OoStage } from "../components/stage/OoStage";
import { TradingModeIndicator } from "../components/TradingModeIndicator";
@ -7976,6 +7977,9 @@ const MessageView = memo(function MessageView({
);
}
if (block.kind === "cloud_agent") return <CloudAgentCard key={i} block={block} />;
if (block.kind === "media") {
return <MediaCard key={i} block={block} target={artifactTarget} messageId={message.id} />;
}
if (block.kind === "skill_draft") {
return (
<div key={i} className="flex justify-start">

View File

@ -52,6 +52,8 @@ export function aiRecipient(input: {
}
function recipientName(provider: string, use: AiDataUse) {
// The transcription server also takes dictation, so it keeps its name for voice use.
if (provider === MEDIA_TRANSCRIPTION_PROVIDER_ID) return "Transcription";
if (use === "voice") return voiceCatalogEntry(provider)?.name ?? provider;
if (use === "memory") {
if (provider === "supermemory") return "Supermemory";
@ -61,6 +63,5 @@ function recipientName(provider: string, use: AiDataUse) {
}
if (provider === "cursor") return "Cursor";
if (provider === MEDIA_COMFYUI_PROVIDER_ID) return "ComfyUI";
if (provider === MEDIA_TRANSCRIPTION_PROVIDER_ID) return "Transcription";
return listPiCatalog().find((entry) => entry.provider === provider)?.providerName ?? provider;
}

View File

@ -10,6 +10,7 @@ import {
MODEL_TIERS,
SecretAskPurpose,
SecretHttpRequest,
TranscribeInputSchema,
USER_FACT_CATEGORIES,
} from "@rakazo/contracts";
import { z } from "zod";
@ -372,6 +373,12 @@ export const builtinAgentTools: ConnectorTool[] = [
description: `Export a video artifact as one H.264/AAC MP4 per preset. ${VIDEO_PRESETS_AND_FITS} For cuts, speed, audio or subtitles use edit_video.`,
inputSchema: mediaParameters(ExportVideoInputSchema),
},
{
name: "transcribe",
description:
"Transcribe speech in an audio or video artifact into SRT and VTT subtitle files, shown on a media card. Set continue_after to get the transcript back, e.g. to burn the .srt in with edit_video.",
inputSchema: mediaParameters(TranscribeInputSchema),
},
{
name: "add_mcp_server",
description:

View File

@ -295,7 +295,9 @@ describe("destroyBot", () => {
const deleteArtifacts = vi.fn().mockResolvedValue({ count: 1 });
const transaction = vi.fn(async (callback: (tx: unknown) => Promise<void>) =>
callback({
$queryRaw: vi.fn().mockResolvedValue([]),
$queryRaw: vi
.fn()
.mockResolvedValue([{ id: "bot-1", webhookSecretId: null, spawnKey: "spawn-1" }]),
chatGroup: {
findMany: vi.fn().mockResolvedValue([]),
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
@ -355,6 +357,7 @@ describe("destroyBot", () => {
id: "bot-1",
spaceId: "workspace-1",
name: "Researcher",
spawnKey: "spawn-1",
deletedByUserId: "user-1",
memoriesPreserved: true,
},

View File

@ -370,8 +370,10 @@ export async function destroyBot(
await removePiBotSessions(deps.dataDir, bot.userId, bot.id);
const deletion = await withTransactionRetry(() =>
deps.prisma.$transaction(async (tx) => {
const locked = await tx.$queryRaw<Array<{ id: string; webhookSecretId: string | null }>>`
SELECT id, "webhookSecretId"
const locked = await tx.$queryRaw<
Array<{ id: string; webhookSecretId: string | null; spawnKey: string | null }>
>`
SELECT id, "webhookSecretId", "spawnKey"
FROM bots
WHERE id = ${bot.id} AND "spaceId" = ${bot.spaceId}
FOR UPDATE
@ -409,6 +411,7 @@ export async function destroyBot(
id: bot.id,
spaceId: bot.spaceId,
name: bot.name,
spawnKey: locked[0]?.spawnKey ?? null,
deletedByUserId: context.userId,
memoriesPreserved: !options.deleteMemories,
},

View File

@ -359,6 +359,7 @@ import {
skillReadFromTool,
skillUpdateFromTool,
} from "./skill-tools.js";
import { ensureStudioWhenMediaConfigured } from "./studio-specialist.js";
import {
continueRunClaimFence,
DESKTOP_HELD_FOR_TAKEOVER_MESSAGE,
@ -3649,6 +3650,8 @@ export function createRunExecutor(deps: ExecutorDeps) {
});
}
const runtimeHistory = [...historicalContext, ...history];
// Studio joins the directory once a media server is set up.
if (exposedToolNames.has("delegate_task")) await ensureStudioWhenMediaConfigured(deps, run);
// The main agent sees every specialist it can delegate to, across the user's spaces.
// Other runs see the teammates they can address with message_bot; runs without it
// (every group run) list their reachable members in <group_members> instead.

View File

@ -102,6 +102,7 @@ export * from "./model-selection.js";
export * from "./model-vision.js";
export * from "./none-sandbox.js";
export * from "./openai-compatible-embeddings.js";
export * from "./openai-compatible-transcription.js";
export * from "./openai-compatible-url.js";
export * from "./openai-voice.js";
export * from "./page-browser-session.js";
@ -136,6 +137,7 @@ export { SupermemoryMemoryProvider } from "./supermemory-memory-provider.js";
export * from "./teaching-session.js";
export * from "./team-chat-messaging.js";
export * from "./third-party-connector-emulator.js";
export * from "./transcription-emulator.js";
export * from "./voice-factory.js";
export * from "./wakeup.js";
export * from "./web-limits.js";

View File

@ -1,5 +1,6 @@
import type { MediaSettings } from "@rakazo/contracts";
import { createImageOperations } from "./media/image-operations.js";
import { createTranscriptionOperations, ffmpegTranscriptionAudio } from "./media/transcription.js";
import { createVideoOperations } from "./media/video-operations.js";
import type { MediaOperationRegistration } from "./media-runtime.js";
@ -13,5 +14,12 @@ export interface MediaOperationDeps {
* from this one list, so both processes run the same operations.
*/
export function createMediaOperations(deps: MediaOperationDeps): MediaOperationRegistration[] {
return [...createImageOperations(deps), ...createVideoOperations()];
return [
...createImageOperations(deps),
...createVideoOperations(),
...createTranscriptionOperations({
loadSettings: deps.loadSettings,
audio: ffmpegTranscriptionAudio,
}),
];
}

View File

@ -0,0 +1,169 @@
import { randomUUID } from "node:crypto";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import type { AdapterContext, JobPublisher } from "@rakazo/adapter-kit";
import type { MediaJobBlock, MediaSettings, MessageBlock } from "@rakazo/contracts";
import type { PrismaClient } from "@rakazo/db";
import { createDb } from "@rakazo/db";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { storeArtifactFromFile } from "../artifact-files.js";
import { LocalArtifactStore } from "../artifacts.js";
import type { MediaOperationRegistration } from "../media-runtime.js";
import { createMediaRuntime } from "../media-runtime.js";
import { createOpenAiCompatibleTranscriptionProvider } from "../openai-compatible-transcription.js";
import { OpenAiCompatibleTranscriptionServerEmulator } from "../transcription-emulator.js";
import { createTranscriptionOperations } from "./transcription.js";
const describePostgres =
process.env.VERIFY_DATABASE && process.env.DATABASE_URL ? describe.sequential : describe.skip;
const WAV = new Uint8Array([82, 73, 70, 70, 36, 0, 0, 0, 87, 65, 86, 69, 1, 2, 3, 4]);
const SETTINGS: MediaSettings = {
comfyui: null,
transcription: { baseUrl: "http://127.0.0.1:8080/v1", model: undefined, apiKey: undefined },
};
describePostgres("transcribe through the media runtime (PostgreSQL)", () => {
let prisma: PrismaClient;
let db: ReturnType<typeof createDb>;
let root: string;
const users: string[] = [];
beforeAll(async () => {
db = createDb(process.env.DATABASE_URL!);
prisma = db.prisma;
root = await mkdtemp(path.join(tmpdir(), "transcribe-runtime-test-"));
});
afterAll(async () => {
if (!db) return;
await prisma.organization.deleteMany({ where: { id: { in: users } } });
await prisma.user.deleteMany({ where: { id: { in: users } } });
await prisma.$disconnect();
await db.pool.end();
await rm(root, { recursive: true, force: true });
});
async function setup() {
const id = randomUUID();
users.push(id);
await prisma.user.create({
data: { id, name: "Transcribe test", email: `${id}@example.test`, emailVerified: true },
});
await prisma.organization.create({
data: { id, name: "Transcribe test", slug: id, createdAt: new Date() },
});
await prisma.space.create({
data: { id, organizationId: id, name: "Transcribe test", isDefault: true },
});
await prisma.member.create({
data: { id, organizationId: id, userId: id, role: "owner", createdAt: new Date() },
});
const bot = await prisma.bot.create({
data: { spaceId: id, userId: id, name: "Studio", color: "test-color" },
});
const thread = await prisma.thread.create({ data: { spaceId: id, userId: id, botId: bot.id } });
const artifacts = new LocalArtifactStore(path.join(root, id));
const jobs: JobPublisher = {
enqueue: async () => undefined,
cancel: async () => undefined,
close: async () => undefined,
};
const server = new OpenAiCompatibleTranscriptionServerEmulator();
const runtime = createMediaRuntime(
{
prisma,
events: { notify: async () => undefined },
jobs,
artifacts,
registrations: createTranscriptionOperations({
loadSettings: async () => SETTINGS,
createProvider: (settings) =>
createOpenAiCompatibleTranscriptionProvider({ ...settings, fetch: server.fetch }),
}) as MediaOperationRegistration[],
},
{ tempRoot: path.join(root, id, "tmp"), heartbeatMs: 50, progressIntervalMs: 0 },
);
const file = path.join(root, `${randomUUID()}.wav`);
await writeFile(file, WAV);
const source = await storeArtifactFromFile(
{ prisma, artifacts },
{
path: file,
name: "voice note.wav",
mimeType: "audio/wav",
spaceId: id,
userId: id,
botId: bot.id,
groupId: null,
runId: null,
},
);
const start = (input: Record<string, unknown>) =>
runtime.start({
operation: "transcribe",
input,
spaceId: id,
userId: id,
botId: bot.id,
groupId: null,
threadId: thread.id,
runId: null,
});
const card = async (jobId: string): Promise<MediaJobBlock> => {
const job = await prisma.mediaJob.findUniqueOrThrow({ where: { id: jobId } });
const message = await prisma.message.findUniqueOrThrow({ where: { id: job.messageId! } });
return (message.blocks as MessageBlock[]).find(
(block): block is MediaJobBlock => block.kind === "media",
)!;
};
const context: AdapterContext = {
operationId: "test",
traceId: "test",
spaceId: id,
userId: id,
signal: new AbortController().signal,
};
return { id, runtime, server, source, start, card, artifacts, context };
}
it("turns an audio artifact into subtitle files on the media card", async () => {
const ctx = await setup();
expect((await ctx.runtime.capabilities(ctx.id)).transcribe).toBe(true);
const started = await ctx.start({ artifactId: ctx.source.id });
expect(await ctx.card(started.jobId)).toMatchObject({
operation: "transcribe",
status: "queued",
title: "Transcript",
});
await ctx.runtime.runJob(started.jobId);
const job = await prisma.mediaJob.findUniqueOrThrow({ where: { id: started.jobId } });
expect(job.status).toBe("done");
const block = await ctx.card(started.jobId);
expect(block.status).toBe("done");
expect(block.outputs.map(({ kind, name, mimeType }) => [kind, name, mimeType])).toEqual([
["file", "voice note.srt", "application/x-subrip"],
["file", "voice note.vtt", "text/vtt"],
]);
expect(block.outputs.every((output) => output.parentArtifactId === ctx.source.id)).toBe(true);
expect(ctx.server.requests).toEqual([
expect.objectContaining({ fileName: "voice_note.wav", fileType: "audio/wav" }),
]);
const vtt = await prisma.artifact.findUniqueOrThrow({
where: { id: block.outputs[1]!.artifactId },
});
const body = new TextDecoder().decode(await ctx.artifacts.get(vtt.storageKey, ctx.context));
expect(body).toBe(
"WEBVTT\n\n00:00:00.000 --> 00:00:02.400\nWelcome to the studio.\n\n00:00:02.400 --> 00:00:05.100\nToday we cut a vertical clip.\n",
);
});
it("refuses an artifact of another user before a job exists", async () => {
const owner = await setup();
const other = await setup();
await expect(other.start({ artifactId: owner.source.id })).rejects.toThrow("Unknown artifact.");
expect(await prisma.mediaJob.count({ where: { userId: other.id } })).toBe(0);
});
});

View File

@ -0,0 +1,399 @@
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 };
},
addOutput: async (file) => {
outputs.push({ ...file, body: await readFile(file.path, "utf8") });
return { artifactId: `out-${outputs.length}` } as MediaOutput;
},
};
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({ artifactId: "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({ artifactId: "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, { artifactId: "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, { artifactId: "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, { artifactId: "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, { artifactId: "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, { artifactId: "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, { artifactId: "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, { artifactId: "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(
{ 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([]);
});
});

View File

@ -0,0 +1,259 @@
import { readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import type { Transcript, TranscriptionProvider, TranscriptSegment } from "@rakazo/adapter-kit";
import { MediaJobDeferredError } from "@rakazo/adapter-kit";
import type { MediaSettings, TranscribeInput, TranscriptionSettings } from "@rakazo/contracts";
import { TranscribeInputSchema } from "@rakazo/contracts";
import { toSrt, toVtt } from "@rakazo/core";
import type { MediaOperationRegistration } from "../media-runtime.js";
import { MediaJobRejectedError } from "../media-runtime.js";
import {
createOpenAiCompatibleTranscriptionProvider,
TranscriptionServerUnavailableError,
} from "../openai-compatible-transcription.js";
import { detectFfmpeg, extractAudio, probeMedia } from "./ffmpeg.js";
/** Bytes per second of the 16 kHz mono 16-bit WAV that audio is converted to before upload. */
export const TRANSCRIPTION_WAV_BYTES_PER_SECOND = 16_000 * 2;
/** How much of the transcript the bot that started the job gets back. */
export const TRANSCRIPT_SUMMARY_MAX_CHARS = 1200;
/**
* Seconds of converted audio per upload, so each chunk stays below the server's size limit
* with room for the WAV header. Rounded down to whole minutes when that is possible.
*/
export function transcriptionChunkSeconds(maxBytes: number): number {
const seconds = Math.floor((maxBytes * 0.95) / TRANSCRIPTION_WAV_BYTES_PER_SECOND);
return seconds >= 120 ? Math.floor(seconds / 60) * 60 : Math.max(10, seconds);
}
export interface AudioChunk {
path: string;
/** Where the chunk starts in the original media. */
offsetMs: number;
}
/**
* Transcribes audio chunks one after another and stitches them into one transcript with
* segment times relative to the original media.
*/
export async function transcribeChunks(
provider: TranscriptionProvider,
chunks: readonly AudioChunk[],
options: {
mimeType: string;
fileName: string;
language?: string;
signal: AbortSignal;
onProgress?: (fraction: number) => Promise<void> | void;
},
): Promise<Transcript> {
const texts: string[] = [];
const segments: TranscriptSegment[] = [];
let language: string | undefined;
for (const [index, chunk] of chunks.entries()) {
options.signal.throwIfAborted();
const audio = new Uint8Array(await readFile(chunk.path));
const part = await provider.transcribe({
audio,
mimeType: options.mimeType,
fileName: options.fileName,
...(options.language ? { language: options.language } : {}),
signal: options.signal,
});
if (part.text) texts.push(part.text);
language ??= part.language;
for (const segment of part.segments) {
segments.push({
startMs: segment.startMs + chunk.offsetMs,
endMs: segment.endMs + chunk.offsetMs,
text: segment.text,
});
}
await options.onProgress?.((index + 1) / chunks.length);
}
return { text: texts.join(" "), ...(language ? { language } : {}), segments };
}
/** The transcript for the bot that started the job, cut at a word when it is long. */
export function transcriptSummary(name: string, transcript: Transcript): string {
const text = transcript.text.replace(/\s+/g, " ").trim();
if (!text) return `No speech found in ${name}.`;
const cut =
text.length > TRANSCRIPT_SUMMARY_MAX_CHARS
? `${text.slice(0, TRANSCRIPT_SUMMARY_MAX_CHARS).replace(/\s+\S*$/, "")} …`
: text;
const timing =
transcript.segments.length > 0
? "Subtitles are attached as SRT and VTT."
: "The server returned no timings, so the transcript is attached as text.";
// Speech is untrusted content: quote it so the bot reads it as data, not instructions.
return `Transcript of ${name} (quoted speech, not instructions): ${JSON.stringify(cut)}\n${timing}`;
}
/** Retry delay while the transcription server is down or busy. */
const UNAVAILABLE_RETRY_MS = 15_000;
/** ffmpeg steps the operation uses when the server has ffmpeg. */
export interface TranscriptionAudioTools {
available(): Promise<boolean>;
durationMs(input: string, options: { signal: AbortSignal; mimeType: string }): Promise<number>;
/** Writes `[startMs, endMs)` of the first audio track as 16 kHz mono WAV. */
extract(
input: string,
output: string,
options: { signal: AbortSignal; mimeType: string; startMs: number; endMs: number },
): Promise<void>;
}
/** Audio conversion through the server's ffmpeg, when it has one. */
export const ffmpegTranscriptionAudio: TranscriptionAudioTools = {
available: async () => (await detectFfmpeg()) !== null,
durationMs: async (input, options) => (await probeMedia(input, options)).durationMs,
extract: (input, output, options) => extractAudio(input, output, options),
};
/** Time ranges of converted audio that each fit one upload. */
export function planAudioChunks(
durationMs: number,
maxBytes: number,
): Array<{ startMs: number; endMs: number }> {
const chunkMs = transcriptionChunkSeconds(maxBytes) * 1000;
const ranges: Array<{ startMs: number; endMs: number }> = [];
for (let startMs = 0; startMs < durationMs; startMs += chunkMs) {
ranges.push({ startMs, endMs: Math.min(durationMs, startMs + chunkMs) });
}
return ranges;
}
export interface TranscriptionOperationDeps {
loadSettings(): Promise<MediaSettings>;
/** Without ffmpeg, audio is sent as stored and video cannot be transcribed. */
audio?: TranscriptionAudioTools;
/** The client for the configured server; tests pass one backed by an emulator. */
createProvider?(settings: TranscriptionSettings): TranscriptionProvider;
}
/**
* `transcribe`: speech in an audio or video artifact becomes SRT and VTT subtitle files (or a
* text file when the server returns no timings), and the bot gets the transcript back.
*/
export function createTranscriptionOperations(
deps: TranscriptionOperationDeps,
): MediaOperationRegistration<TranscribeInput>[] {
const createProvider = deps.createProvider ?? createOpenAiCompatibleTranscriptionProvider;
return [
{
operation: "transcribe",
inputSchema: TranscribeInputSchema,
title: (input) => input.title ?? "Transcript",
available: async () => Boolean((await deps.loadSettings()).transcription),
artifactIds: (input) => [input.artifactId],
handler: {
operation: "transcribe",
async run(context) {
const input = TranscribeInputSchema.parse(context.input);
const settings = (await deps.loadSettings()).transcription;
if (!settings) throw new MediaJobRejectedError("Transcription is not set up.");
const provider = createProvider(settings);
const source = await context.fetchArtifact(input.artifactId);
if (source.kind !== "audio" && source.kind !== "video") {
throw new MediaJobRejectedError("Only audio and video can be transcribed.");
}
const audio = await prepareAudio(deps.audio, provider, source, context);
await context.progress(0.05);
let transcript: Transcript;
try {
transcript = await transcribeChunks(provider, audio.chunks, {
mimeType: audio.mimeType,
fileName: audio.fileName,
...(input.language ? { language: input.language } : {}),
signal: context.signal,
onProgress: (fraction) => context.progress(0.05 + fraction * 0.9),
});
} catch (error) {
// Nothing is stored yet, so the job can wait for the server to come back.
if (error instanceof TranscriptionServerUnavailableError) {
throw new MediaJobDeferredError(error.message, UNAVAILABLE_RETRY_MS);
}
throw error;
}
if (!transcript.text.trim() && transcript.segments.length === 0) {
throw new MediaJobRejectedError(`No speech found in ${source.name}.`);
}
const base = path.parse(source.name).name || "transcript";
const files =
transcript.segments.length > 0
? [
{
name: `${base}.srt`,
mimeType: "application/x-subrip",
body: toSrt(transcript.segments),
},
{ name: `${base}.vtt`, mimeType: "text/vtt", body: toVtt(transcript.segments) },
]
: [
{
name: `${base}.txt`,
mimeType: "text/plain",
body: `${transcript.text.trim()}\n`,
},
];
for (const [index, file] of files.entries()) {
const filePath = path.join(
context.tempDir,
`transcript-${index}${path.extname(file.name)}`,
);
await writeFile(filePath, file.body, { mode: 0o600 });
await context.addOutput({
path: filePath,
name: file.name,
mimeType: file.mimeType,
parentArtifactId: source.artifactId,
});
}
return { summary: transcriptSummary(source.name, transcript) };
},
},
},
];
}
async function prepareAudio(
tools: TranscriptionAudioTools | undefined,
provider: TranscriptionProvider,
source: { path: string; name: string; mimeType: string; kind: string; size: number },
context: { tempDir: string; signal: AbortSignal },
): Promise<{ chunks: AudioChunk[]; mimeType: string; fileName: string }> {
const maxBytes = provider.describe().capabilities.maxBytes;
if (!tools || !(await tools.available())) {
if (source.kind === "video") {
throw new MediaJobRejectedError("Transcribing video needs ffmpeg on the server.");
}
if (source.size > maxBytes) {
throw new MediaJobRejectedError(
"This recording is too long to transcribe without ffmpeg on the server.",
);
}
return {
chunks: [{ path: source.path, offsetMs: 0 }],
mimeType: source.mimeType,
fileName: source.name,
};
}
// Whisper servers expect 16 kHz mono; converting also keeps long files within upload limits.
const options = { signal: context.signal, mimeType: source.mimeType };
const durationMs = await tools.durationMs(source.path, options);
const chunks: AudioChunk[] = [];
for (const [index, range] of planAudioChunks(durationMs, maxBytes).entries()) {
const chunkPath = path.join(context.tempDir, `speech-${index}.wav`);
await tools.extract(source.path, chunkPath, { ...options, ...range });
chunks.push({ path: chunkPath, offsetMs: range.startMs });
}
if (chunks.length === 0) throw new MediaJobRejectedError(`No speech found in ${source.name}.`);
return {
chunks,
mimeType: "audio/wav",
fileName: `${path.parse(source.name).name || "speech"}.wav`,
};
}

View File

@ -0,0 +1,320 @@
import { randomUUID } from "node:crypto";
import type {
AdapterDescriptor,
Transcript,
TranscriptionCapabilities,
TranscriptionProvider,
TranscriptionRequest,
TranscriptSegment,
} from "@rakazo/adapter-kit";
import {
assertAllowedOpenAiCompatibleUrl,
assertHttpsForKeyedOpenAiCompatibleUrl,
OPENAI_COMPATIBLE_PROVIDER_ID,
} from "./openai-compatible-url.js";
import { createOpenAiCompatibleFetch } from "./pi-openai-compatible-provider.js";
import { readBodyCapped } from "./web-ssrf.js";
/** The OpenAI upload limit; whisper.cpp and faster-whisper servers accept at least this much. */
export const OPENAI_COMPATIBLE_TRANSCRIPTION_MAX_BYTES = 25 * 1024 * 1024;
/** Used when no model is configured; whisper.cpp ignores it and most servers alias it. */
export const OPENAI_COMPATIBLE_TRANSCRIPTION_DEFAULT_MODEL = "whisper-1";
// CPU-only whisper servers need minutes for a long upload; the job's signal still cancels sooner.
const TRANSCRIPTION_TIMEOUT_MS = 15 * 60_000;
const MAX_TRANSCRIPTION_RESPONSE_BYTES = 8 * 1024 * 1024;
/** The server is down, restarting or busy; trying again later may succeed. */
export class TranscriptionServerUnavailableError extends Error {
constructor(message: string) {
super(message);
this.name = "TranscriptionServerUnavailableError";
}
}
export interface OpenAiCompatibleTranscriptionConfig {
baseUrl: string;
model?: string;
apiKey?: string;
fetch?: typeof globalThis.fetch;
}
type ResponseFormat = "verbose_json" | "json";
/**
* Speech to text from any server that speaks the OpenAI `POST /v1/audio/transcriptions`
* protocol: whisper.cpp's server (with `--inference-path /v1/audio/transcriptions`),
* faster-whisper based servers, vLLM, or a hosted API. Asks for timed segments and falls back
* to plain text when the server only returns that.
*/
export function createOpenAiCompatibleTranscriptionProvider(
config: OpenAiCompatibleTranscriptionConfig,
): TranscriptionProvider {
const baseUrl = assertAllowedOpenAiCompatibleUrl(config.baseUrl);
const apiKey = config.apiKey?.trim() || undefined;
assertHttpsForKeyedOpenAiCompatibleUrl(baseUrl, apiKey);
const model = config.model?.trim() || OPENAI_COMPATIBLE_TRANSCRIPTION_DEFAULT_MODEL;
const endpoint = new URL("audio/transcriptions", `${baseUrl.href}/`).href;
const fetchImpl = createOpenAiCompatibleFetch(config.fetch);
function post(
request: TranscriptionRequest,
format: ResponseFormat,
signal: AbortSignal,
): Promise<Response> {
const fields: Record<string, string> = { model, response_format: format };
const language = languageHint(request.language);
if (language) fields.language = language;
const body = multipartBody(fields, {
name: uploadName(request.fileName, request.mimeType),
mimeType: request.mimeType || "application/octet-stream",
bytes: request.audio,
});
const headers: Record<string, string> = {
Accept: "application/json, text/plain",
"Content-Type": body.contentType,
};
if (apiKey) headers.Authorization = `Bearer ${apiKey}`;
return fetchImpl(endpoint, {
method: "POST",
headers,
body: body.bytes,
redirect: "error",
signal,
});
}
async function transcribeAs(
request: TranscriptionRequest,
format: ResponseFormat,
): Promise<Transcript | { rejectedFormat: true }> {
const timeout = AbortSignal.timeout(TRANSCRIPTION_TIMEOUT_MS);
const signal = request.signal ? AbortSignal.any([request.signal, timeout]) : timeout;
try {
const response = await post(request, format, signal);
const text = new TextDecoder().decode(await readResponse(response, signal));
if (!response.ok) {
const message = errorMessage(text);
// Servers that only implement `json`/`text` reject the timed format; ask again plainly.
if (
format === "verbose_json" &&
(response.status === 400 || response.status === 422) &&
/format|verbose/i.test(message)
) {
return { rejectedFormat: true };
}
const failure = httpErrorMessage(response.status, message);
if (response.status === 429 || response.status === 502 || response.status === 503) {
throw new TranscriptionServerUnavailableError(failure);
}
throw new Error(failure);
}
return parseTranscriptionResponse(text, response.headers.get("content-type"));
} catch (error) {
if (request.signal?.aborted) throw error;
if (timeout.aborted) throw new Error("Transcription server timed out");
// undici reports refused or reset connections as a bare "fetch failed".
if (error instanceof TypeError && error.message === "fetch failed") {
throw new TranscriptionServerUnavailableError("Transcription server is unreachable");
}
throw error;
}
}
return {
describe(): AdapterDescriptor<TranscriptionCapabilities> {
return {
id: OPENAI_COMPATIBLE_PROVIDER_ID,
contractVersion: "1",
adapterVersion: "0.1.0",
capabilities: {
segments: true,
languageDetection: true,
maxBytes: OPENAI_COMPATIBLE_TRANSCRIPTION_MAX_BYTES,
},
};
},
async transcribe(request: TranscriptionRequest): Promise<Transcript> {
request.signal?.throwIfAborted();
if (request.audio.byteLength === 0) throw new Error("The audio is empty");
if (request.audio.byteLength > OPENAI_COMPATIBLE_TRANSCRIPTION_MAX_BYTES) {
throw new Error("The audio is too large for the transcription server");
}
const timed = await transcribeAs(request, "verbose_json");
if (!("rejectedFormat" in timed)) return timed;
const plain = await transcribeAs(request, "json");
if ("rejectedFormat" in plain) throw new Error("Transcription server rejected the request");
return plain;
},
};
}
async function readResponse(response: Response, signal: AbortSignal): Promise<Uint8Array> {
try {
return await readBodyCapped(response, MAX_TRANSCRIPTION_RESPONSE_BYTES, signal);
} catch (error) {
if (error instanceof Error && error.message === "Response is too large") {
throw new Error("Transcription server returned too much data");
}
throw error;
}
}
function httpErrorMessage(status: number, detail: string): string {
if (status === 401 || status === 403) return "Transcription server rejected the API key";
if (status === 413) return "The audio is too large for the transcription server";
if (status === 404) return "Transcription server has no /audio/transcriptions endpoint";
return detail
? `Transcription server returned ${status}: ${detail}`
: `Transcription server returned ${status}`;
}
/** The server's own message from an OpenAI-style `{error: {message}}` or a short plain body. */
function errorMessage(body: string): string {
let message = body;
try {
const parsed: unknown = JSON.parse(body);
if (isRecord(parsed)) {
const error = parsed.error;
message =
isRecord(error) && typeof error.message === "string"
? error.message
: typeof error === "string"
? error
: typeof parsed.detail === "string"
? parsed.detail
: typeof parsed.message === "string"
? parsed.message
: "";
}
} catch {
// A plain-text error body is used as it is.
}
return message.replace(/\s+/g, " ").trim().slice(0, 200);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
/**
* Reads `verbose_json` (`{text, language, segments: [{start, end, text}]}` in seconds), plain
* `json` (`{text}`) or a `text/plain` body. Malformed segments are dropped rather than trusted.
*/
export function parseTranscriptionResponse(body: string, contentType: string | null): Transcript {
const trimmed = body.trim();
const looksJson = /json/i.test(contentType ?? "") || trimmed.startsWith("{");
if (!looksJson) return { text: trimmed, segments: [] };
let parsed: unknown;
try {
parsed = JSON.parse(trimmed);
} catch {
throw new Error("Transcription server returned invalid JSON");
}
if (!isRecord(parsed) || typeof parsed.text !== "string") {
throw new Error("Transcription server returned no text");
}
const segments: TranscriptSegment[] = [];
if (Array.isArray(parsed.segments)) {
for (const entry of parsed.segments) {
if (!isRecord(entry) || typeof entry.text !== "string") continue;
const start = Number(entry.start);
const end = Number(entry.end);
const text = entry.text.trim();
if (!text || !Number.isFinite(start) || !Number.isFinite(end) || start < 0) continue;
const startMs = Math.round(start * 1000);
const previousEnd = segments.at(-1)?.endMs ?? 0;
segments.push({
startMs: Math.max(startMs, previousEnd),
endMs: Math.max(Math.round(end * 1000), startMs, previousEnd),
text,
});
}
}
const language = languageCode(parsed.language);
return {
text: parsed.text.trim() || segments.map((segment) => segment.text).join(" "),
...(language ? { language } : {}),
segments,
};
}
/** Whisper's language codes; servers report either the code or its English name. */
const WHISPER_LANGUAGES =
"en zh de es ru ko fr ja pt tr pl ca nl ar sv it id hi fi vi he uk el ms cs ro da hu ta no th ur hr bg lt la mi ml cy sk te fa lv bn sr az sl kn et mk br eu is hy ne mn bs kk sq sw gl mr pa si km sn yo so af oc ka be tg sd gu am yi lo uz fo ht ps tk nn mt sa lb my bo tl mg as tt haw ln ha ba jw su yue".split(
" ",
);
let languageNames: Map<string, string> | undefined;
function languageCode(value: unknown): string | undefined {
if (typeof value !== "string") return undefined;
const normalized = value.trim().toLowerCase();
if (WHISPER_LANGUAGES.includes(normalized)) return normalized;
if (!languageNames) {
const names = new Intl.DisplayNames(["en"], { type: "language" });
languageNames = new Map(
WHISPER_LANGUAGES.map((code) => [(names.of(code) ?? code).toLowerCase(), code]),
);
}
return languageNames.get(normalized);
}
/** OpenAI takes ISO 639-1 codes; a BCP 47 tag such as `de-AT` becomes `de`. */
function languageHint(language: string | undefined): string | undefined {
const primary = language?.trim().split(/[-_]/)[0]?.toLowerCase();
return primary && /^[a-z]{2,3}$/.test(primary) ? primary : undefined;
}
const EXTENSIONS: Record<string, string> = {
"audio/mpeg": "mp3",
"audio/mp4": "m4a",
"audio/wav": "wav",
"audio/x-wav": "wav",
"audio/webm": "webm",
"audio/ogg": "ogg",
"audio/flac": "flac",
"video/mp4": "mp4",
"video/webm": "webm",
};
/** Servers pick the decoder from the file extension, so the upload name always carries one. */
function uploadName(fileName: string | undefined, mimeType: string): string {
const safe = (fileName ?? "").replace(/[^\w.-]+/g, "_").slice(-100);
if (/\.[a-z0-9]{2,4}$/i.test(safe)) return safe;
const extension = EXTENSIONS[mimeType.split(";")[0]?.trim().toLowerCase() ?? ""] ?? "wav";
return `${safe || "audio"}.${extension}`;
}
/**
* A multipart/form-data body built by hand: the undici fetch used for address-checked requests
* does not serialize the global FormData class, and a byte body works with every fetch.
*/
function multipartBody(
fields: Record<string, string>,
file: { name: string; mimeType: string; bytes: Uint8Array },
): { contentType: string; bytes: Uint8Array<ArrayBuffer> } {
const boundary = `----rakazo${randomUUID().replaceAll("-", "")}`;
const encoder = new TextEncoder();
const parts: Uint8Array[] = [];
for (const [name, value] of Object.entries(fields)) {
parts.push(
encoder.encode(
`--${boundary}\r\nContent-Disposition: form-data; name="${name}"\r\n\r\n${value}\r\n`,
),
);
}
parts.push(
encoder.encode(
`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${file.name}"\r\nContent-Type: ${file.mimeType.replace(/[\r\n]/g, "")}\r\n\r\n`,
),
file.bytes,
encoder.encode(`\r\n--${boundary}--\r\n`),
);
const bytes = new Uint8Array(parts.reduce((total, part) => total + part.byteLength, 0));
let offset = 0;
for (const part of parts) {
bytes.set(part, offset);
offset += part.byteLength;
}
return { contentType: `multipart/form-data; boundary=${boundary}`, bytes };
}

View File

@ -0,0 +1,57 @@
import type { MediaCapabilities } from "@rakazo/adapter-kit";
import type { PrismaClient } from "@rakazo/db";
import { beforeEach, describe, expect, it, vi } from "vitest";
const ensureStudioBot = vi.fn();
vi.mock("@rakazo/db", () => ({
ensureStudioBot: (...args: unknown[]) => ensureStudioBot(...args),
}));
import { ensureStudioWhenMediaConfigured, mediaBackendsConfigured } from "./studio-specialist.js";
const none: MediaCapabilities = {
generate_image: false,
generate_video: false,
edit_image: false,
edit_video: false,
export_video: false,
transcribe: false,
};
const prisma = {} as PrismaClient;
const run = { userId: "user-1", spaceId: "space-1" };
describe("Studio specialist", () => {
beforeEach(() => {
ensureStudioBot.mockReset();
});
it("counts only media servers, not local edits, as media being set up", () => {
expect(mediaBackendsConfigured({ ...none, edit_image: true, edit_video: true })).toBe(false);
expect(mediaBackendsConfigured({ ...none, transcribe: true })).toBe(true);
expect(mediaBackendsConfigured({ ...none, generate_image: true })).toBe(true);
});
it("creates Studio once a media server is configured", async () => {
await ensureStudioWhenMediaConfigured(
{ prisma, media: { capabilities: async () => ({ ...none, edit_video: true }) } },
run,
);
await ensureStudioWhenMediaConfigured({ prisma }, run);
expect(ensureStudioBot).not.toHaveBeenCalled();
await ensureStudioWhenMediaConfigured(
{ prisma, media: { capabilities: async () => ({ ...none, transcribe: true }) } },
run,
);
expect(ensureStudioBot).toHaveBeenCalledWith(prisma, "user-1");
});
it("never fails the run", async () => {
ensureStudioBot.mockRejectedValue(new Error("database unavailable"));
await expect(
ensureStudioWhenMediaConfigured(
{ prisma, media: { capabilities: async () => ({ ...none, generate_video: true }) } },
run,
),
).resolves.toBeUndefined();
});
});

View File

@ -0,0 +1,29 @@
import type { MediaCapabilities, MediaRuntime } from "@rakazo/adapter-kit";
import type { PrismaClient } from "@rakazo/db";
import { ensureStudioBot } from "@rakazo/db";
import { getLogger } from "@rakazo/logging";
/**
* Generation or transcription only run once the deployment owner set up a media server; local
* edits (ffmpeg, image tools) are always there and alone do not call for a specialist.
*/
export function mediaBackendsConfigured(capabilities: MediaCapabilities): boolean {
return capabilities.generate_image || capabilities.generate_video || capabilities.transcribe;
}
/**
* Creates the user's Studio specialist the first time their main bot runs with a media server
* configured, so the specialist directory lists it from that run on. Never fails the run.
*/
export async function ensureStudioWhenMediaConfigured(
deps: { prisma: PrismaClient; media?: Pick<MediaRuntime, "capabilities"> },
run: { userId: string; spaceId: string },
): Promise<void> {
if (!deps.media) return;
try {
if (!mediaBackendsConfigured(await deps.media.capabilities(run.spaceId))) return;
await ensureStudioBot(deps.prisma, run.userId);
} catch (error) {
getLogger().error("studio specialist", error);
}
}

View File

@ -0,0 +1,291 @@
import { createServer } from "node:http";
import type { AddressInfo } from "node:net";
import type { TranscriptionProvider } from "@rakazo/adapter-kit";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
createOpenAiCompatibleTranscriptionProvider,
OPENAI_COMPATIBLE_TRANSCRIPTION_DEFAULT_MODEL,
OPENAI_COMPATIBLE_TRANSCRIPTION_MAX_BYTES,
parseTranscriptionResponse,
TranscriptionServerUnavailableError,
} from "./openai-compatible-transcription.js";
import {
EMULATED_TRANSCRIPT,
EmulatorTranscriptionProvider,
OpenAiCompatibleTranscriptionServerEmulator,
} from "./transcription-emulator.js";
const AUDIO = new Uint8Array([82, 73, 70, 70, 1, 2, 3, 4]);
function httpProvider(
flavor: "openai" | "plain" | "text",
options: { model?: string; apiKey?: string } = {},
) {
const server = new OpenAiCompatibleTranscriptionServerEmulator({
flavor,
apiKey: options.apiKey,
});
const provider = createOpenAiCompatibleTranscriptionProvider({
baseUrl: "http://127.0.0.1:8080",
...options,
fetch: server.fetch,
});
return { server, provider };
}
const factories: Record<string, () => TranscriptionProvider> = {
emulator: () => new EmulatorTranscriptionProvider(),
"openai-compatible": () => httpProvider("openai").provider,
"openai-compatible without timed output": () => httpProvider("plain").provider,
"openai-compatible plain text": () => httpProvider("text").provider,
};
/**
* Offline conformance: every TranscriptionProvider declares its limits, returns the spoken
* text with ordered, non-overlapping segments in milliseconds (or none), refuses empty or
* oversized audio before uploading, and honors cancellation.
*/
for (const [name, create] of Object.entries(factories)) {
describe(`${name} transcription conformance (offline)`, () => {
it("describes the contract and its limits", () => {
const descriptor = create().describe();
expect(descriptor.contractVersion).toBe("1");
expect(descriptor.capabilities.maxBytes).toBeGreaterThan(0);
});
it("returns the text and ordered segments", async () => {
const transcript = await create().transcribe({ audio: AUDIO, mimeType: "audio/wav" });
expect(transcript.text).toBe(EMULATED_TRANSCRIPT.text);
let previousEnd = 0;
for (const segment of transcript.segments) {
expect(segment.text.trim()).toBe(segment.text);
expect(segment.text.length).toBeGreaterThan(0);
expect(segment.startMs).toBeGreaterThanOrEqual(previousEnd);
expect(segment.endMs).toBeGreaterThanOrEqual(segment.startMs);
previousEnd = segment.endMs;
}
});
it("refuses empty and oversized audio", async () => {
const provider = create();
await expect(
provider.transcribe({ audio: new Uint8Array(), mimeType: "audio/wav" }),
).rejects.toThrow(/empty/);
const tooLarge = new Uint8Array(provider.describe().capabilities.maxBytes + 1);
await expect(provider.transcribe({ audio: tooLarge, mimeType: "audio/wav" })).rejects.toThrow(
/too large/,
);
});
it("rejects when the caller cancels", async () => {
await expect(
create().transcribe({ audio: AUDIO, mimeType: "audio/wav", signal: AbortSignal.abort() }),
).rejects.toThrow();
});
});
}
describe("OpenAI-compatible transcription provider", () => {
afterEach(() => {
vi.unstubAllEnvs();
});
it("posts a multipart upload to /v1/audio/transcriptions asking for timed segments", async () => {
const { server, provider } = httpProvider("openai", { model: " " });
const transcript = await provider.transcribe({
audio: AUDIO,
mimeType: "audio/wav",
fileName: "interview take 1.wav",
language: "de-AT",
});
expect(server.requests).toEqual([
{
url: "http://127.0.0.1:8080/v1/audio/transcriptions",
authorization: null,
model: OPENAI_COMPATIBLE_TRANSCRIPTION_DEFAULT_MODEL,
responseFormat: "verbose_json",
language: "de",
fileName: "interview_take_1.wav",
fileType: "audio/wav",
fileSize: AUDIO.byteLength,
},
]);
expect(transcript).toEqual({ ...EMULATED_TRANSCRIPT, language: "en" });
});
it("sends a real multipart body through the default address-checked fetch", async () => {
const received: { contentType?: string; body?: Buffer } = {};
const server = createServer((request, response) => {
const chunks: Buffer[] = [];
request.on("data", (chunk: Buffer) => chunks.push(chunk));
request.on("end", () => {
received.contentType = request.headers["content-type"];
received.body = Buffer.concat(chunks);
response.setHeader("content-type", "application/json");
response.end(JSON.stringify({ text: "loopback" }));
});
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
try {
const { port } = server.address() as AddressInfo;
const provider = createOpenAiCompatibleTranscriptionProvider({
baseUrl: `http://127.0.0.1:${port}`,
});
await expect(
provider.transcribe({ audio: AUDIO, mimeType: "audio/wav", fileName: "a.wav" }),
).resolves.toEqual({ text: "loopback", segments: [] });
const form = await new Request("http://127.0.0.1/", {
method: "POST",
headers: { "content-type": received.contentType ?? "" },
body: new Uint8Array(received.body ?? []),
}).formData();
expect(form.get("response_format")).toBe("verbose_json");
const file = form.get("file");
expect(file instanceof File && file.name).toBe("a.wav");
expect(file instanceof File && new Uint8Array(await file.arrayBuffer())).toEqual(AUDIO);
} finally {
await new Promise((resolve) => server.close(resolve));
}
});
it("names an upload without an extension after its type and sends the configured model", async () => {
const { server, provider } = httpProvider("openai", { model: "Systran/faster-whisper-small" });
await provider.transcribe({ audio: AUDIO, mimeType: "audio/mpeg", fileName: "voice note" });
expect(server.requests[0]).toMatchObject({
model: "Systran/faster-whisper-small",
fileName: "voice_note.mp3",
});
});
it("asks again for plain JSON when the server rejects timed output", async () => {
const { server, provider } = httpProvider("plain");
const transcript = await provider.transcribe({ audio: AUDIO, mimeType: "audio/wav" });
expect(server.requests.map((request) => request.responseFormat)).toEqual([
"verbose_json",
"json",
]);
expect(transcript).toEqual({ text: EMULATED_TRANSCRIPT.text, segments: [] });
});
it("sends the API key as a bearer token over https", async () => {
vi.stubEnv("RAKAZO_OPENAI_COMPAT_ALLOW_PUBLIC", "1");
const server = new OpenAiCompatibleTranscriptionServerEmulator({ apiKey: "test-key" });
const provider = createOpenAiCompatibleTranscriptionProvider({
baseUrl: "https://speech.example.test/v1",
apiKey: "test-key",
fetch: server.fetch,
});
await expect(
provider.transcribe({ audio: AUDIO, mimeType: "audio/wav" }),
).resolves.toMatchObject({ text: EMULATED_TRANSCRIPT.text });
expect(server.requests[0]?.authorization).toBe("Bearer test-key");
});
it("reports server failures in words", async () => {
const { server, provider } = httpProvider("openai");
server.failNextStatus = 500;
await expect(provider.transcribe({ audio: AUDIO, mimeType: "audio/wav" })).rejects.toThrow(
"Transcription server returned 500: Upstream failure",
);
const keyed = httpProvider("openai", { apiKey: "right" });
const wrong = createOpenAiCompatibleTranscriptionProvider({
baseUrl: "http://127.0.0.1:8080",
apiKey: "wrong",
fetch: keyed.server.fetch,
});
await expect(wrong.transcribe({ audio: AUDIO, mimeType: "audio/wav" })).rejects.toThrow(
"Transcription server rejected the API key",
);
const missing = createOpenAiCompatibleTranscriptionProvider({
baseUrl: "http://127.0.0.1:8080",
fetch: async () => Response.json({ error: { message: "Not found" } }, { status: 404 }),
});
await expect(missing.transcribe({ audio: AUDIO, mimeType: "audio/wav" })).rejects.toThrow(
"Transcription server has no /audio/transcriptions endpoint",
);
const unreachable = createOpenAiCompatibleTranscriptionProvider({
baseUrl: "http://127.0.0.1:8080",
fetch: async () => {
throw new TypeError("fetch failed");
},
});
await expect(unreachable.transcribe({ audio: AUDIO, mimeType: "audio/wav" })).rejects.toThrow(
TranscriptionServerUnavailableError,
);
server.failNextStatus = 503;
await expect(provider.transcribe({ audio: AUDIO, mimeType: "audio/wav" })).rejects.toThrow(
TranscriptionServerUnavailableError,
);
});
it("refuses unsafe endpoints before sending anything", () => {
expect(() =>
createOpenAiCompatibleTranscriptionProvider({ baseUrl: "http://169.254.169.254" }),
).toThrow(/blocked/);
expect(() =>
createOpenAiCompatibleTranscriptionProvider({ baseUrl: "https://speech.example.test" }),
).toThrow(/Public model endpoints are blocked/);
vi.stubEnv("RAKAZO_OPENAI_COMPAT_ALLOW_PUBLIC", "1");
expect(() =>
createOpenAiCompatibleTranscriptionProvider({
baseUrl: "http://speech.example.test",
apiKey: "secret",
}),
).toThrow(/must use HTTPS/);
expect(() =>
createOpenAiCompatibleTranscriptionProvider({ baseUrl: "file:///etc/passwd" }),
).toThrow();
});
it("caps the upload size at the protocol limit", () => {
const { provider } = httpProvider("openai");
expect(provider.describe().capabilities.maxBytes).toBe(
OPENAI_COMPATIBLE_TRANSCRIPTION_MAX_BYTES,
);
});
});
describe("parseTranscriptionResponse", () => {
it("reads verbose JSON segments in seconds, dropping malformed ones and overlaps", () => {
const transcript = parseTranscriptionResponse(
JSON.stringify({
text: " Hallo. Wie geht's? ",
language: "german",
segments: [
{ start: 0, end: 1.25, text: " Hallo." },
{ start: "x", end: 2, text: "bad timing" },
{ start: 1, end: 2.5, text: " Wie geht's?" },
{ start: 3, end: 4, text: " " },
{ start: 3, end: 4 },
],
}),
"application/json",
);
expect(transcript).toEqual({
text: "Hallo. Wie geht's?",
language: "de",
segments: [
{ startMs: 0, endMs: 1250, text: "Hallo." },
{ startMs: 1250, endMs: 2500, text: "Wie geht's?" },
],
});
});
it("keeps a language code and reads plain JSON and text bodies", () => {
expect(parseTranscriptionResponse('{"text":"hi","language":"en"}', null)).toEqual({
text: "hi",
language: "en",
segments: [],
});
expect(parseTranscriptionResponse(" just words \n", "text/plain")).toEqual({
text: "just words",
segments: [],
});
expect(() => parseTranscriptionResponse('{"segments":[]}', "application/json")).toThrow(
"Transcription server returned no text",
);
expect(() => parseTranscriptionResponse("{oops", "application/json")).toThrow(
"Transcription server returned invalid JSON",
);
});
});

View File

@ -0,0 +1,145 @@
import type {
Transcript,
TranscriptionCapabilities,
TranscriptionProvider,
TranscriptionRequest,
} from "@rakazo/adapter-kit";
/** What the emulators "hear" in any audio: two timed sentences. */
export const EMULATED_TRANSCRIPT: Transcript = {
text: "Welcome to the studio. Today we cut a vertical clip.",
language: "en",
segments: [
{ startMs: 0, endMs: 2400, text: "Welcome to the studio." },
{ startMs: 2400, endMs: 5100, text: "Today we cut a vertical clip." },
],
};
const EMULATED_MAX_BYTES = 25 * 1024 * 1024;
/** Offline transcription provider for tests and conformance; no speech server involved. */
export class EmulatorTranscriptionProvider implements TranscriptionProvider {
describe() {
return {
id: "emulator",
contractVersion: "1",
adapterVersion: "0.1.0",
capabilities: {
segments: true,
languageDetection: true,
maxBytes: EMULATED_MAX_BYTES,
} satisfies TranscriptionCapabilities,
};
}
async transcribe(request: TranscriptionRequest): Promise<Transcript> {
request.signal?.throwIfAborted();
if (request.audio.byteLength === 0) throw new Error("The audio is empty");
if (request.audio.byteLength > EMULATED_MAX_BYTES) {
throw new Error("The audio is too large for the transcription server");
}
return {
...EMULATED_TRANSCRIPT,
...(request.language ? { language: request.language } : {}),
segments: EMULATED_TRANSCRIPT.segments.map((segment) => ({ ...segment })),
};
}
}
export interface EmulatedTranscriptionRequest {
url: string;
authorization: string | null;
model: string | null;
responseFormat: string | null;
language: string | null;
fileName: string | null;
fileType: string | null;
fileSize: number;
}
/**
* In-process substitute for an OpenAI-compatible `POST /v1/audio/transcriptions` server, so
* the real HTTP adapter runs in offline tests. `openai` answers every response format like
* OpenAI and whisper.cpp; `plain` mimics servers without timed output that reject
* `verbose_json`; `text` answers with a bare `text/plain` body whatever was asked.
*/
export class OpenAiCompatibleTranscriptionServerEmulator {
readonly requests: EmulatedTranscriptionRequest[] = [];
/** Answer the next request with this HTTP status instead of a transcript. */
failNextStatus: number | undefined;
constructor(
private readonly options: { apiKey?: string; flavor?: "openai" | "plain" | "text" } = {},
) {}
readonly fetch: typeof fetch = async (input, init) => {
init?.signal?.throwIfAborted();
const url = new URL(input instanceof Request ? input.url : String(input));
if (init?.method !== "POST" || !url.pathname.endsWith("/audio/transcriptions")) {
return Response.json({ error: { message: "Not found" } }, { status: 404 });
}
const headers = new Headers(init.headers);
const authorization = headers.get("authorization");
let form: FormData;
try {
form = await new Request(url, { method: "POST", headers, body: init.body }).formData();
} catch {
return Response.json({ error: { message: "Expected multipart/form-data" } }, { status: 400 });
}
const file = form.get("file");
const field = (name: string) => {
const value = form.get(name);
return typeof value === "string" ? value : null;
};
const responseFormat = field("response_format");
this.requests.push({
url: url.href,
authorization,
model: field("model"),
responseFormat,
language: field("language"),
fileName: file instanceof File ? file.name : null,
fileType: file instanceof File ? file.type : null,
fileSize: file instanceof File ? file.size : 0,
});
if (this.failNextStatus) {
const status = this.failNextStatus;
this.failNextStatus = undefined;
return Response.json({ error: { message: "Upstream failure" } }, { status });
}
if (this.options.apiKey && authorization !== `Bearer ${this.options.apiKey}`) {
return Response.json({ error: { message: "Invalid API key" } }, { status: 401 });
}
if (!(file instanceof File) || file.size === 0) {
return Response.json({ error: { message: "file is required" } }, { status: 400 });
}
const flavor = this.options.flavor ?? "openai";
if (flavor === "text" || responseFormat === "text") {
return new Response(EMULATED_TRANSCRIPT.text, {
headers: { "content-type": "text/plain; charset=utf-8" },
});
}
if (responseFormat === "verbose_json") {
if (flavor === "plain") {
return Response.json(
{ error: { message: "Unsupported response_format: verbose_json" } },
{ status: 400 },
);
}
return Response.json({
task: "transcribe",
language: "english",
duration: EMULATED_TRANSCRIPT.segments.at(-1)!.endMs / 1000,
text: EMULATED_TRANSCRIPT.text,
segments: EMULATED_TRANSCRIPT.segments.map((segment, id) => ({
id,
start: segment.startMs / 1000,
end: segment.endMs / 1000,
// Whisper keeps the leading space of each segment.
text: ` ${segment.text}`,
})),
});
}
return Response.json({ text: EMULATED_TRANSCRIPT.text });
};
}

View File

@ -13,6 +13,7 @@ export * from "./media.js";
export * from "./media-generation.js";
export * from "./media-jobs.js";
export * from "./media-settings.js";
export * from "./media-transcription.js";
export * from "./media-video.js";
export * from "./model-tiers.js";
export * from "./openai-compatible-ui.js";

View File

@ -34,3 +34,6 @@ export const MediaJobSnapshotSchema = z.object({
export type MediaJobSnapshotDto = z.infer<typeof MediaJobSnapshotSchema>;
export const MediaJobStartOutputSchema = z.object({ jobId: Id, messageId: Id });
/** Which media operations the actor's space can run now, so cards offer only those. */
export const MediaCapabilitiesSchema = z.record(MediaOperationSchema, z.boolean());

View File

@ -0,0 +1,20 @@
import * as z from "zod";
import { Id } from "./ids.js";
export const TranscribeInputSchema = z.object({
artifactId: Id.describe("Audio or video artifact to transcribe."),
language: z
.string()
.trim()
.regex(/^[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8})*$/, "Use a language code such as en or de")
.optional()
.describe("Spoken language as a code such as en or de; detected when omitted."),
title: z
.string()
.trim()
.min(1)
.max(120)
.optional()
.describe("A few words in the user's language for the media card."),
});
export type TranscribeInput = z.infer<typeof TranscribeInputSchema>;

View File

@ -83,6 +83,7 @@ import {
IntegrationSetupStateSchema,
} from "./integration-settings.js";
import {
MediaCapabilitiesSchema,
MediaJobSnapshotSchema,
MediaJobStartInputSchema,
MediaJobStartOutputSchema,
@ -702,6 +703,7 @@ export const appContract = {
get: oc.output(MediaSettingsViewSchema),
update: oc.input(MediaSettingsUpdateSchema).output(MediaSettingsViewSchema),
},
capabilities: oc.output(MediaCapabilitiesSchema),
jobs: {
start: oc.input(MediaJobStartInputSchema).output(MediaJobStartOutputSchema),
get: oc.input(z.object({ jobId: Id })).output(MediaJobSnapshotSchema),

View File

@ -22,6 +22,7 @@ export * from "./featured-connectors.js";
export * from "./group-mentions.js";
export * from "./http-response.js";
export * from "./mcp.js";
export * from "./media/subtitles.js";
export * from "./message-pages.js";
export * from "./message-reactions.js";
export * from "./message-visibility.js";

View File

@ -0,0 +1,76 @@
import { describe, expect, it } from "vitest";
import { SUBTITLE_MAX_LINE_CHARS, toSrt, toVtt } from "./subtitles.js";
const SEGMENTS = [
{ startMs: 0, endMs: 2400, text: " Welcome to the studio." },
{ startMs: 2400, endMs: 5100, text: "Today we cut a vertical clip." },
];
describe("subtitles", () => {
it("writes numbered SubRip cues with comma milliseconds", () => {
expect(toSrt(SEGMENTS)).toBe(
[
"1",
"00:00:00,000 --> 00:00:02,400",
"Welcome to the studio.",
"",
"2",
"00:00:02,400 --> 00:00:05,100",
"Today we cut a vertical clip.",
"",
].join("\n"),
);
});
it("writes WebVTT with a header, dot milliseconds and escaped cue text", () => {
expect(
toVtt([...SEGMENTS, { startMs: 3_723_004, endMs: 3_725_000, text: "Tom & <Jerry> --> go" }]),
).toBe(
[
"WEBVTT",
"",
"00:00:00.000 --> 00:00:02.400",
"Welcome to the studio.",
"",
"00:00:02.400 --> 00:00:05.100",
"Today we cut a vertical clip.",
"",
"01:02:03.004 --> 01:02:05.000",
"Tom &amp; &lt;Jerry&gt; → go",
"",
].join("\n"),
);
});
it("returns an empty file body for no speech", () => {
expect(toSrt([])).toBe("");
expect(toVtt([{ startMs: 0, endMs: 10, text: " \n " }])).toBe("WEBVTT\n");
});
it("wraps long segments into two-line cues that share the segment's time", () => {
const text =
"This is a long sentence that keeps going well past what fits on a phone screen, so it has to wrap across several readable cues.";
const srt = toSrt([{ startMs: 1000, endMs: 9000, text }]);
const cues = srt.trim().split("\n\n");
expect(cues.length).toBeGreaterThan(1);
const times = cues.map((cue) => cue.split("\n")[1]);
expect(times[0]?.startsWith("00:00:01,000 --> ")).toBe(true);
expect(times.at(-1)?.endsWith(" --> 00:00:09,000")).toBe(true);
for (let index = 1; index < times.length; index += 1) {
expect(times[index]?.split(" --> ")[0]).toBe(times[index - 1]?.split(" --> ")[1]);
}
for (const cue of cues) {
const lines = cue.split("\n").slice(2);
expect(lines.length).toBeLessThanOrEqual(2);
for (const line of lines) expect(line.length).toBeLessThanOrEqual(SUBTITLE_MAX_LINE_CHARS);
}
expect(cues.flatMap((cue) => cue.split("\n").slice(2)).join(" ")).toBe(text);
});
it("keeps a word longer than a line whole and never runs time backwards", () => {
const word = "a".repeat(60);
expect(toSrt([{ startMs: 500, endMs: 100, text: word }])).toBe(
`1\n00:00:00,500 --> 00:00:00,500\n${word}\n`,
);
});
});

View File

@ -0,0 +1,98 @@
/** A timed piece of a transcript, in milliseconds from the start of the media. */
export interface SubtitleSegment {
startMs: number;
endMs: number;
text: string;
}
/** Readable on a phone held upright and the common broadcast limit for one subtitle line. */
export const SUBTITLE_MAX_LINE_CHARS = 42;
const SUBTITLE_MAX_LINES = 2;
/** SubRip (`.srt`): numbered cues with `HH:MM:SS,mmm` times. */
export function toSrt(segments: readonly SubtitleSegment[]): string {
return subtitleCues(segments)
.map(
(cue, index) =>
`${index + 1}\n${timestamp(cue.startMs, ",")} --> ${timestamp(cue.endMs, ",")}\n${cue.lines.join("\n")}\n`,
)
.join("\n");
}
/** WebVTT (`.vtt`): the format browsers play in `<track>`, with cue text escaped. */
export function toVtt(segments: readonly SubtitleSegment[]): string {
const cues = subtitleCues(segments).map(
(cue) =>
`${timestamp(cue.startMs, ".")} --> ${timestamp(cue.endMs, ".")}\n${cue.lines.map(escapeVtt).join("\n")}\n`,
);
return ["WEBVTT\n", ...cues].join("\n");
}
interface SubtitleCue {
startMs: number;
endMs: number;
lines: string[];
}
/**
* Turns transcript segments into cues of at most two short lines. A long segment becomes
* several cues that share its time span in proportion to their length, so subtitles stay
* readable instead of covering the frame.
*/
function subtitleCues(segments: readonly SubtitleSegment[]): SubtitleCue[] {
const cues: SubtitleCue[] = [];
for (const segment of segments) {
// A blank line ends a cue in both formats, and "-->" would read as a timing line.
const text = segment.text.replaceAll("-->", "→").replace(/\s+/g, " ").trim();
if (!text) continue;
const startMs = Math.max(0, Math.round(segment.startMs));
const endMs = Math.max(startMs, Math.round(segment.endMs));
const lines = wrapLines(text, SUBTITLE_MAX_LINE_CHARS);
const groups: string[][] = [];
for (let index = 0; index < lines.length; index += SUBTITLE_MAX_LINES) {
groups.push(lines.slice(index, index + SUBTITLE_MAX_LINES));
}
const total = groups.reduce((sum, group) => sum + group.join(" ").length, 0);
let cursor = startMs;
let consumed = 0;
for (const [index, group] of groups.entries()) {
consumed += group.join(" ").length;
const groupEnd =
index === groups.length - 1
? endMs
: startMs + Math.round(((endMs - startMs) * consumed) / total);
cues.push({ startMs: cursor, endMs: groupEnd, lines: group });
cursor = groupEnd;
}
}
return cues;
}
function wrapLines(text: string, maxChars: number): string[] {
const lines: string[] = [];
let line = "";
for (const word of text.split(" ")) {
if (!line) {
line = word;
} else if (line.length + 1 + word.length <= maxChars) {
line = `${line} ${word}`;
} else {
lines.push(line);
line = word;
}
}
if (line) lines.push(line);
return lines;
}
function timestamp(ms: number, separator: "," | "."): string {
const hours = Math.floor(ms / 3_600_000);
const minutes = Math.floor((ms % 3_600_000) / 60_000);
const seconds = Math.floor((ms % 60_000) / 1000);
const millis = ms % 1000;
return `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}${separator}${String(millis).padStart(3, "0")}`;
}
function escapeVtt(line: string): string {
return line.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
}

View File

@ -0,0 +1,3 @@
-- A deleted bot remembers its spawn key, so helpers that create a bot idempotently (such as the
-- Studio specialist) do not bring back one the user deleted.
ALTER TABLE "bot_deletions" ADD COLUMN "spawnKey" TEXT;

View File

@ -460,6 +460,8 @@ model BotDeletion {
spaceId String
space Space @relation(fields: [spaceId], references: [id], onDelete: Cascade)
name String
/// The deleted bot's spawn key, so idempotent creation does not bring it back.
spawnKey String?
deletedByUserId String
memoriesPreserved Boolean
deletedAt DateTime @default(now())

View File

@ -17,6 +17,7 @@ export * from "./project-workspace.js";
export * from "./repos.js";
export * from "./scope.js";
export * from "./spaces.js";
export * from "./studio-agent.js";
export * from "./transaction-retry.js";
export * from "./user-profile.js";
export * from "./voice-credentials.js";

View File

@ -0,0 +1,140 @@
import { randomUUID } from "node:crypto";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { bootstrapUserSpace } from "./bootstrap-user.js";
import type { PrismaClient } from "./client.js";
import { createDb } from "./client.js";
import { ensureMainBot } from "./main-agent.js";
import {
ensureStudioBot,
STUDIO_BOT_DEFAULTS,
STUDIO_BOT_TOOLS,
studioBotSpawnKey,
} from "./studio-agent.js";
const databaseUrl = process.env.DATABASE_URL;
const describePostgres =
process.env.VERIFY_DATABASE && databaseUrl ? describe.sequential : describe.skip;
const signupEnv = { signupsEnabled: "true", signupAllowlist: "" };
describePostgres("Studio specialist (PostgreSQL)", () => {
let prisma: PrismaClient;
let close: () => Promise<void>;
const userIds: string[] = [];
async function newUser(name = "Ada") {
const id = `st${randomUUID().replaceAll("-", "")}`;
userIds.push(id);
await prisma.user.create({ data: { id, name, email: `${id}@rakazo.test` } });
const { spaceId } = await bootstrapUserSpace(prisma, { id }, signupEnv, {
claimDeploymentOwner: false,
});
return { userId: id, homeSpaceId: spaceId };
}
beforeAll(() => {
const db = createDb(databaseUrl!);
prisma = db.prisma;
close = async () => {
await db.prisma.$disconnect();
await db.pool.end();
};
});
afterAll(async () => {
if (!prisma) return;
const memberships = await prisma.member.findMany({
where: { userId: { in: userIds } },
select: { organizationId: true },
});
await prisma.organization.deleteMany({
where: { id: { in: memberships.map((member) => member.organizationId) } },
});
await prisma.user.deleteMany({ where: { id: { in: userIds } } });
await close();
});
it("creates one Studio next to the main bot, even under concurrent calls", async () => {
const { userId, homeSpaceId } = await newUser();
const results = await Promise.all([
ensureStudioBot(prisma, userId),
ensureStudioBot(prisma, userId),
ensureStudioBot(prisma, userId),
]);
expect(new Set(results.map((bot) => bot?.id)).size).toBe(1);
const main = await ensureMainBot(prisma, userId);
const studios = await prisma.bot.findMany({ where: { userId, kind: "specialist" } });
expect(studios).toHaveLength(1);
expect(studios[0]).toMatchObject({
id: results[0]?.id,
spaceId: homeSpaceId,
name: "Studio",
instructions: STUDIO_BOT_DEFAULTS.instructions,
tags: ["media"],
restrictBuiltinTools: true,
allowedBuiltinTools: [...STUDIO_BOT_TOOLS],
spawnKey: studioBotSpawnKey(userId),
});
expect(main.spaceId).toBe(homeSpaceId);
// A thread makes it addressable: the main bot's specialist directory lists bots with one.
expect(await prisma.thread.count({ where: { botId: studios[0]?.id } })).toBe(1);
expect(await ensureStudioBot(prisma, userId)).toEqual(results[0]);
});
it("gives every member of a shared space their own Studio", async () => {
const owner = await newUser();
const ownerStudio = await ensureStudioBot(prisma, owner.userId);
const guestId = `st${randomUUID().replaceAll("-", "")}`;
userIds.push(guestId);
await prisma.user.create({
data: { id: guestId, name: "Guest", email: `${guestId}@rakazo.test` },
});
const organization = await prisma.space.findUniqueOrThrow({
where: { id: owner.homeSpaceId },
select: { organizationId: true },
});
// The organization's default-space trigger makes the guest a member of the owner's space.
await prisma.member.create({
data: {
id: `member-${guestId}`,
organizationId: organization.organizationId,
userId: guestId,
role: "member",
createdAt: new Date(),
},
});
const guestStudio = await ensureStudioBot(prisma, guestId);
expect(guestStudio).not.toBeNull();
expect(guestStudio?.id).not.toBe(ownerStudio?.id);
expect(await prisma.bot.findUniqueOrThrow({ where: { id: guestStudio!.id } })).toMatchObject({
userId: guestId,
spawnKey: studioBotSpawnKey(guestId),
});
});
it("does not bring back a Studio the user archived or deleted", async () => {
const archiver = await newUser();
const archived = await ensureStudioBot(prisma, archiver.userId);
await prisma.bot.update({ where: { id: archived!.id }, data: { archivedAt: new Date() } });
await expect(ensureStudioBot(prisma, archiver.userId)).resolves.toBeNull();
expect(await prisma.bot.count({ where: { userId: archiver.userId, name: "Studio" } })).toBe(1);
const deleter = await newUser();
const deleted = await ensureStudioBot(prisma, deleter.userId);
const row = await prisma.bot.findUniqueOrThrow({ where: { id: deleted!.id } });
// What destroyBot records when the user deletes a bot.
await prisma.botDeletion.create({
data: {
id: row.id,
spaceId: row.spaceId,
name: row.name,
spawnKey: row.spawnKey,
deletedByUserId: deleter.userId,
memoriesPreserved: true,
},
});
await prisma.bot.delete({ where: { id: row.id } });
await expect(ensureStudioBot(prisma, deleter.userId)).resolves.toBeNull();
expect(await prisma.bot.count({ where: { userId: deleter.userId, name: "Studio" } })).toBe(0);
});
});

View File

@ -0,0 +1,91 @@
import { MediaOperationSchema } from "@rakazo/contracts";
import type { PrismaClient } from "./client.js";
import { ensureMainBot } from "./main-agent.js";
import { createRepos } from "./repos.js";
import { requireMembership } from "./scope.js";
/**
* Idempotency key of the user's Studio specialist. Spawn keys are unique per space, so it
* carries the user id to stay unique in a space several users share.
*/
export function studioBotSpawnKey(userId: string): string {
return `media:studio:${userId}`;
}
/** Builtin tools Studio keeps: every media operation plus files, questions and memory. */
export const STUDIO_BOT_TOOLS = [
...MediaOperationSchema.options,
"list_files",
"read_file",
"attach_file",
"ask_user",
"message_user",
"remember",
"web_search",
"web_fetch",
"skill_read",
] as const;
/** Product text: the main bot reads the description when routing, Studio follows the instructions. */
export const STUDIO_BOT_DEFAULTS = {
name: "Studio",
title: "Media producer",
description:
"Makes and edits images, video and audio: cuts, reframes and exports for every format, transcribes and subtitles.",
instructions: [
"You make and edit images, video and audio with the media tools, then hand the result back in a sentence or two.",
"",
"Work like an editor:",
"- Start from where the piece will run. Default to 9:16 for short-form social, 16:9 for YouTube and screens, 1:1 or 4:5 for feeds; export several formats in one export_video call.",
"- Hook in the first two seconds: open on the strongest moment, not the setup.",
"- Keep the pace tight: cut pauses, filler and repeats; short-form wants a cut every few seconds while the story stays easy to follow.",
"- Respect safe areas in 9:16: keep faces, text and subtitles out of the top 15% and bottom 20%, where the platform's buttons sit. When reframing, keep the subject centred.",
"- Most social video plays muted: transcribe speech, then burn in short subtitles.",
"- Keep the source: every edit makes a new file, so try a cut and refine it.",
"",
"Media jobs run in the background and the user sees each one's card. Set continue_after when you need a result for your next step. Ask only when the purpose or format is unclear and a wrong guess would waste a long render.",
].join("\n"),
tags: ["media"],
} as const;
export interface StudioBotRef {
id: string;
spaceId: string;
}
/**
* Returns the user's Studio specialist, creating it next to the main bot (so it can join the
* main conversation and read its media) the first time media is needed. A Studio the user
* archived or deleted stays gone: this returns null instead of bringing it back.
*/
export async function ensureStudioBot(
prisma: PrismaClient,
userId: string,
): Promise<StudioBotRef | null> {
const main = await ensureMainBot(prisma, userId);
const spawnKey = studioBotSpawnKey(userId);
const existing = await prisma.bot.findUnique({
where: { spaceId_spawnKey: { spaceId: main.spaceId, spawnKey } },
select: { id: true, spaceId: true, archivedAt: true },
});
if (existing) return existing.archivedAt ? null : { id: existing.id, spaceId: existing.spaceId };
const deleted = await prisma.botDeletion.findFirst({
where: { spaceId: main.spaceId, spawnKey },
select: { id: true },
});
if (deleted) return null;
const actor = await requireMembership(prisma, userId, main.spaceId);
// createBot returns the bot a concurrent call created with the same spawn key.
const bot = await createRepos(prisma).createBot(actor, {
name: STUDIO_BOT_DEFAULTS.name,
title: STUDIO_BOT_DEFAULTS.title,
description: STUDIO_BOT_DEFAULTS.description,
instructions: STUDIO_BOT_DEFAULTS.instructions,
notifyOnFinish: true,
spawnKey,
tags: [...STUDIO_BOT_DEFAULTS.tags],
restrictBuiltinTools: true,
allowedBuiltinTools: [...STUDIO_BOT_TOOLS],
});
return { id: bot.id, spaceId: bot.spaceId };
}

View File

@ -110,6 +110,7 @@ async function main() {
"packages/testkit/src/user-profile.postgres.test.ts",
"packages/db/src/space-membership.postgres.test.ts",
"packages/db/src/main-agent.postgres.test.ts",
"packages/db/src/studio-agent.postgres.test.ts",
"packages/db/src/user-profile.postgres.test.ts",
"packages/db/src/messaging.postgres.test.ts",
"packages/memory/src/commit.postgres.test.ts",
@ -118,6 +119,7 @@ async function main() {
"packages/adapters/src/job-reconciler.postgres.test.ts",
"packages/adapters/src/cloud-agent.postgres.test.ts",
"packages/adapters/src/media/image-operations.postgres.test.ts",
"packages/adapters/src/media/transcription.postgres.test.ts",
"packages/adapters/src/media/video-operations.postgres.test.ts",
"packages/adapters/src/media-runtime.postgres.test.ts",
"packages/adapters/src/builtin-memory-provider.postgres.test.ts",