From 3f85cb04209be1ab7ed221d9703bb0b1f2c450b2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 13:17:17 +0000 Subject: [PATCH 01/14] feat(media): transcribe speech on any OpenAI-compatible server and write subtitles Transcription has to run on the user's own hardware, and the only speech-to-text path so far was the OpenAI voice key. Self-hosted whisper servers (whisper.cpp, faster-whisper based servers, vLLM) all speak the OpenAI transcriptions protocol, so one provider covers them. - createOpenAiCompatibleTranscriptionProvider posts a multipart upload to {base}/audio/transcriptions asking for timed segments, falls back to plain JSON for servers that reject verbose_json, reads text/plain bodies, and reports failures in words. It reuses the address-checked fetch and URL guards of the other OpenAI-compatible adapters, caps uploads at 25 MiB and responses at 8 MiB, and builds the multipart body by hand because the undici fetch behind the address checks does not serialize the global FormData class. - An in-process server emulator (OpenAI/whisper.cpp, plain-JSON-only and text-only flavours) and an in-process provider back an offline conformance suite, plus one loopback test through the real fetch. - toSrt/toVtt in core turn segments into SubRip and WebVTT, wrapping long segments into cues of at most two 42-character lines that share the segment's time, and escaping WebVTT cue text. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u --- packages/adapters/src/index.ts | 2 + .../src/openai-compatible-transcription.ts | 308 ++++++++++++++++++ .../src/transcription-conformance.test.ts | 286 ++++++++++++++++ .../adapters/src/transcription-emulator.ts | 145 +++++++++ packages/core/src/index.ts | 1 + packages/core/src/media/subtitles.test.ts | 76 +++++ packages/core/src/media/subtitles.ts | 98 ++++++ 7 files changed, 916 insertions(+) create mode 100644 packages/adapters/src/openai-compatible-transcription.ts create mode 100644 packages/adapters/src/transcription-conformance.test.ts create mode 100644 packages/adapters/src/transcription-emulator.ts create mode 100644 packages/core/src/media/subtitles.test.ts create mode 100644 packages/core/src/media/subtitles.ts diff --git a/packages/adapters/src/index.ts b/packages/adapters/src/index.ts index 7b4ecd0..aa1ca1c 100644 --- a/packages/adapters/src/index.ts +++ b/packages/adapters/src/index.ts @@ -86,6 +86,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"; @@ -120,6 +121,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"; diff --git a/packages/adapters/src/openai-compatible-transcription.ts b/packages/adapters/src/openai-compatible-transcription.ts new file mode 100644 index 0000000..1b71c27 --- /dev/null +++ b/packages/adapters/src/openai-compatible-transcription.ts @@ -0,0 +1,308 @@ +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; + +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 { + const fields: Record = { 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 = { + 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 { + 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 }; + } + throw new Error(httpErrorMessage(response.status, message)); + } + 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 Error("Transcription server is unreachable"); + } + throw error; + } + } + + return { + describe(): AdapterDescriptor { + 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 { + 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 { + 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 { + 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 | 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 = { + "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, + file: { name: string; mimeType: string; bytes: Uint8Array }, +): { contentType: string; bytes: Uint8Array } { + 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 }; +} diff --git a/packages/adapters/src/transcription-conformance.test.ts b/packages/adapters/src/transcription-conformance.test.ts new file mode 100644 index 0000000..9957653 --- /dev/null +++ b/packages/adapters/src/transcription-conformance.test.ts @@ -0,0 +1,286 @@ +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, +} 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 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((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( + "Transcription server is unreachable", + ); + }); + + 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", + ); + }); +}); diff --git a/packages/adapters/src/transcription-emulator.ts b/packages/adapters/src/transcription-emulator.ts new file mode 100644 index 0000000..b93d983 --- /dev/null +++ b/packages/adapters/src/transcription-emulator.ts @@ -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 { + 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 }); + }; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index c870847..7349f34 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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"; diff --git a/packages/core/src/media/subtitles.test.ts b/packages/core/src/media/subtitles.test.ts new file mode 100644 index 0000000..5e48ec8 --- /dev/null +++ b/packages/core/src/media/subtitles.test.ts @@ -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 & --> 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 & <Jerry> → 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`, + ); + }); +}); diff --git a/packages/core/src/media/subtitles.ts b/packages/core/src/media/subtitles.ts new file mode 100644 index 0000000..c4f703f --- /dev/null +++ b/packages/core/src/media/subtitles.ts @@ -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 ``, 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("&", "&").replaceAll("<", "<").replaceAll(">", ">"); +} From fa97f3957080b0ae5ed156716a9834ab207d5461 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 13:21:56 +0000 Subject: [PATCH 02/14] feat(media): add the Studio specialist the main bot hands media work to Media work (cutting for a format, subtitles, exports) needs editing judgement and a focused tool set, so the main bot should be able to delegate it to a visible specialist instead of doing it itself. - ensureStudioBot creates "Studio" once per user, next to the main bot so it can join the main conversation and read its media, tagged media, with editor instructions (hook first, tight pacing, 9:16 safe areas, subtitles for muted playback) and a restricted builtin allowlist: every media operation plus files, questions, memory and web lookup. It has a thread, so the main bot's specialist directory lists it. The spawn key carries the user id so members of a shared space each get their own. - A Studio the user archived or deleted stays gone. Deleted bots now record their spawn key in bot_deletions so idempotent creation can tell. - Postgres suite for concurrency, shared spaces, archive and delete. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u --- packages/adapters/src/child-bots.test.ts | 5 +- packages/adapters/src/child-bots.ts | 7 +- .../migration.sql | 3 + packages/db/prisma/schema.prisma | 2 + packages/db/src/index.ts | 1 + packages/db/src/studio-agent.postgres.test.ts | 139 ++++++++++++++++++ packages/db/src/studio-agent.ts | 91 ++++++++++++ packages/testkit/src/cli/harness.ts | 1 + 8 files changed, 246 insertions(+), 3 deletions(-) create mode 100644 packages/db/prisma/migrations/20261003135000_bot_deletion_spawn_key/migration.sql create mode 100644 packages/db/src/studio-agent.postgres.test.ts create mode 100644 packages/db/src/studio-agent.ts diff --git a/packages/adapters/src/child-bots.test.ts b/packages/adapters/src/child-bots.test.ts index 6f5479e..f164116 100644 --- a/packages/adapters/src/child-bots.test.ts +++ b/packages/adapters/src/child-bots.test.ts @@ -295,7 +295,9 @@ describe("destroyBot", () => { const deleteArtifacts = vi.fn().mockResolvedValue({ count: 1 }); const transaction = vi.fn(async (callback: (tx: unknown) => Promise) => 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, }, diff --git a/packages/adapters/src/child-bots.ts b/packages/adapters/src/child-bots.ts index 9ab1df9..78a1506 100644 --- a/packages/adapters/src/child-bots.ts +++ b/packages/adapters/src/child-bots.ts @@ -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>` - 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, }, diff --git a/packages/db/prisma/migrations/20261003135000_bot_deletion_spawn_key/migration.sql b/packages/db/prisma/migrations/20261003135000_bot_deletion_spawn_key/migration.sql new file mode 100644 index 0000000..38bffd0 --- /dev/null +++ b/packages/db/prisma/migrations/20261003135000_bot_deletion_spawn_key/migration.sql @@ -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; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index b9773da..c1785c1 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -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()) diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index 3a251fa..a5a142e 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -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"; diff --git a/packages/db/src/studio-agent.postgres.test.ts b/packages/db/src/studio-agent.postgres.test.ts new file mode 100644 index 0000000..540b213 --- /dev/null +++ b/packages/db/src/studio-agent.postgres.test.ts @@ -0,0 +1,139 @@ +import { randomUUID } from "node:crypto"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { bootstrapUserSpace } from "./bootstrap-user.js"; +import { createDb, type PrismaClient } 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; + 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); + }); +}); diff --git a/packages/db/src/studio-agent.ts b/packages/db/src/studio-agent.ts new file mode 100644 index 0000000..5e24154 --- /dev/null +++ b/packages/db/src/studio-agent.ts @@ -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 { + 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 }; +} diff --git a/packages/testkit/src/cli/harness.ts b/packages/testkit/src/cli/harness.ts index 27415a4..948ec91 100644 --- a/packages/testkit/src/cli/harness.ts +++ b/packages/testkit/src/cli/harness.ts @@ -108,6 +108,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", From 5e0b09d9c1312d40122154d671f039b3656be283 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 13:35:21 +0000 Subject: [PATCH 03/14] feat(media): transcription input, chunked transcripts and the Studio trigger Groundwork for the transcribe operation and for creating Studio when it is first needed. - Contracts: TranscribeInputSchema (artifact id, optional language code). - transcribeChunks uploads audio chunks in order and stitches them into one transcript on the original timeline; transcriptionChunkSeconds sizes 16 kHz mono WAV chunks below the server's upload limit; transcriptSummary hands the bot the transcript, cut at a word. - ensureStudioWhenMediaConfigured creates Studio once a media server (generation or transcription) is configured. Local edits alone do not count, because ffmpeg and image tools are present on most installs and a specialist nobody asked for would appear for everyone. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u --- .../adapters/src/media/transcription.test.ts | 102 ++++++++++++++++++ packages/adapters/src/media/transcription.ts | 79 ++++++++++++++ .../adapters/src/studio-specialist.test.ts | 57 ++++++++++ packages/adapters/src/studio-specialist.ts | 28 +++++ packages/contracts/src/index.ts | 1 + packages/contracts/src/media-transcription.ts | 13 +++ 6 files changed, 280 insertions(+) create mode 100644 packages/adapters/src/media/transcription.test.ts create mode 100644 packages/adapters/src/media/transcription.ts create mode 100644 packages/adapters/src/studio-specialist.test.ts create mode 100644 packages/adapters/src/studio-specialist.ts create mode 100644 packages/contracts/src/media-transcription.ts diff --git a/packages/adapters/src/media/transcription.test.ts b/packages/adapters/src/media/transcription.test.ts new file mode 100644 index 0000000..bf6c010 --- /dev/null +++ b/packages/adapters/src/media/transcription.test.ts @@ -0,0 +1,102 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { EmulatorTranscriptionProvider } from "../transcription-emulator.js"; +import { + 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: 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: ".length + TRANSCRIPT_SUMMARY_MAX_CHARS + 2, + ); + expect(body.endsWith("word …")).toBe(true); + }); +}); diff --git a/packages/adapters/src/media/transcription.ts b/packages/adapters/src/media/transcription.ts new file mode 100644 index 0000000..0b081e9 --- /dev/null +++ b/packages/adapters/src/media/transcription.ts @@ -0,0 +1,79 @@ +import { readFile } from "node:fs/promises"; +import type { Transcript, TranscriptionProvider, TranscriptSegment } from "@rakazo/adapter-kit"; + +/** 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; + }, +): Promise { + 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."; + return `Transcript of ${name}: ${cut}\n${timing}`; +} diff --git a/packages/adapters/src/studio-specialist.test.ts b/packages/adapters/src/studio-specialist.test.ts new file mode 100644 index 0000000..472d981 --- /dev/null +++ b/packages/adapters/src/studio-specialist.test.ts @@ -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(); + }); +}); diff --git a/packages/adapters/src/studio-specialist.ts b/packages/adapters/src/studio-specialist.ts new file mode 100644 index 0000000..5d3eab6 --- /dev/null +++ b/packages/adapters/src/studio-specialist.ts @@ -0,0 +1,28 @@ +import type { MediaCapabilities, MediaRuntime } from "@rakazo/adapter-kit"; +import { ensureStudioBot, type PrismaClient } 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 }, + run: { userId: string; spaceId: string }, +): Promise { + 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); + } +} diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index ba2c70b..9a36f8b 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -10,6 +10,7 @@ export * from "./integration-settings.js"; export * from "./local-settings.js"; export * from "./mcp.js"; export * from "./media.js"; +export * from "./media-transcription.js"; export * from "./model-tiers.js"; export * from "./openai-compatible-ui.js"; export * from "./project-goals.js"; diff --git a/packages/contracts/src/media-transcription.ts b/packages/contracts/src/media-transcription.ts new file mode 100644 index 0000000..3d0373a --- /dev/null +++ b/packages/contracts/src/media-transcription.ts @@ -0,0 +1,13 @@ +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."), +}); +export type TranscribeInput = z.infer; From 8b2dcaa767fd28e2a22bf9a4c178290ecd02c2b0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 13:35:21 +0000 Subject: [PATCH 04/14] feat(web): show media jobs as a live card in the conversation Media jobs report progress and results through the media block, which the web app did not render. - MediaCard renders the block: operation icon, title, status (reusing the task card's labels, plus "Waiting for GPU" for jobs queued behind the GPU), a progress bar while active, the error when failed, and outputs: images as thumbnails with the existing lightbox, video and audio in the media player, subtitle and text files as file cards. Finished video and audio get a download link, "Export" with presets labelled by aspect ratio (the video's own shape left out) and "Transcribe", offered only when the space can run them; running jobs get "Cancel". - MessageView renders it with one branch. - An E2E seeds the card on a thread snapshot with real uploaded artifacts. - artifact-url.ts and MediaPlayer.tsx are minimal stand-ins for the pipeline workstream's files and give way to them on merge. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u --- apps/web/e2e/media-card.spec.ts | 164 +++++++++ .../src/components/media/MediaCard.test.tsx | 193 +++++++++++ apps/web/src/components/media/MediaCard.tsx | 319 ++++++++++++++++++ apps/web/src/components/media/MediaPlayer.tsx | 33 ++ apps/web/src/lib/artifact-url.ts | 4 + apps/web/src/pages/Shell.tsx | 3 + 6 files changed, 716 insertions(+) create mode 100644 apps/web/e2e/media-card.spec.ts create mode 100644 apps/web/src/components/media/MediaCard.test.tsx create mode 100644 apps/web/src/components/media/MediaCard.tsx create mode 100644 apps/web/src/components/media/MediaPlayer.tsx create mode 100644 apps/web/src/lib/artifact-url.ts diff --git a/apps/web/e2e/media-card.spec.ts b/apps/web/e2e/media-card.spec.ts new file mode 100644 index 0000000..e777374 --- /dev/null +++ b/apps/web/e2e/media-card.spec.ts @@ -0,0 +1,164 @@ +import { expect, type Page, test } from "@playwright/test"; +import { + activeBotId, + captureScreenshot, + completeOnboarding, + fetchRpcEnvelope, + fulfillFetchedRpc, + rpc, + signup, +} from "./helpers"; + +type MediaBlock = Record & { kind: "media" }; +type Snapshot = { + threadId: string; + messages: Array<{ seq: number } & Record>; +}; + +/** 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 { + 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 thread snapshot carries a bot message with the job's card. + */ +async function seedMediaMessage(page: Page, botId: string, block: () => MediaBlock) { + await page.route("**/rpc/threads/get", async (route) => { + const fetched = await fetchRpcEnvelope(route); + const snapshot = fetched.body.json as (Snapshot & { botId?: string }) | undefined; + if (snapshot?.botId === botId) { + 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(), + }); + } + await fulfillFetchedRpc(route, fetched); + }); +} + +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, + }, + ], + }, + ); + 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 captureScreenshot(page, testInfo, "media-card-done"); +}); diff --git a/apps/web/src/components/media/MediaCard.test.tsx b/apps/web/src/components/media/MediaCard.test.tsx new file mode 100644 index 0000000..ff38de3 --- /dev/null +++ b/apps/web/src/components/media/MediaCard.test.tsx @@ -0,0 +1,193 @@ +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 }) => ( + {name} + ), +})); +vi.mock("../ArtifactFileCard", () => ({ + ArtifactFileCard: ({ name }: { name: string }) => {name}, +})); +vi.mock("./MediaPlayer", () => ({ + MediaPlayer: ({ artifactId, kind }: { artifactId: string; kind: string }) => ( + + ), +})); +vi.mock("../../lib/artifact-url", () => ({ + artifactUrl: (artifactId: string) => `/artifact/${artifactId}`, +})); + +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 { + 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(); + 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( + , + ); + 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( + , + ); + expect(html).toContain('role="progressbar"'); + expect(html).not.toContain("aria-valuenow"); + }); + + it("offers download, export and transcribe for a finished video", () => { + const html = renderToString( + , + ); + expect(html).toContain('data-player="video"'); + expect(html).toContain('href="/artifact/video-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( + , + ); + expect(onlyTranscribe).toContain('data-player="audio"'); + expect(onlyTranscribe).toContain("Transcribe"); + expect(onlyTranscribe).not.toContain("Export"); + + const nothingAvailable = renderToString( + , + ); + 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( + , + ); + 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( + , + ); + 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); + }); +}); diff --git a/apps/web/src/components/media/MediaCard.tsx b/apps/web/src/components/media/MediaCard.tsx new file mode 100644 index 0000000..e4ba52c --- /dev/null +++ b/apps/web/src/components/media/MediaCard.tsx @@ -0,0 +1,319 @@ +import { useLingui } from "@lingui/react/macro"; +import { + MEDIA_JOB_ACTIVE_STATUSES, + MEDIA_PRESETS, + type MediaJobBlock, + type MediaJobStatus, + type MediaOperation, + type MediaOutput, + type MediaPresetId, + MediaPresetIdSchema, +} from "@rakazo/contracts"; +import { + Button, + buttonVariants, + cn, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@rakazo/ui-web"; +import { + Captions, + ChevronDown, + Download, + Film, + ImageIcon, + type LucideIcon, + Scissors, + Sparkles, +} from "lucide-react"; +import { useId, useState } from "react"; +import type { ArtifactTarget } from "../../lib/artifact-open"; +import { artifactUrl } from "../../lib/artifact-url"; +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 } + | { 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 OPERATION_ICONS: Record = { + 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) { + 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 ( + + {t`Working…`} + + ); + } + const label = + status === "queued" + ? t`Queued` + : status === "waiting" + ? t`Waiting for GPU` + : status === "done" + ? t`Done` + : status === "failed" + ? t`Failed` + : t`Cancelled`; + return ( + + {label} + + ); +} + +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 ( +
+ + + + {onAction && available.export && presets.length > 0 ? ( + + } + > + + {t`Export`} + + + + {presets.map((preset) => ( + onAction({ kind: "export", artifactId: output.artifactId, preset })} + > + {MEDIA_PRESETS[preset].aspect} + + ))} + + + ) : null} + {onAction && available.transcribe && (output.kind === "video" || output.kind === "audio") ? ( + + ) : null} +
+ ); +} + +/** + * 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 = { export: false, transcribe: false }, + 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; +}) { + const { t } = useLingui(); + const titleId = useId(); + const [busy, setBusy] = useState(false); + const [actionError, setActionError] = useState(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 ( +
+
+
+ + + + + {active && act ? ( + + ) : null} + +
+ {active ? ( +
+
+
+ ) : null} + {block.status === "failed" && block.error ? ( +

+ {block.error} +

+ ) : null} + {images.length > 0 ? ( +
+ {images.map((output) => ( + + ))} +
+ ) : null} + {others.map((output) => ( +
+ {output.kind === "video" || output.kind === "audio" ? ( + <> + + {active ? null : ( + + )} + + ) : ( + + )} +
+ ))} + {actionError ? ( +

+ {actionError} +

+ ) : null} +
+
+ ); +} + +/** The media card in a conversation, with its follow-up actions wired to the media job RPCs. */ +export function MediaCard({ block, target }: { block: MediaJobBlock; target: ArtifactTarget }) { + return ; +} diff --git a/apps/web/src/components/media/MediaPlayer.tsx b/apps/web/src/components/media/MediaPlayer.tsx new file mode 100644 index 0000000..ade576e --- /dev/null +++ b/apps/web/src/components/media/MediaPlayer.tsx @@ -0,0 +1,33 @@ +// Stand-in for B1's media player; replaced by B1's component when its branch is merged. +import { artifactUrl } from "../../lib/artifact-url"; + +export function MediaPlayer({ + artifactId, + kind, + name, + posterArtifactId, +}: { + artifactId: string; + kind: "video" | "audio"; + mimeType: string; + name: string; + posterArtifactId?: string; + width?: number; + height?: number; +}) { + if (kind === "audio") { + // biome-ignore lint/a11y/useMediaCaption: generated media has no caption track yet. + return