diff --git a/apps/web/__tests__/unit/developer-transcribe-fast-path.test.ts b/apps/web/__tests__/unit/developer-transcribe-fast-path.test.ts new file mode 100644 index 00000000000..31916ff087f --- /dev/null +++ b/apps/web/__tests__/unit/developer-transcribe-fast-path.test.ts @@ -0,0 +1,674 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const context = vi.hoisted(() => ({ + fake: null as null | { + db: unknown; + rows: (table: object) => Record[]; + seed: ( + table: object, + row: Record, + ) => Record; + reset: () => void; + failNextCommit: () => void; + }, + objects: new Map(), + ledgerIds: 0, + transcribe: vi.fn(), + measured: null as number | null, + writes: new Map(), +})); + +vi.mock("@cap/database", async () => { + const schema = await import("@cap/database/schema"); + const { createFakeDatabase } = await import("./developer-transcript-fake-db"); + const fake = createFakeDatabase( + new Map Record>([ + [ + schema.developerTranscripts, + () => ({ + status: "queued", + sourceType: "upload", + sourceUrl: null, + requestedLanguage: null, + durationSeconds: null, + text: null, + resultKey: null, + reservedMicroCredits: 0, + billedMicroCredits: 0, + errorCode: null, + errorMessage: null, + deletedAt: null, + }), + ], + ]), + ); + context.fake = fake; + return { db: () => fake.db }; +}); + +vi.mock("@cap/database/helpers", () => ({ + nanoId: () => { + context.ledgerIds += 1; + return `ledger-${context.ledgerIds}`; + }, + nanoIdLength: 15, +})); + +vi.mock("@cap/env", () => ({ + serverEnv: () => ({ + MEDIA_SERVER_URL: "http://media.test", + MEDIA_SERVER_WEBHOOK_SECRET: "secret", + }), + buildEnv: { NEXT_PUBLIC_IS_CAP: "true" }, + NODE_ENV: "test", +})); + +vi.mock("@/lib/mp3-duration", async (importOriginal) => { + const actual = await importOriginal(); + return { + measureMp3DurationSeconds: (audio: Uint8Array) => + context.measured ?? actual.measureMp3DurationSeconds(audio), + }; +}); + +vi.mock("@/lib/developer-transcript-storage", () => ({ + developerObjectSize: async (key: string) => + context.objects.get(key)?.length ?? null, + developerObjectUrl: async (key: string) => `http://storage.test/${key}`, + getDeveloperObjectText: async (key: string) => { + const bytes = context.objects.get(key); + return bytes ? new TextDecoder().decode(bytes) : null; + }, + putDeveloperObject: async (key: string, body: string | Uint8Array) => { + context.writes.set(key, (context.writes.get(key) ?? 0) + 1); + context.objects.set( + key, + typeof body === "string" ? new TextEncoder().encode(body) : body, + ); + }, + putDeveloperObjectFile: async () => {}, + putDeveloperObjectStream: async () => {}, + deleteDeveloperObjects: async (keys: string[]) => { + for (const key of keys) context.objects.delete(key); + }, +})); + +vi.mock("@/lib/developer-transcript-speech", () => ({ + transcribeSpeech: context.transcribe, + parseTranscriptLanguage: () => "auto", +})); + +import { + developerCreditAccounts, + developerCreditTransactions, + developerTranscripts, +} from "@cap/database/schema"; +import { transcriptionMicroCredits } from "@/lib/developer-pricing"; +import { + developerTranscribeWorkflow, + FAST_PATH_BUDGET_MS, + FAST_PATH_MAX_AUDIO_SECONDS, + runTranscriptFastPath, +} from "@/workflows/developer-transcribe"; + +const SOURCE_KEY = "developer/_uploads/app-1/t-tr-1"; +const AUDIO_KEY = "developer/app-1/transcripts/tr-1/audio.mp3"; +const RESULT_KEY = "developer/app-1/transcripts/tr-1/result.json"; +const FRAME_SECONDS = 1152 / 44_100; + +function mp3Frames(count: number, bitrateIndex = 9) { + const bitrate = [0, 32, 40, 48, 56, 64, 80, 96, 112, 128][bitrateIndex] ?? 0; + const length = Math.floor((144_000 * bitrate) / 44_100); + const out = new Uint8Array(length * count); + for (let index = 0; index < count; index++) { + out.set([0xff, 0xfb, bitrateIndex << 4, 0x00], index * length); + } + return out; +} + +const wavHeader = () => { + const bytes = new Uint8Array(64); + bytes.set(new TextEncoder().encode("RIFF"), 0); + bytes.set(new TextEncoder().encode("WAVE"), 8); + return bytes; +}; + +type Media = { + probe: () => Response; + convert: () => Response; + check: () => Response; + convertCalls: number; + convertBytesPulled: number; + convertCancelled: boolean; +}; + +const media: Media = { + probe: () => new Response("{}", { status: 500 }), + convert: () => new Response(null, { status: 500 }), + check: () => Response.json({ hasAudio: true }), + convertCalls: 0, + convertBytesPulled: 0, + convertCancelled: false, +}; + +const streamOf = (chunk: Uint8Array, chunks: number) => { + let sent = 0; + return new ReadableStream({ + pull(controller) { + if (sent >= chunks) { + controller.close(); + return; + } + sent += 1; + media.convertBytesPulled += chunk.length; + controller.enqueue(chunk); + }, + cancel() { + media.convertCancelled = true; + }, + }); +}; + +const fake = () => { + if (!context.fake) throw new Error("Fake database is not ready"); + return context.fake; +}; +const transcript = () => fake().rows(developerTranscripts)[0]; +const balance = () => + fake().rows(developerCreditAccounts)[0]?.balanceMicroCredits; +const ledger = () => fake().rows(developerCreditTransactions); +const run = () => + developerTranscribeWorkflow({ transcriptId: "tr-1", runId: "run-1" }); + +beforeEach(() => { + fake().reset(); + context.objects = new Map([[SOURCE_KEY, wavHeader()]]); + context.ledgerIds = 0; + context.measured = null; + context.writes = new Map(); + context.transcribe.mockReset(); + context.transcribe.mockResolvedValue({ + state: "ok", + text: "hello world", + words: [ + { text: "hello", start: 0, end: 400, confidence: 0.9 }, + { text: "world", start: 450, end: 900, confidence: 0.9 }, + ], + language: "en", + durationSeconds: 30, + }); + media.probe = () => new Response("{}", { status: 500 }); + media.convert = () => new Response(null, { status: 500 }); + media.check = () => Response.json({ hasAudio: true }); + media.convertCalls = 0; + media.convertBytesPulled = 0; + media.convertCancelled = false; + fake().seed(developerCreditAccounts, { + id: "account-1", + appId: "app-1", + balanceMicroCredits: 100_000, + }); + fake().seed(developerTranscripts, { + id: "tr-1", + appId: "app-1", + runId: "run-1", + }); + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = String(input); + if (url.startsWith("http://storage.test/")) { + const bytes = context.objects.get( + url.slice("http://storage.test/".length), + ); + return bytes + ? new Response(bytes.slice(), { status: 200 }) + : new Response(null, { status: 404 }); + } + if (url === "http://media.test/video/probe") return media.probe(); + if (url === "http://media.test/audio/check") return media.check(); + if (url === "http://media.test/audio/convert") { + media.convertCalls += 1; + return media.convert(); + } + throw new Error(`Unexpected fetch ${url}`); + }); +}); + +afterEach(() => vi.unstubAllGlobals()); + +const thirtySeconds = () => { + const frames = Math.round(30 / FRAME_SECONDS); + media.convert = () => + new Response(streamOf(mp3Frames(frames), 1), { status: 200 }); + return transcriptionMicroCredits(frames * FRAME_SECONDS); +}; + +const fast = () => runTranscriptFastPath("tr-1", "run-1"); + +const failSettleOnce = () => { + const answer = context.transcribe.getMockImplementation(); + context.transcribe.mockImplementationOnce(async (...args: unknown[]) => { + fake().failNextCommit(); + return answer ? answer(...args) : undefined; + }); +}; + +const longSource = () => { + const bytes = new Uint8Array(1024 * 1024); + bytes.set(wavHeader()); + context.objects.set(SOURCE_KEY, bytes); + const account = fake().rows(developerCreditAccounts)[0]; + if (account) account.balanceMicroCredits = 1_000_000; +}; + +describe("the fast path", () => { + it("completes a short file in one step: claimed, held, extracted, transcribed and charged", async () => { + const cost = thirtySeconds(); + + const result = await fast(); + + expect(result).toMatchObject({ resume: "done", settlement: "complete" }); + expect(transcript()).toMatchObject({ + status: "complete", + text: "hello world", + billedMicroCredits: cost, + reservedMicroCredits: 0, + wordCount: 2, + }); + expect(balance()).toBe(100_000 - cost); + expect(ledger()).toHaveLength(1); + expect(context.transcribe).toHaveBeenCalledTimes(1); + expect(media.convertCalls).toBe(1); + }); + + it("hands the audio to speech recognition without reading it back from storage", async () => { + thirtySeconds(); + const reads: string[] = []; + const realFetch = globalThis.fetch; + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + reads.push(String(input)); + return realFetch(input); + }); + + await fast(); + + expect(reads.filter((url) => url.endsWith(AUDIO_KEY))).toEqual([]); + expect(context.objects.has(AUDIO_KEY)).toBe(true); + }); + + it("leaves nothing for the rest of the workflow to redo", async () => { + const cost = thirtySeconds(); + + const result = await run(); + + expect(result).toEqual({ success: true, message: "Transcript completed" }); + expect(context.transcribe).toHaveBeenCalledTimes(1); + expect(media.convertCalls).toBe(1); + expect(context.writes.get(RESULT_KEY)).toBe(1); + expect(context.writes.get(AUDIO_KEY)).toBe(1); + expect(ledger()).toHaveLength(1); + expect(balance()).toBe(100_000 - cost); + expect([...context.objects.keys()]).toEqual([RESULT_KEY]); + }); + + it("does not charge, transcribe or write twice when the whole step runs again", async () => { + const cost = thirtySeconds(); + + const first = await fast(); + const second = await fast(); + + expect(first).toMatchObject({ resume: "done", settlement: "complete" }); + expect(second).toEqual({ resume: "finalize", settlement: "complete" }); + expect(context.transcribe).toHaveBeenCalledTimes(1); + expect(context.writes.get(RESULT_KEY)).toBe(1); + expect(ledger()).toHaveLength(1); + expect(balance()).toBe(100_000 - cost); + }); + + it("still cleans up and writes captions when it runs again after its settle was saved", async () => { + const cost = thirtySeconds(); + expect(await fast()).toMatchObject({ resume: "done" }); + context.objects.set(SOURCE_KEY, wavHeader()); + + const result = await run(); + + expect(result).toEqual({ success: true, message: "Transcript completed" }); + expect([...context.objects.keys()]).toEqual([RESULT_KEY]); + expect(context.transcribe).toHaveBeenCalledTimes(1); + expect(ledger()).toHaveLength(1); + expect(balance()).toBe(100_000 - cost); + }); + + it("cleans up when it runs again after its own settle ended the transcript for lack of credit", async () => { + thirtySeconds(); + const settled = context.transcribe.getMockImplementation(); + context.transcribe.mockImplementationOnce(async (...args: unknown[]) => { + const account = fake().rows(developerCreditAccounts)[0]; + if (account) account.balanceMicroCredits = 0; + return settled ? settled(...args) : undefined; + }); + expect(await fast()).toMatchObject({ + resume: "done", + settlement: "insufficient_credits", + }); + context.objects.set(SOURCE_KEY, wavHeader()); + + const result = await run(); + + expect(result).toEqual({ + success: false, + message: "Transcript was not completed: skipped", + }); + expect([...context.objects.keys()]).toEqual([]); + expect(ledger()).toHaveLength(0); + }); + + it("leaves a transcript another run owns alone even when it is finished", async () => { + thirtySeconds(); + await fast(); + const row = transcript(); + if (row) row.runId = "run-2"; + + expect(await fast()).toEqual({ resume: "unclaimed" }); + expect(context.objects.has(RESULT_KEY)).toBe(true); + }); + + const stopAfterExtraction = () => { + let calls = 0; + return vi + .spyOn(performance, "now") + .mockImplementation(() => (calls++ < 2 ? 0 : FAST_PATH_BUDGET_MS + 1)); + }; + + const deleteWhileProcessing = () => { + const row = transcript(); + if (!row) throw new Error("no transcript"); + row.deletedAt = new Date("2026-11-13T05:00:00.000Z"); + row.text = null; + }; + + it("charges a transcript deleted after its speech finished when the whole step runs again", async () => { + const cost = thirtySeconds(); + failSettleOnce(); + expect(await fast()).toMatchObject({ resume: "settle" }); + expect(transcript()?.status).toBe("processing"); + deleteWhileProcessing(); + + const retry = await fast(); + + expect(retry).toMatchObject({ resume: "done", settlement: "complete" }); + expect(context.transcribe).toHaveBeenCalledTimes(1); + expect(ledger()).toHaveLength(1); + expect(balance()).toBe(100_000 - cost); + expect(transcript()).toMatchObject({ + status: "complete", + billedMicroCredits: cost, + reservedMicroCredits: 0, + text: null, + }); + expect(transcript()?.deletedAt).toBeInstanceOf(Date); + + expect((await run()).success).toBe(true); + expect([...context.objects.keys()]).toEqual([]); + expect(ledger()).toHaveLength(1); + }); + + it("does not ask for the source hold again when it runs again after extraction", async () => { + const cost = thirtySeconds(); + const clock = stopAfterExtraction(); + expect(await fast()).toEqual({ resume: "transcribe" }); + clock.mockRestore(); + expect(Number(transcript()?.reservedMicroCredits)).toBe(cost); + fake().seed(developerTranscripts, { + id: "tr-other", + appId: "app-1", + status: "processing", + reservedMicroCredits: 99_500, + }); + + const retry = await fast(); + + expect(retry).toMatchObject({ resume: "done", settlement: "complete" }); + expect(media.convertCalls).toBe(1); + expect(context.transcribe).toHaveBeenCalledTimes(1); + expect(ledger()).toHaveLength(1); + expect(balance()).toBe(100_000 - cost); + }); + + it("finishes through the workflow without a new hold when it runs again after extraction", async () => { + const cost = thirtySeconds(); + const clock = stopAfterExtraction(); + await fast(); + clock.mockRestore(); + fake().seed(developerTranscripts, { + id: "tr-other", + appId: "app-1", + status: "processing", + reservedMicroCredits: 99_500, + }); + + const result = await run(); + + expect(result).toEqual({ success: true, message: "Transcript completed" }); + expect(context.transcribe).toHaveBeenCalledTimes(1); + expect(ledger()).toHaveLength(1); + expect(balance()).toBe(100_000 - cost); + }); + + it("ends a transcript deleted before its speech without a charge when the step runs again", async () => { + thirtySeconds(); + const clock = stopAfterExtraction(); + await fast(); + clock.mockRestore(); + deleteWhileProcessing(); + + const result = await run(); + + expect(result.success).toBe(false); + expect(context.transcribe).not.toHaveBeenCalled(); + expect(transcript()).toMatchObject({ + status: "error", + billedMicroCredits: 0, + reservedMicroCredits: 0, + }); + expect(ledger()).toHaveLength(0); + expect([...context.objects.keys()]).toEqual([]); + }); + + it("leaves a run that was never claimed alone", async () => { + thirtySeconds(); + const row = transcript(); + if (row) row.status = "processing"; + if (row) row.runId = "run-other"; + + expect(await fast()).toEqual({ resume: "unclaimed" }); + expect(media.convertCalls).toBe(0); + expect(context.transcribe).not.toHaveBeenCalled(); + }); + + it("does not transcribe or charge twice when it runs again after the settle failed", async () => { + const cost = thirtySeconds(); + failSettleOnce(); + + const first = await fast(); + expect(first).toMatchObject({ resume: "settle" }); + expect(transcript()?.status).toBe("processing"); + expect(ledger()).toHaveLength(0); + + const second = await fast(); + + expect(second).toMatchObject({ resume: "done", settlement: "complete" }); + expect(context.transcribe).toHaveBeenCalledTimes(1); + expect(context.writes.get(RESULT_KEY)).toBe(1); + expect(ledger()).toHaveLength(1); + expect(balance()).toBe(100_000 - cost); + }); + + it("is finished step by step when its settle fails, with one charge", async () => { + const cost = thirtySeconds(); + failSettleOnce(); + + const result = await run(); + + expect(result.success).toBe(true); + expect(context.transcribe).toHaveBeenCalledTimes(1); + expect(ledger()).toHaveLength(1); + expect(balance()).toBe(100_000 - cost); + }); + + it("hands a long recording to its own transcription step", async () => { + thirtySeconds(); + context.measured = FAST_PATH_MAX_AUDIO_SECONDS + 1; + longSource(); + + const result = await fast(); + + expect(result).toEqual({ resume: "transcribe" }); + expect(context.transcribe).not.toHaveBeenCalled(); + expect(transcript()).toMatchObject({ + status: "processing", + durationSeconds: FAST_PATH_MAX_AUDIO_SECONDS + 1, + }); + + const finished = await run(); + expect(finished).toEqual({ + success: true, + message: "Transcript completed", + }); + expect(media.convertCalls).toBe(1); + expect(context.transcribe).toHaveBeenCalledTimes(1); + expect(ledger()).toHaveLength(1); + }); + + it("finishes a long recording through the separate step with the same charge", async () => { + thirtySeconds(); + context.measured = FAST_PATH_MAX_AUDIO_SECONDS + 1; + longSource(); + + const result = await run(); + + expect(result.success).toBe(true); + expect(context.transcribe).toHaveBeenCalledTimes(1); + expect(transcript()).toMatchObject({ + status: "complete", + billedMicroCredits: transcriptionMicroCredits( + FAST_PATH_MAX_AUDIO_SECONDS + 1, + ), + }); + expect(ledger()).toHaveLength(1); + }); + + it("hands extraction to its own step when getting the file took most of the budget", async () => { + thirtySeconds(); + let calls = 0; + const clock = vi + .spyOn(performance, "now") + .mockImplementation(() => (calls++ === 0 ? 0 : FAST_PATH_BUDGET_MS + 1)); + + const result = await fast(); + clock.mockRestore(); + + expect(result).toEqual({ resume: "extract", audio: null }); + expect(media.convertCalls).toBe(0); + expect(Number(transcript()?.reservedMicroCredits)).toBeGreaterThan(0); + + expect((await run()).success).toBe(true); + expect(media.convertCalls).toBe(1); + expect(context.transcribe).toHaveBeenCalledTimes(1); + expect(ledger()).toHaveLength(1); + }); + + it("hands transcription to its own step when extraction took most of the budget", async () => { + thirtySeconds(); + let calls = 0; + const clock = vi + .spyOn(performance, "now") + .mockImplementation(() => (calls++ < 2 ? 0 : FAST_PATH_BUDGET_MS + 1)); + + const result = await fast(); + clock.mockRestore(); + + expect(result).toEqual({ resume: "transcribe" }); + expect(media.convertCalls).toBe(1); + expect(context.transcribe).not.toHaveBeenCalled(); + }); + + it("hands over at the hold when another transcript holds the credit", async () => { + thirtySeconds(); + fake().seed(developerTranscripts, { + id: "tr-other", + appId: "app-1", + status: "processing", + reservedMicroCredits: 99_500, + }); + + const result = await fast(); + + expect(result).toMatchObject({ + resume: "prepare", + source: { ok: false, wait: true }, + }); + expect(media.convertCalls).toBe(0); + expect(context.transcribe).not.toHaveBeenCalled(); + }); + + it("hands over to the hold raise when the audio is longer than its file suggested", async () => { + const frames = Math.round(30 / FRAME_SECONDS); + media.probe = () => + Response.json({ + metadata: { duration: 3 * 3600, width: 1, height: 1 }, + }); + media.convert = () => + new Response(streamOf(mp3Frames(frames), 1), { status: 200 }); + + const result = await fast(); + + expect(result).toMatchObject({ + resume: "extract", + audio: { ok: false, raise: true, reportedSeconds: 3 * 3600 }, + }); + expect(context.transcribe).not.toHaveBeenCalled(); + }); + + it("keeps the failure code and message of a file it cannot use", async () => { + context.objects.set( + SOURCE_KEY, + new TextEncoder().encode("#EXTM3U\nhttp://127.0.0.1/secret.ts\n"), + ); + + const result = await run(); + + expect(result).toEqual({ + success: false, + message: "The file is not a supported audio or video format", + }); + expect(transcript()).toMatchObject({ + status: "error", + errorCode: "unsupported_format", + errorMessage: "The file is not a supported audio or video format", + billedMicroCredits: 0, + }); + }); + + it("keeps the failure code when speech recognition keeps failing", async () => { + thirtySeconds(); + context.transcribe.mockRejectedValue(new Error("speech is down")); + + await expect(run()).rejects.toThrow("speech is down"); + + expect(transcript()).toMatchObject({ + status: "error", + errorCode: "transcription_failed", + errorMessage: "The transcript could not be created", + billedMicroCredits: 0, + }); + expect(ledger()).toHaveLength(0); + }); + + it("does nothing for a run that no longer owns the transcript", async () => { + const row = transcript(); + if (row) row.runId = "run-2"; + + expect(await fast()).toEqual({ resume: "unclaimed" }); + expect(media.convertCalls).toBe(0); + }); +}); diff --git a/apps/web/__tests__/unit/developer-transcribe-no-speech.test.ts b/apps/web/__tests__/unit/developer-transcribe-no-speech.test.ts index 7b4205a58fc..6802a2cff5a 100644 --- a/apps/web/__tests__/unit/developer-transcribe-no-speech.test.ts +++ b/apps/web/__tests__/unit/developer-transcribe-no-speech.test.ts @@ -338,7 +338,7 @@ describe("audio with no speech", () => { resultKey: RESULT_KEY, }); - expect(again.message).toBe("Transcript is not waiting to run"); + expect(again.message).toBe("Transcript completed"); expect(resettled).toEqual({ state: "complete", chargedMicroCredits: 0, diff --git a/apps/web/__tests__/unit/developer-transcribe-workflow.test.ts b/apps/web/__tests__/unit/developer-transcribe-workflow.test.ts index 007252b45ef..7a08db28fbc 100644 --- a/apps/web/__tests__/unit/developer-transcribe-workflow.test.ts +++ b/apps/web/__tests__/unit/developer-transcribe-workflow.test.ts @@ -266,7 +266,8 @@ describe("developer transcribe workflow", () => { expect([...context.objects.keys()]).toEqual([RESULT_KEY]); const again = await run(); - expect(again.message).toBe("Transcript is not waiting to run"); + expect(again.message).toBe("Transcript completed"); + expect([...context.objects.keys()]).toEqual([RESULT_KEY]); expect(ledger()).toHaveLength(1); expect(context.transcribe).toHaveBeenCalledTimes(1); }); @@ -444,16 +445,25 @@ describe("developer transcribe workflow", () => { expect(transcript()?.reservedMicroCredits).toBe(0); }); - it("costs nothing when deleted after the audio was read but before it was sent", async () => { + it("costs nothing when deleted after the audio was extracted and held for but before it was sent", async () => { thirtySeconds(); - const realFetch = globalThis.fetch; - vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { - const response = await realFetch(input); - if (String(input) === `http://storage.test/${AUDIO_KEY}`) deleteNow(); - return response; - }); + const storage = await import("@/lib/developer-transcript-storage"); + let reserved = 0; + const read = vi + .spyOn(storage, "getDeveloperObjectText") + .mockImplementation(async (key: string) => { + const bytes = context.objects.get(key); + if (key === RESULT_KEY && !bytes) { + reserved = Number(transcript()?.reservedMicroCredits); + deleteNow(); + } + return bytes ? new TextDecoder().decode(bytes) : null; + }); const result = await run(); + read.mockRestore(); + + expect(reserved).toBeGreaterThan(0); expect(result.success).toBe(false); expect(context.transcribe).not.toHaveBeenCalled(); @@ -511,10 +521,13 @@ describe("developer transcribe workflow", () => { throw new Error("speech is down"); }); - await expect(run()).rejects.toThrow("speech is down"); + const result = await run(); + expect(result.success).toBe(false); + expect(context.transcribe).toHaveBeenCalledTimes(1); expect(transcript()).toMatchObject({ status: "error", + errorCode: "source_missing", billedMicroCredits: 0, reservedMicroCredits: 0, }); diff --git a/apps/web/__tests__/unit/developer-transcript-store.test.ts b/apps/web/__tests__/unit/developer-transcript-store.test.ts index d18dda13969..acc765e589a 100644 --- a/apps/web/__tests__/unit/developer-transcript-store.test.ts +++ b/apps/web/__tests__/unit/developer-transcript-store.test.ts @@ -1459,3 +1459,32 @@ describe("input validation", () => { expect(isTranscriptIdempotencyKey(undefined)).toBe(false); }); }); + +describe("deleting a transcript that is still running", () => { + it("leaves its files for the run to settle and clean up, and removes them otherwise", async () => { + for (const [status, kept] of [ + ["processing", true], + ["queued", false], + ["complete", false], + ["error", false], + ] as const) { + context.deleted = []; + const id = `tr-${status}`; + fake().seed(developerTranscripts, { + id, + appId: "app-1", + status, + sourceType: "upload", + }); + + expect(await deleteTranscript("app-1", id)).toEqual({ ok: true }); + + expect(context.deleted.length > 0).toBe(!kept); + expect( + fake() + .rows(developerTranscripts) + .find((row) => row.id === id)?.deletedAt, + ).toBeInstanceOf(Date); + } + }); +}); diff --git a/apps/web/lib/developer-transcript-run.ts b/apps/web/lib/developer-transcript-run.ts index cd846fcc484..1064a690502 100644 --- a/apps/web/lib/developer-transcript-run.ts +++ b/apps/web/lib/developer-transcript-run.ts @@ -38,6 +38,17 @@ export async function claimTranscriptRun(transcriptId: string, runId: string) { ); } +export async function runOwnedState( + transcriptId: string, + runId: string, +): Promise<"complete" | "ended" | "processing" | null> { + const row = await loadRun(transcriptId); + if (!row || row.runId !== runId) return null; + if (row.status === "complete") return "complete"; + if (row.status === "processing") return "processing"; + return row.status === "error" ? "ended" : null; +} + export async function transcriptAwaitsSpeech(transcriptId: string) { const row = await loadRun(transcriptId); return row !== null && !row.deletedAt && row.status === "processing"; diff --git a/apps/web/lib/developer-transcript-store.ts b/apps/web/lib/developer-transcript-store.ts index 8c2526a3547..a29a5bcaf3f 100644 --- a/apps/web/lib/developer-transcript-store.ts +++ b/apps/web/lib/developer-transcript-store.ts @@ -1034,8 +1034,12 @@ export async function deleteTranscript( async function syncDeletedTranscriptCaptions( appId: string, transcriptId: string, + known?: TranscriptRow | null, ) { - const transcript = await findTranscriptAnyState(appId, transcriptId); + const transcript = + known === undefined + ? await findTranscriptAnyState(appId, transcriptId) + : known; if ( !transcript?.deletedAt || !transcript.videoId || @@ -1055,9 +1059,12 @@ export async function deleteTranscriptObjects( appId: string, transcriptId: string, ) { - const keys = transcriptObjectKeys(appId, transcriptId); - await deleteDeveloperObjects([keys.source, keys.audio, keys.result]); - await syncDeletedTranscriptCaptions(appId, transcriptId); + const transcript = await findTranscriptAnyState(appId, transcriptId); + if (transcript?.status !== "processing") { + const keys = transcriptObjectKeys(appId, transcriptId); + await deleteDeveloperObjects([keys.source, keys.audio, keys.result]); + } + await syncDeletedTranscriptCaptions(appId, transcriptId, transcript); } const CAPTION_RECONCILE_WINDOW_MS = 3 * 60 * 60 * 1000; diff --git a/apps/web/workflows/developer-transcribe.ts b/apps/web/workflows/developer-transcribe.ts index ef6fc0ef8a9..860887a19c8 100644 --- a/apps/web/workflows/developer-transcribe.ts +++ b/apps/web/workflows/developer-transcribe.ts @@ -30,6 +30,7 @@ import { import { claimTranscriptRun, failTranscriptRun, + runOwnedState, transcriptAwaitsSpeech, } from "@/lib/developer-transcript-run"; import { @@ -197,8 +198,17 @@ export async function developerTranscribeWorkflow( const { transcriptId, runId } = payload; try { - const claimed = await claimTranscript(transcriptId, runId); - if (!claimed) { + const fast = await runTranscriptFastPath(transcriptId, runId); + if (fast.resume === "unclaimed") { + return { success: true, message: "Transcript is not waiting to run" }; + } + if (fast.resume === "finalize") { + return finishTranscript(transcriptId, fast.settlement); + } + if ( + fast.resume === "claim" && + !(await claimTranscript(transcriptId, runId)) + ) { return { success: true, message: "Transcript is not waiting to run" }; } @@ -214,38 +224,55 @@ export async function developerTranscribeWorkflow( return { success: false, message: failed.message }; }; - let source = await prepareTranscriptSource(transcriptId, null); - while (!source.ok && "wait" in source) { - const next = nextHoldWait(gate, source); - if ("code" in next) return stop(next); - await sleep(`${next.seconds}s`); - source = await prepareTranscriptSource( - transcriptId, - source.known ?? { bytes: null }, - ); + if (fast.resume === "claim" || fast.resume === "prepare") { + let source = + fast.resume === "prepare" && fast.source + ? fast.source + : await prepareTranscriptSource(transcriptId, null); + while (!source.ok && "wait" in source) { + const next = nextHoldWait(gate, source); + if ("code" in next) return stop(next); + await sleep(`${next.seconds}s`); + source = await prepareTranscriptSource( + transcriptId, + source.known ?? { bytes: null }, + ); + } + if (!source.ok) return stop(source); } - if (!source.ok) return stop(source); - let audio = await extractTranscriptAudio(transcriptId); - for ( - let raises = 0; - !audio.ok && "raise" in audio && raises < HOLD_RAISE_LIMIT; - raises++ + if ( + fast.resume === "claim" || + fast.resume === "prepare" || + fast.resume === "extract" ) { - const { reportedSeconds } = audio; - let raised = await raiseTranscriptHold(transcriptId, reportedSeconds); - while (!raised.ok && "wait" in raised) { - const next = nextHoldWait(gate, raised); - if ("code" in next) return stop(next); - await sleep(`${next.seconds}s`); - raised = await raiseTranscriptHold(transcriptId, reportedSeconds); + let audio = + fast.resume === "extract" && fast.audio + ? fast.audio + : await extractTranscriptAudio(transcriptId); + for ( + let raises = 0; + !audio.ok && "raise" in audio && raises < HOLD_RAISE_LIMIT; + raises++ + ) { + const { reportedSeconds } = audio; + let raised = await raiseTranscriptHold(transcriptId, reportedSeconds); + while (!raised.ok && "wait" in raised) { + const next = nextHoldWait(gate, raised); + if ("code" in next) return stop(next); + await sleep(`${next.seconds}s`); + raised = await raiseTranscriptHold(transcriptId, reportedSeconds); + } + if (!raised.ok) return stop(raised); + audio = await extractTranscriptAudio(transcriptId); } - if (!raised.ok) return stop(raised); - audio = await extractTranscriptAudio(transcriptId); + if (!audio.ok) return stop(audio); } - if (!audio.ok) return stop(audio); - const transcription = await transcribeTranscriptAudio(transcriptId); + const transcription = + fast.resume === "settle" || fast.resume === "done" + ? fast.transcription + : await transcribeTranscriptAudio(transcriptId); if (!transcription.ok) { await failTranscriptStep( transcriptId, @@ -256,16 +283,12 @@ export async function developerTranscribeWorkflow( return { success: false, message: transcription.message }; } - const settlement = await settleTranscriptStep(transcriptId, transcription); - await cleanupTranscriptObjects(transcriptId, settlement !== "complete"); - if (settlement === "complete") await writeVideoCaptions(transcriptId); - return { - success: settlement === "complete", - message: - settlement === "complete" - ? "Transcript completed" - : `Transcript was not completed: ${settlement}`, - }; + return finishTranscript( + transcriptId, + fast.resume === "done" + ? fast.settlement + : await settleTranscriptStep(transcriptId, transcription), + ); } catch (error) { await failTranscriptStep( transcriptId, @@ -277,6 +300,18 @@ export async function developerTranscribeWorkflow( } } +async function finishTranscript(transcriptId: string, settlement: Settlement) { + await cleanupTranscriptObjects(transcriptId, settlement !== "complete"); + if (settlement === "complete") await writeVideoCaptions(transcriptId); + return { + success: settlement === "complete", + message: + settlement === "complete" + ? "Transcript completed" + : `Transcript was not completed: ${settlement}`, + }; +} + async function loadTranscript(transcriptId: string) { const row = await loadTranscriptRow(transcriptId); return row && !row.deletedAt ? row : null; @@ -303,6 +338,128 @@ async function loadTranscriptRow(transcriptId: string) { return row ?? null; } +export const FAST_PATH_MAX_AUDIO_SECONDS = 30 * 60; +export const FAST_PATH_BUDGET_MS = 2 * 60 * 1000; + +type FastPath = + | { resume: "unclaimed" } + | { resume: "finalize"; settlement: Settlement } + | { resume: "claim" } + | { resume: "prepare"; source: StepFailure | HoldWait | null } + | { resume: "extract"; audio: StepFailure | HoldRaise | null } + | { resume: "transcribe" } + | { resume: "settle"; transcription: TranscriptionSummary | StepFailure } + | { + resume: "done"; + transcription: TranscriptionSummary; + settlement: Settlement; + }; + +const handOff = (transcriptId: string, stage: string, error: unknown) => { + console.error( + `[developer-transcribe] ${stage} failed in the fast path for ${transcriptId}, continuing step by step`, + error, + ); +}; + +export async function runTranscriptFastPath( + transcriptId: string, + runId: string, +): Promise { + "use step"; + + const startedAt = performance.now(); + const overBudget = () => performance.now() - startedAt > FAST_PATH_BUDGET_MS; + let saved: ExtractedAudio | null; + try { + if (await claimTranscriptRun(transcriptId, runId)) { + saved = await savedExtraction(transcriptId); + } else { + const state = await runOwnedState(transcriptId, runId); + if (state === "complete") { + return { resume: "finalize", settlement: "complete" }; + } + if (state === "ended") { + return { resume: "finalize", settlement: "skipped" }; + } + if (state !== "processing") return { resume: "unclaimed" }; + saved = (await savedExtraction(transcriptId)) ?? { + ok: true, + audio: null, + durationSeconds: 0, + }; + } + } catch (error) { + handOff(transcriptId, "claim", error); + return { resume: "claim" }; + } + + let extracted: ExtractedAudio; + if (saved) { + extracted = saved; + } else { + try { + const source = await prepareSource(transcriptId, null); + if (!source.ok) return { resume: "prepare", source }; + } catch (error) { + handOff(transcriptId, "prepare", error); + return { resume: "prepare", source: null }; + } + + if (overBudget()) return { resume: "extract", audio: null }; + + try { + const audio = await extractAudio(transcriptId); + if (!audio.ok) return { resume: "extract", audio }; + extracted = audio; + } catch (error) { + handOff(transcriptId, "extract", error); + return { resume: "extract", audio: null }; + } + } + + if (extracted.durationSeconds > FAST_PATH_MAX_AUDIO_SECONDS || overBudget()) { + return { resume: "transcribe" }; + } + + let transcription: TranscriptionSummary | StepFailure; + try { + transcription = await transcribeAudio(transcriptId, extracted.audio); + } catch (error) { + handOff(transcriptId, "transcribe", error); + return { resume: "transcribe" }; + } + if (!transcription.ok) return { resume: "settle", transcription }; + + try { + return { + resume: "done", + transcription, + settlement: await settleSummary(transcriptId, transcription), + }; + } catch (error) { + handOff(transcriptId, "settle", error); + return { resume: "settle", transcription }; + } +} + +async function savedExtraction( + transcriptId: string, +): Promise { + const transcript = await loadTranscriptRow(transcriptId); + if (!transcript || transcript.durationSeconds === null) return null; + const keys = transcriptObjectKeys(transcript.appId, transcript.id); + const audioSaved = ((await developerObjectSize(keys.audio)) ?? 0) > 0; + if (!audioSaved && (await getDeveloperObjectText(keys.result)) === null) { + return null; + } + return { + ok: true, + audio: null, + durationSeconds: transcript.durationSeconds, + }; +} + async function claimTranscript( transcriptId: string, runId: string, @@ -417,6 +574,13 @@ async function prepareTranscriptSource( ): Promise<{ ok: true } | StepFailure | HoldWait> { "use step"; + return prepareSource(transcriptId, known); +} + +async function prepareSource( + transcriptId: string, + known: KnownSource | null, +): Promise<{ ok: true } | StepFailure | HoldWait> { const transcript = await loadTranscript(transcriptId); if (!transcript) { return failure("source_missing", FAILURE_MESSAGES.source_missing); @@ -590,6 +754,19 @@ async function extractTranscriptAudio( ): Promise<{ ok: true } | StepFailure | HoldRaise> { "use step"; + const extracted = await extractAudio(transcriptId); + return extracted.ok ? { ok: true } : extracted; +} + +type ExtractedAudio = { + ok: true; + audio: Buffer | null; + durationSeconds: number; +}; + +async function extractAudio( + transcriptId: string, +): Promise { const transcript = await loadTranscript(transcriptId); if (!transcript) { return failure("source_missing", FAILURE_MESSAGES.source_missing); @@ -605,7 +782,11 @@ async function extractTranscriptAudio( transcript.durationSeconds !== null && ((await developerObjectSize(keys.audio)) ?? 0) > 0 ) { - return { ok: true }; + return { + ok: true, + audio: null, + durationSeconds: transcript.durationSeconds, + }; } if (transcript.sourceType === "video" && !transcript.s3Key) { return failure("source_missing", FAILURE_MESSAGES.video_missing); @@ -657,7 +838,7 @@ async function extractTranscriptAudio( if (reservation.state === "skipped") { return failure("source_missing", FAILURE_MESSAGES.source_missing); } - return { ok: true }; + return { ok: true, audio, durationSeconds }; } type TranscriptionSummary = { @@ -672,6 +853,21 @@ async function transcribeTranscriptAudio( ): Promise { "use step"; + return transcribeAudio(transcriptId, null); +} + +async function readExtractedAudio(key: string) { + const audioResponse = await fetchStorageUrl(await developerObjectUrl(key)); + if (!audioResponse.ok || !audioResponse.body) { + throw new Error(`Extracted audio is not readable: ${audioResponse.status}`); + } + return readStreamBounded(audioResponse.body, TRANSCRIPT_AUDIO_MAX_BYTES); +} + +async function transcribeAudio( + transcriptId: string, + extracted: Buffer | null, +): Promise { const transcript = await loadTranscriptRow(transcriptId); if (!transcript) { return failure("source_missing", FAILURE_MESSAGES.source_missing); @@ -692,18 +888,9 @@ async function transcribeTranscriptAudio( return failure("source_missing", FAILURE_MESSAGES.deleted); } - const audioResponse = await fetchStorageUrl( - await developerObjectUrl(keys.audio), - ); - if (!audioResponse.ok || !audioResponse.body) { - throw new Error(`Extracted audio is not readable: ${audioResponse.status}`); - } let audio: Buffer; try { - audio = await readStreamBounded( - audioResponse.body, - TRANSCRIPT_AUDIO_MAX_BYTES, - ); + audio = extracted ?? (await readExtractedAudio(keys.audio)); } catch (error) { if (error instanceof StreamTooLargeError) { return failure("duration_too_long", FAILURE_MESSAGES.duration_too_long); @@ -749,9 +936,18 @@ async function transcribeTranscriptAudio( async function settleTranscriptStep( transcriptId: string, summary: TranscriptionSummary, -): Promise<"complete" | "insufficient_credits" | "skipped"> { +): Promise { "use step"; + return settleSummary(transcriptId, summary); +} + +type Settlement = "complete" | "insufficient_credits" | "skipped"; + +async function settleSummary( + transcriptId: string, + summary: TranscriptionSummary, +): Promise { const transcript = await loadTranscriptRow(transcriptId); if (!transcript) return "skipped"; const keys = transcriptObjectKeys(transcript.appId, transcript.id);