From 1073c15e02f70f5efbd95eac2fb9b979d50e5fe3 Mon Sep 17 00:00:00 2001 From: Walker Date: Thu, 9 Jul 2026 16:42:09 -0700 Subject: [PATCH 1/3] feat: add sleep to translate-audio call --- src/workflows/translate-audio.ts | 57 ++++++++++++-------------------- 1 file changed, 21 insertions(+), 36 deletions(-) diff --git a/src/workflows/translate-audio.ts b/src/workflows/translate-audio.ts index 29a5696..7aea745 100644 --- a/src/workflows/translate-audio.ts +++ b/src/workflows/translate-audio.ts @@ -1,3 +1,5 @@ +import { sleep } from "workflow"; + import env from "../env.ts"; import { getApiKeyFromEnv } from "../lib/client-factory.ts"; import { getLanguageCodePair, toISO639_1, toISO639_3 } from "../lib/language-codes.ts"; @@ -84,11 +86,6 @@ export interface AudioTranslationOptions extends MuxAIOptions { const STATIC_RENDITION_POLL_INTERVAL_MS = 5000; const STATIC_RENDITION_MAX_ATTEMPTS = 36; // ~3 minutes -async function sleep(ms: number): Promise { - "use step"; - await new Promise(resolve => setTimeout(resolve, ms)); -} - function getReadyAudioStaticRendition(asset: any) { const files = asset.static_renditions?.files as any[] | undefined; if (!files || files.length === 0) { @@ -144,6 +141,18 @@ async function requestStaticRenditionCreation( } } +async function retrieveAsset( + assetId: string, + credentials?: WorkflowCredentialsInput, +): Promise { + "use step"; + const muxClient = await resolveMuxClient(credentials); + const mux = await muxClient.createClient(); + return mux.video.assets.retrieve(assetId); +} + +// Orchestration-level (not a step): the poll loop must call the durable `sleep`, +// which suspends the workflow between retries without holding the function open. async function waitForAudioStaticRendition({ assetId, initialAsset, @@ -153,9 +162,6 @@ async function waitForAudioStaticRendition({ initialAsset: any; credentials?: WorkflowCredentialsInput; }): Promise { - "use step"; - const muxClient = await resolveMuxClient(credentials); - const mux = await muxClient.createClient(); let currentAsset = initialAsset; if (hasReadyAudioStaticRendition(currentAsset)) { @@ -174,7 +180,7 @@ async function waitForAudioStaticRendition({ for (let attempt = 1; attempt <= STATIC_RENDITION_MAX_ATTEMPTS; attempt++) { await sleep(STATIC_RENDITION_POLL_INTERVAL_MS); - currentAsset = await mux.video.assets.retrieve(assetId); + currentAsset = await retrieveAsset(assetId, credentials); if (hasReadyAudioStaticRendition(currentAsset)) { return currentAsset; @@ -198,26 +204,15 @@ async function waitForAudioStaticRendition({ ); } -async function fetchAudioFromMux(audioUrl: string): Promise { - "use step"; - - const audioResponse = await fetch(audioUrl); - if (!audioResponse.ok) { - throw new Error(`Failed to fetch audio file: ${audioResponse.statusText}`); - } - - return audioResponse.arrayBuffer(); -} - async function createElevenLabsDubbingJob({ - audioBuffer, + sourceUrl, assetId, elevenLabsLangCode, elevenLabsSourceLangCode, numSpeakers, credentials, }: { - audioBuffer: ArrayBuffer; + sourceUrl: string; assetId: string; elevenLabsLangCode: string; elevenLabsSourceLangCode?: string; @@ -227,10 +222,10 @@ async function createElevenLabsDubbingJob({ "use step"; const elevenLabsApiKey = await getApiKeyFromEnv("elevenlabs", credentials); - const audioBlob = new Blob([audioBuffer], { type: "audio/mp4" }); - + // Hand ElevenLabs the Mux audio URL directly so it fetches the source itself, + // instead of downloading the bytes onto the workflow runner and re-uploading them. const formData = new FormData(); - formData.append("file", audioBlob); + formData.append("source_url", sourceUrl); formData.append("target_lang", elevenLabsLangCode); if (elevenLabsSourceLangCode) { formData.append("source_lang", elevenLabsSourceLangCode); @@ -460,16 +455,6 @@ export async function translateAudio( audioUrl = await signUrl(audioUrl, playbackId, "video", undefined, credentials); } - // Fetch audio from Mux - console.warn("🎙️ Fetching audio from Mux..."); - - let audioBuffer: ArrayBuffer; - try { - audioBuffer = await fetchAudioFromMux(audioUrl); - } catch (error) { - wrapError(error, "Failed to fetch audio from Mux"); - } - // Create dubbing job in ElevenLabs console.warn("🎙️ Creating dubbing job in ElevenLabs..."); @@ -484,7 +469,7 @@ export async function translateAudio( let dubbingId: string; try { dubbingId = await createElevenLabsDubbingJob({ - audioBuffer, + sourceUrl: audioUrl, assetId, elevenLabsLangCode, elevenLabsSourceLangCode, From 247b61188e62812a4d11df555cf8f36dfbb500ae Mon Sep 17 00:00:00 2001 From: Walker Date: Thu, 9 Jul 2026 16:56:47 -0700 Subject: [PATCH 2/3] update --- .github/workflows/integration-tests.yml | 4 +- .../workflows/translate-audio-integration.yml | 56 ------------------- tests/integration/translate-audio.test.ts | 38 ------------- 3 files changed, 2 insertions(+), 96 deletions(-) delete mode 100644 .github/workflows/translate-audio-integration.yml delete mode 100644 tests/integration/translate-audio.test.ts diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 90bb8ae..2e182e5 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -24,7 +24,7 @@ jobs: - name: Install dependencies run: npm ci - - name: Run integration tests (excluding long-running audio translation test) + - name: Run integration tests env: MUX_TOKEN_ID: ${{ secrets.MUX_TOKEN_ID }} MUX_TOKEN_SECRET: ${{ secrets.MUX_TOKEN_SECRET }} @@ -49,7 +49,7 @@ jobs: MUX_TEST_ASSET_ID_WITHOUT_BURNED_IN_CAPTIONS: ${{ secrets.MUX_TEST_ASSET_ID_WITHOUT_BURNED_IN_CAPTIONS }} MUX_TEST_ASSET_ID_AUDIO_ONLY: ${{ secrets.MUX_TEST_ASSET_ID_AUDIO_ONLY }} MUX_TEST_ASSET_ID_VIOLENT_AUDIO_ONLY: ${{ secrets.MUX_TEST_ASSET_ID_VIOLENT_AUDIO_ONLY }} - run: npm run test:integration -- --exclude tests/integration/translate-audio.test.ts + run: npm run test:integration - name: Simple CLI smoke test - Summarization example env: diff --git a/.github/workflows/translate-audio-integration.yml b/.github/workflows/translate-audio-integration.yml deleted file mode 100644 index 2d51862..0000000 --- a/.github/workflows/translate-audio-integration.yml +++ /dev/null @@ -1,56 +0,0 @@ -name: Translate Audio Integration Test - -on: - push: - branches: - - main - pull_request: - paths: - - tests/integration/translate-audio.test.ts - - src/workflows/translate-audio.ts - - .github/workflows/translate-audio-integration.yml - workflow_dispatch: - -jobs: - translate-audio-integration-test: - runs-on: ubuntu-latest - timeout-minutes: 45 - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version-file: .nvmrc - cache: npm - - - name: Install dependencies - run: npm ci - - - name: Run translate audio integration test - env: - MUX_TOKEN_ID: ${{ secrets.MUX_TOKEN_ID }} - MUX_TOKEN_SECRET: ${{ secrets.MUX_TOKEN_SECRET }} - MUX_SIGNING_KEY: ${{ secrets.MUX_SIGNING_KEY }} - MUX_PRIVATE_KEY: ${{ secrets.MUX_PRIVATE_KEY }} - OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} - ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} - ELEVENLABS_API_KEY: ${{ secrets.ELEVENLABS_API_KEY }} - HIVE_API_KEY: ${{ secrets.HIVE_API_KEY }} - GOOGLE_GENERATIVE_AI_API_KEY: ${{ secrets.GOOGLE_GENERATIVE_AI_API_KEY }} - S3_ENDPOINT: ${{ secrets.S3_ENDPOINT }} - S3_REGION: ${{ secrets.S3_REGION }} - S3_BUCKET: ${{ secrets.S3_BUCKET }} - S3_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY_ID }} - S3_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_ACCESS_KEY }} - MUX_TEST_ASSET_ID: ${{ secrets.MUX_TEST_ASSET_ID }} - MUX_TEST_ASSET_ID_CHAPTERS: ${{ secrets.MUX_TEST_ASSET_ID_CHAPTERS }} - MUX_TEST_ASSET_ID_VIOLENT: ${{ secrets.MUX_TEST_ASSET_ID_VIOLENT }} - MUX_TEST_ASSET_ID_BURNED_IN_CAPTIONS: ${{ secrets.MUX_TEST_ASSET_ID_BURNED_IN_CAPTIONS }} - MUX_TEST_ASSET_ID_BURNED_IN_CAPTIONS_2: ${{ secrets.MUX_TEST_ASSET_ID_BURNED_IN_CAPTIONS_2 }} - MUX_TEST_ASSET_ID_WITHOUT_BURNED_IN_CAPTIONS: ${{ secrets.MUX_TEST_ASSET_ID_WITHOUT_BURNED_IN_CAPTIONS }} - MUX_TEST_ASSET_ID_AUDIO_ONLY: ${{ secrets.MUX_TEST_ASSET_ID_AUDIO_ONLY }} - MUX_TEST_ASSET_ID_VIOLENT_AUDIO_ONLY: ${{ secrets.MUX_TEST_ASSET_ID_VIOLENT_AUDIO_ONLY }} - run: DOTENV_CONFIG_PATH=.env.test npx vitest run tests/integration/translate-audio.test.ts diff --git a/tests/integration/translate-audio.test.ts b/tests/integration/translate-audio.test.ts deleted file mode 100644 index 580962f..0000000 --- a/tests/integration/translate-audio.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { translateAudio } from "../../src/workflows"; -import { muxTestAssets } from "../helpers/mux-test-assets"; - -describe("audio Translation Integration Tests", () => { - const assetId = muxTestAssets.assetId; - - it("should translate audio to French without uploading to Mux", async () => { - const result = await translateAudio(assetId, "fr", { - provider: "elevenlabs", - uploadToS3: false, - uploadToMux: false, - }); - - // Assert that the result exists - expect(result).toBeDefined(); - - // Verify structure - expect(result).toHaveProperty("assetId"); - expect(result).toHaveProperty("targetLanguageCode"); - expect(result).toHaveProperty("dubbingId"); - - // Verify asset ID matches - expect(result.assetId).toBe(assetId); - - // Verify target language code - expect(result.targetLanguageCode).toBe("fr"); - - // Verify dubbing ID exists - expect(typeof result.dubbingId).toBe("string"); - expect(result.dubbingId.length).toBeGreaterThan(0); - - // Since both uploadToS3 and uploadToMux are false, neither should be present - expect(result.uploadedTrackId).toBeUndefined(); - expect(result.presignedUrl).toBeUndefined(); - }, 600000); // 10 minute timeout for ElevenLabs processing -}); From 5e3891ce6f930e8e13941458317cba2cf5fc6dac Mon Sep 17 00:00:00 2001 From: Walker Date: Mon, 13 Jul 2026 15:58:56 -0700 Subject: [PATCH 3/3] update --- src/lib/mux-ai-error.ts | 27 ++++++++++++++++++++++++++- src/workflows/translate-audio.ts | 14 +++++++------- 2 files changed, 33 insertions(+), 8 deletions(-) diff --git a/src/lib/mux-ai-error.ts b/src/lib/mux-ai-error.ts index d797678..044d2b5 100644 --- a/src/lib/mux-ai-error.ts +++ b/src/lib/mux-ai-error.ts @@ -66,11 +66,36 @@ export class MuxAiError extends Error { * trigger the much stronger field-level scrubber on the preceding * model output. */ +/** + * Extract a human-readable detail from an unknown thrown value. + * + * An error thrown inside a Workflow DevKit step crosses a VM/serialization + * boundary before it reaches a workflow-level catch, arriving as a plain + * object that fails `instanceof Error` but still carries a `message` string. + * Duck-typing `message` avoids collapsing the real cause (e.g. "fetch failed") + * to "Unknown error". + */ +function extractErrorDetail(error: unknown): string { + if (error instanceof Error && error.message) { + return error.message; + } + if (typeof error === "string" && error.length > 0) { + return error; + } + if (typeof error === "object" && error !== null) { + const message = (error as { message?: unknown }).message; + if (typeof message === "string" && message.length > 0) { + return message; + } + } + return "Unknown error"; +} + export function wrapError(error: unknown, message: string): never { if (error instanceof MuxAiError) { throw error; } - const rawDetail = error instanceof Error ? error.message : "Unknown error"; + const rawDetail = extractErrorDetail(error); const leakReason = detectLeakReason(rawDetail); const shouldSuppress = leakReason === "canary" || leakReason === "prompt_tag"; const detail = shouldSuppress ? diff --git a/src/workflows/translate-audio.ts b/src/workflows/translate-audio.ts index 7aea745..3d34a3d 100644 --- a/src/workflows/translate-audio.ts +++ b/src/workflows/translate-audio.ts @@ -481,15 +481,15 @@ export async function translateAudio( wrapError(error, "Failed to create ElevenLabs dubbing job"); } - // Poll for completion + // Poll for completion. ElevenLabs added intermediate dubbing states console.warn("⏳ Waiting for dubbing to complete..."); - let dubbingStatus: string = "dubbing"; + let dubbingStatus = "dubbing"; let pollAttempts = 0; const maxPollAttempts = 180; // 30 minutes at 10s intervals let targetLanguages: string[] = []; - while (dubbingStatus === "dubbing" && pollAttempts < maxPollAttempts) { + while (dubbingStatus !== "dubbed" && pollAttempts < maxPollAttempts) { await sleep(10000); // Wait 10 seconds pollAttempts++; @@ -500,13 +500,13 @@ export async function translateAudio( }); dubbingStatus = statusResult.status; targetLanguages = statusResult.targetLanguages; - - if (dubbingStatus === "failed") { - throw new Error("ElevenLabs dubbing job failed"); - } } catch (error) { wrapError(error, "Failed to check dubbing status"); } + + if (dubbingStatus === "failed") { + throw new MuxAiError("ElevenLabs reported that the dubbing job failed.", { type: "processing_error" }); + } } if (dubbingStatus !== "dubbed") {