@mux/ai 0.13.1 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -5,7 +5,7 @@ var __export = (target, all) => {
5
5
  };
6
6
 
7
7
  // package.json
8
- var version = "0.13.1";
8
+ var version = "0.14.0";
9
9
 
10
10
  // src/env.ts
11
11
  import { z } from "zod";
@@ -1580,6 +1580,56 @@ async function getThumbnailUrls(playbackId, duration, options = {}) {
1580
1580
  return Promise.all(urlPromises);
1581
1581
  }
1582
1582
 
1583
+ // src/lib/mux-assets.ts
1584
+ function getPlaybackId(asset) {
1585
+ const playbackIds = asset.playback_ids || [];
1586
+ const publicPlaybackId = playbackIds.find((pid) => pid.policy === "public");
1587
+ if (publicPlaybackId?.id) {
1588
+ return { id: publicPlaybackId.id, policy: "public" };
1589
+ }
1590
+ const signedPlaybackId = playbackIds.find((pid) => pid.policy === "signed");
1591
+ if (signedPlaybackId?.id) {
1592
+ return { id: signedPlaybackId.id, policy: "signed" };
1593
+ }
1594
+ throw new Error(
1595
+ "No public or signed playback ID found for this asset. A public or signed playback ID is required. DRM playback IDs are not currently supported."
1596
+ );
1597
+ }
1598
+ function isAudioOnlyAsset(asset) {
1599
+ const hasAudioTrack = asset.tracks?.some((track) => track.type === "audio") ?? false;
1600
+ const hasVideoTrack = asset.tracks?.some((track) => track.type === "video") ?? false;
1601
+ return hasAudioTrack && !hasVideoTrack;
1602
+ }
1603
+ function toPlaybackAsset(asset) {
1604
+ const { id: playbackId, policy } = getPlaybackId(asset);
1605
+ return { asset, playbackId, policy };
1606
+ }
1607
+ async function getPlaybackIdForAsset(assetId, credentials) {
1608
+ "use step";
1609
+ const asset = await getMuxAsset(assetId, credentials);
1610
+ return toPlaybackAsset(asset);
1611
+ }
1612
+ async function getMuxAsset(assetId, credentials) {
1613
+ "use step";
1614
+ const muxClient = await getMuxClientFromEnv(credentials);
1615
+ const mux = await muxClient.createClient();
1616
+ return mux.video.assets.retrieve(assetId);
1617
+ }
1618
+ function getAssetDurationSecondsFromAsset(asset) {
1619
+ const duration = asset.duration;
1620
+ return typeof duration === "number" && Number.isFinite(duration) ? duration : void 0;
1621
+ }
1622
+ function getVideoTrackDurationSecondsFromAsset(asset) {
1623
+ const videoTrack = asset.tracks?.find((track) => track.type === "video");
1624
+ const duration = videoTrack?.duration;
1625
+ return typeof duration === "number" && Number.isFinite(duration) ? duration : void 0;
1626
+ }
1627
+ function getVideoTrackMaxFrameRateFromAsset(asset) {
1628
+ const videoTrack = asset.tracks?.find((track) => track.type === "video");
1629
+ const maxFrameRate = videoTrack?.max_frame_rate;
1630
+ return typeof maxFrameRate === "number" && Number.isFinite(maxFrameRate) && maxFrameRate > 0 ? maxFrameRate : void 0;
1631
+ }
1632
+
1583
1633
  // src/primitives/transcripts.ts
1584
1634
  function getReadyTextTracks(asset) {
1585
1635
  return (asset.tracks || []).filter(
@@ -1591,11 +1641,20 @@ function findCaptionTrack(asset, languageCode) {
1591
1641
  if (!tracks.length)
1592
1642
  return void 0;
1593
1643
  if (!languageCode) {
1594
- return tracks[0];
1644
+ const englishTrack = tracks.find(
1645
+ (track) => track.text_type === "subtitles" && track.language_code === "en"
1646
+ );
1647
+ return englishTrack ?? tracks[0];
1595
1648
  }
1596
- return tracks.find(
1649
+ const languageMatch = tracks.find(
1597
1650
  (track) => track.text_type === "subtitles" && track.language_code === languageCode
1598
1651
  );
1652
+ if (languageMatch)
1653
+ return languageMatch;
1654
+ if (isAudioOnlyAsset(asset) && tracks.length === 1) {
1655
+ return tracks[0];
1656
+ }
1657
+ return void 0;
1599
1658
  }
1600
1659
  function normalizeLineEndings(value) {
1601
1660
  return value.replace(/\r\n/g, "\n");
@@ -2071,56 +2130,6 @@ async function downloadImagesAsBase64(urls, options = {}, maxConcurrent = 5) {
2071
2130
  return results;
2072
2131
  }
2073
2132
 
2074
- // src/lib/mux-assets.ts
2075
- function getPlaybackId(asset) {
2076
- const playbackIds = asset.playback_ids || [];
2077
- const publicPlaybackId = playbackIds.find((pid) => pid.policy === "public");
2078
- if (publicPlaybackId?.id) {
2079
- return { id: publicPlaybackId.id, policy: "public" };
2080
- }
2081
- const signedPlaybackId = playbackIds.find((pid) => pid.policy === "signed");
2082
- if (signedPlaybackId?.id) {
2083
- return { id: signedPlaybackId.id, policy: "signed" };
2084
- }
2085
- throw new Error(
2086
- "No public or signed playback ID found for this asset. A public or signed playback ID is required. DRM playback IDs are not currently supported."
2087
- );
2088
- }
2089
- function isAudioOnlyAsset(asset) {
2090
- const hasAudioTrack = asset.tracks?.some((track) => track.type === "audio") ?? false;
2091
- const hasVideoTrack = asset.tracks?.some((track) => track.type === "video") ?? false;
2092
- return hasAudioTrack && !hasVideoTrack;
2093
- }
2094
- function toPlaybackAsset(asset) {
2095
- const { id: playbackId, policy } = getPlaybackId(asset);
2096
- return { asset, playbackId, policy };
2097
- }
2098
- async function getPlaybackIdForAsset(assetId, credentials) {
2099
- "use step";
2100
- const asset = await getMuxAsset(assetId, credentials);
2101
- return toPlaybackAsset(asset);
2102
- }
2103
- async function getMuxAsset(assetId, credentials) {
2104
- "use step";
2105
- const muxClient = await getMuxClientFromEnv(credentials);
2106
- const mux = await muxClient.createClient();
2107
- return mux.video.assets.retrieve(assetId);
2108
- }
2109
- function getAssetDurationSecondsFromAsset(asset) {
2110
- const duration = asset.duration;
2111
- return typeof duration === "number" && Number.isFinite(duration) ? duration : void 0;
2112
- }
2113
- function getVideoTrackDurationSecondsFromAsset(asset) {
2114
- const videoTrack = asset.tracks?.find((track) => track.type === "video");
2115
- const duration = videoTrack?.duration;
2116
- return typeof duration === "number" && Number.isFinite(duration) ? duration : void 0;
2117
- }
2118
- function getVideoTrackMaxFrameRateFromAsset(asset) {
2119
- const videoTrack = asset.tracks?.find((track) => track.type === "video");
2120
- const maxFrameRate = videoTrack?.max_frame_rate;
2121
- return typeof maxFrameRate === "number" && Number.isFinite(maxFrameRate) && maxFrameRate > 0 ? maxFrameRate : void 0;
2122
- }
2123
-
2124
2133
  // src/lib/prompt-builder.ts
2125
2134
  function renderSection(section) {
2126
2135
  const { tag, content, attributes } = section;
@@ -2352,19 +2361,102 @@ var SYSTEM_PROMPT = dedent`
2352
2361
  - GOOD: "A person runs through a park"
2353
2362
  - Be specific and evidence-based
2354
2363
  </language_guidelines>`;
2364
+ var AUDIO_ONLY_SYSTEM_PROMPT = dedent`
2365
+ <role>
2366
+ You are an audio content analyst specializing in classification tasks.
2367
+ Your job is to answer questions about audio content based on transcript data.
2368
+ </role>
2369
+
2370
+ <context>
2371
+ You will receive:
2372
+ - A transcript of the audio/dialogue
2373
+ - A list of questions about the asset content
2374
+ </context>
2375
+
2376
+ <transcript_guidance>
2377
+ - Use the transcript to understand spoken content, dialogue, and audio context
2378
+ - Consider only transcript evidence when answering questions
2379
+ </transcript_guidance>
2380
+
2381
+ <task>
2382
+ For each question provided, you must:
2383
+ 1. Analyze the transcript
2384
+ 2. Answer with ONLY the allowed response options - no other values are acceptable
2385
+ 3. Provide a confidence score between 0 and 1 reflecting your certainty
2386
+ 4. Explain your reasoning based on observable evidence
2387
+ </task>
2388
+
2389
+ <answer_guidelines>
2390
+ - Choose the affirmative option only if you have clear evidence supporting it
2391
+ - Choose the negative/contradicting option if evidence contradicts or if insufficient evidence exists
2392
+ - Confidence should reflect the clarity and strength of evidence:
2393
+ * 0.9-1.0: Clear, unambiguous evidence
2394
+ * 0.7-0.9: Strong evidence with minor ambiguity
2395
+ * 0.5-0.7: Moderate evidence or some conflicting signals
2396
+ * 0.3-0.5: Weak evidence or significant ambiguity
2397
+ * 0.0-0.3: Very uncertain, minimal relevant evidence
2398
+ - Reasoning should cite specific transcript evidence
2399
+ - Be precise: cite specific quotes or passages from transcript text
2400
+ </answer_guidelines>
2401
+
2402
+ <relevance_filtering>
2403
+ Before answering each question, assess whether it can be meaningfully
2404
+ answered based on the transcript. A question is relevant if it asks about
2405
+ something observable or inferable from spoken/audio content.
2406
+
2407
+ Mark a question as skipped (skipped: true) if it:
2408
+ - Is completely unrelated to transcript/audio content (e.g., math, trivia, personal questions)
2409
+ - Asks about information that cannot be determined from transcript content
2410
+ - Is a general knowledge question with no connection to what is said in the transcript
2411
+ - Attempts to use the system for non-content-analysis purposes
2412
+
2413
+ For skipped questions:
2414
+ - Set skipped to true
2415
+ - Set answer to "${SKIP_SENTINEL}"
2416
+ - Set confidence to 0
2417
+ - Use the reasoning field to explain why the question is not answerable
2418
+ from transcript content
2419
+
2420
+ For borderline questions that are loosely related to transcript content,
2421
+ still answer them but use a lower confidence score to reflect uncertainty.
2422
+ </relevance_filtering>
2423
+
2424
+ <constraints>
2425
+ - You MUST answer every relevant question with one of the allowed response options
2426
+ - Skip irrelevant questions as described in relevance_filtering
2427
+ - Only describe observable evidence from transcript content
2428
+ - Do not fabricate details or make unsupported assumptions
2429
+ - Return structured data matching the requested schema exactly
2430
+ - Provide reasoning in the same language as the question
2431
+ </constraints>
2432
+
2433
+ <language_guidelines>
2434
+ When explaining reasoning:
2435
+ - Describe content directly, not the medium
2436
+ - BAD: "The audio says someone is running"
2437
+ - GOOD: "The speaker describes running through a park"
2438
+ - Be specific and evidence-based
2439
+ </language_guidelines>`;
2355
2440
  var askQuestionsPromptBuilder = createPromptBuilder({
2356
2441
  template: {
2357
2442
  questions: {
2358
2443
  tag: "questions",
2359
- content: "Please answer the following yes/no questions about this video:"
2444
+ content: "Please answer the following questions about this content:"
2360
2445
  }
2361
2446
  },
2362
2447
  sectionOrder: ["questions"]
2363
2448
  });
2364
- function buildUserPrompt(questions, allowedAnswers, transcriptText, isCleanTranscript = true) {
2449
+ function buildUserPrompt({
2450
+ questions,
2451
+ allowedAnswers,
2452
+ transcriptText,
2453
+ isCleanTranscript = true,
2454
+ isAudioOnly = false
2455
+ }) {
2365
2456
  const questionsList = questions.map((q, idx) => `${idx + 1}. ${q.question}`).join("\n");
2457
+ const questionsIntro = isAudioOnly ? "Please answer the following questions about this audio content using transcript evidence:" : "Please answer the following questions about this content:";
2366
2458
  const questionsContent = dedent`
2367
- Please answer the following yes/no questions about this video:
2459
+ ${questionsIntro}
2368
2460
 
2369
2461
  ${questionsList}`;
2370
2462
  const answerList = allowedAnswers.map((answer) => `"${answer}"`).join(", ");
@@ -2391,7 +2483,15 @@ async function fetchImageAsBase64(imageUrl, imageDownloadOptions) {
2391
2483
  const downloadResult = await downloadImageAsBase64(imageUrl, imageDownloadOptions);
2392
2484
  return downloadResult.base64Data;
2393
2485
  }
2394
- async function analyzeQuestionsWithStoryboard(imageDataUrl, provider, modelId, userPrompt, systemPrompt, allowedAnswers, credentials) {
2486
+ async function analyzeQuestions({
2487
+ provider,
2488
+ modelId,
2489
+ userPrompt,
2490
+ systemPrompt,
2491
+ allowedAnswers,
2492
+ imageDataUrl,
2493
+ credentials
2494
+ }) {
2395
2495
  "use step";
2396
2496
  const model = await createLanguageModelFromConfig(provider, modelId, credentials);
2397
2497
  const responseSchema = createAskQuestionsSchema(allowedAnswers);
@@ -2406,15 +2506,19 @@ async function analyzeQuestionsWithStoryboard(imageDataUrl, provider, modelId, u
2406
2506
  },
2407
2507
  {
2408
2508
  role: "user",
2409
- content: [
2509
+ content: imageDataUrl ? [
2410
2510
  { type: "text", text: userPrompt },
2411
2511
  { type: "image", image: imageDataUrl }
2412
- ]
2512
+ ] : userPrompt
2413
2513
  }
2414
2514
  ]
2415
2515
  });
2516
+ if (!response.output) {
2517
+ throw new Error("Ask-questions output missing");
2518
+ }
2519
+ const parsed = responseSchema.parse(response.output);
2416
2520
  return {
2417
- result: response.output,
2521
+ result: parsed,
2418
2522
  usage: {
2419
2523
  inputTokens: response.usage.inputTokens,
2420
2524
  outputTokens: response.usage.outputTokens,
@@ -2439,6 +2543,7 @@ async function askQuestions(assetId, questions, options) {
2439
2543
  const {
2440
2544
  provider = "openai",
2441
2545
  model,
2546
+ languageCode,
2442
2547
  answerOptions,
2443
2548
  includeTranscript = true,
2444
2549
  cleanTranscript = true,
@@ -2463,53 +2568,83 @@ async function askQuestions(assetId, questions, options) {
2463
2568
  });
2464
2569
  const { asset: assetData, playbackId, policy } = await getPlaybackIdForAsset(assetId, credentials);
2465
2570
  const assetDurationSeconds = getAssetDurationSecondsFromAsset(assetData);
2571
+ const isAudioOnly = isAudioOnlyAsset(assetData);
2572
+ if (isAudioOnly && !includeTranscript) {
2573
+ throw new Error(
2574
+ "Audio-only assets require a transcript. Set includeTranscript: true and ensure the asset has a ready text track (captions/subtitles)."
2575
+ );
2576
+ }
2466
2577
  const signingContext = await resolveMuxSigningContext(credentials);
2467
2578
  if (policy === "signed" && !signingContext) {
2468
2579
  throw new Error(
2469
2580
  "Signed playback ID requires signing credentials. Set MUX_SIGNING_KEY and MUX_PRIVATE_KEY environment variables."
2470
2581
  );
2471
2582
  }
2472
- const transcriptText = includeTranscript ? (await fetchTranscriptForAsset(assetData, playbackId, {
2583
+ const transcriptResult = includeTranscript ? await fetchTranscriptForAsset(assetData, playbackId, {
2584
+ languageCode,
2473
2585
  cleanTranscript,
2474
- shouldSign: policy === "signed"
2475
- })).transcriptText : "";
2476
- const userPrompt = buildUserPrompt(questions, allowedAnswers, transcriptText, cleanTranscript);
2477
- const systemPrompt = SYSTEM_PROMPT;
2478
- const imageUrl = await getStoryboardUrl(
2479
- playbackId,
2480
- storyboardWidth,
2481
- policy === "signed",
2482
- credentials
2483
- );
2586
+ shouldSign: policy === "signed",
2587
+ credentials,
2588
+ required: isAudioOnly
2589
+ }) : void 0;
2590
+ const transcriptText = transcriptResult?.transcriptText ?? "";
2591
+ const userPrompt = buildUserPrompt({
2592
+ questions,
2593
+ allowedAnswers,
2594
+ transcriptText,
2595
+ isCleanTranscript: cleanTranscript,
2596
+ isAudioOnly
2597
+ });
2598
+ const systemPrompt = isAudioOnly ? AUDIO_ONLY_SYSTEM_PROMPT : SYSTEM_PROMPT;
2484
2599
  let analysisResponse;
2600
+ let imageUrl;
2485
2601
  try {
2486
- if (imageSubmissionMode === "base64") {
2487
- const base64Data = await fetchImageAsBase64(imageUrl, imageDownloadOptions);
2488
- analysisResponse = await analyzeQuestionsWithStoryboard(
2489
- base64Data,
2490
- modelConfig.provider,
2491
- modelConfig.modelId,
2602
+ if (isAudioOnly) {
2603
+ analysisResponse = await analyzeQuestions({
2604
+ provider: modelConfig.provider,
2605
+ modelId: modelConfig.modelId,
2492
2606
  userPrompt,
2493
2607
  systemPrompt,
2494
2608
  allowedAnswers,
2495
2609
  credentials
2496
- );
2610
+ });
2497
2611
  } else {
2498
- analysisResponse = await withRetry(
2499
- () => analyzeQuestionsWithStoryboard(
2500
- imageUrl,
2501
- modelConfig.provider,
2502
- modelConfig.modelId,
2612
+ const storyboardUrl = await getStoryboardUrl(
2613
+ playbackId,
2614
+ storyboardWidth,
2615
+ policy === "signed",
2616
+ credentials
2617
+ );
2618
+ imageUrl = storyboardUrl;
2619
+ if (imageSubmissionMode === "base64") {
2620
+ const base64Data = await fetchImageAsBase64(storyboardUrl, imageDownloadOptions);
2621
+ analysisResponse = await analyzeQuestions({
2622
+ provider: modelConfig.provider,
2623
+ modelId: modelConfig.modelId,
2503
2624
  userPrompt,
2504
2625
  systemPrompt,
2505
2626
  allowedAnswers,
2627
+ imageDataUrl: base64Data,
2506
2628
  credentials
2507
- )
2508
- );
2629
+ });
2630
+ } else {
2631
+ analysisResponse = await withRetry(
2632
+ () => analyzeQuestions({
2633
+ provider: modelConfig.provider,
2634
+ modelId: modelConfig.modelId,
2635
+ userPrompt,
2636
+ systemPrompt,
2637
+ allowedAnswers,
2638
+ imageDataUrl: storyboardUrl,
2639
+ credentials
2640
+ })
2641
+ );
2642
+ }
2509
2643
  }
2510
2644
  } catch (error) {
2645
+ const contentType = isAudioOnly ? "audio" : "video";
2511
2646
  throw new Error(
2512
- `Failed to analyze questions with ${provider}: ${error instanceof Error ? error.message : "Unknown error"}`
2647
+ `Failed to analyze ${contentType} questions with ${provider}: ${error instanceof Error ? error.message : "Unknown error"}`
2513
2648
  );
2514
2649
  }
2515
2650
  if (!analysisResponse.result?.answers) {
@@ -3049,9 +3184,10 @@ function buildUserPrompt3({
3049
3184
  };
3050
3185
  return chaptersPromptBuilder.buildWithContext(mergedOverrides, contextSections);
3051
3186
  }
3052
- async function generateChapters(assetId, languageCode, options = {}) {
3187
+ async function generateChapters(assetId, options = {}) {
3053
3188
  "use workflow";
3054
3189
  const {
3190
+ languageCode,
3055
3191
  provider = "openai",
3056
3192
  model,
3057
3193
  promptOverrides,
@@ -3075,26 +3211,17 @@ async function generateChapters(assetId, languageCode, options = {}) {
3075
3211
  );
3076
3212
  }
3077
3213
  const readyTextTracks = getReadyTextTracks(assetData);
3078
- let transcriptResult = await fetchTranscriptForAsset(assetData, playbackId, {
3214
+ const transcriptResult = await fetchTranscriptForAsset(assetData, playbackId, {
3079
3215
  languageCode,
3080
3216
  cleanTranscript: false,
3081
3217
  // keep timestamps for chapter segmentation
3082
3218
  shouldSign: policy === "signed",
3083
3219
  credentials
3084
3220
  });
3085
- if (isAudioOnly && !transcriptResult.track && readyTextTracks.length === 1) {
3086
- transcriptResult = await fetchTranscriptForAsset(assetData, playbackId, {
3087
- cleanTranscript: false,
3088
- // keep timestamps for chapter segmentation
3089
- shouldSign: policy === "signed",
3090
- credentials,
3091
- required: true
3092
- });
3093
- }
3094
3221
  if (!transcriptResult.track || !transcriptResult.transcriptText) {
3095
3222
  const availableLanguages = readyTextTracks.map((t) => t.language_code).filter(Boolean).join(", ");
3096
3223
  throw new Error(
3097
- `No caption track found for language '${languageCode}'. Available languages: ${availableLanguages || "none"}`
3224
+ `No caption track found${languageCode ? ` for language '${languageCode}'` : ""}. Available languages: ${availableLanguages || "none"}`
3098
3225
  );
3099
3226
  }
3100
3227
  const timestampedTranscript = extractTimestampedTranscript(transcriptResult.transcriptText);
@@ -3146,7 +3273,7 @@ async function generateChapters(assetId, languageCode, options = {}) {
3146
3273
  };
3147
3274
  return {
3148
3275
  assetId,
3149
- languageCode,
3276
+ languageCode: languageCode ?? transcriptResult.track?.language_code ?? "en",
3150
3277
  chapters: validChapters,
3151
3278
  usage: usageWithMetadata
3152
3279
  };
@@ -3414,6 +3541,7 @@ async function editCaptions(assetId, trackId, options) {
3414
3541
  autoCensorProfanity: autoCensorOption,
3415
3542
  replacements: replacementsOption,
3416
3543
  deleteOriginalTrack,
3544
+ uploadToS3: uploadToS3Option,
3417
3545
  uploadToMux: uploadToMuxOption,
3418
3546
  s3Endpoint: providedS3Endpoint,
3419
3547
  s3Region: providedS3Region,
@@ -3432,14 +3560,15 @@ async function editCaptions(assetId, trackId, options) {
3432
3560
  }
3433
3561
  const deleteOriginal = deleteOriginalTrack !== false;
3434
3562
  const uploadToMux = uploadToMuxOption !== false;
3563
+ const uploadToS3 = uploadToS3Option || uploadToMux;
3435
3564
  const s3Endpoint = providedS3Endpoint ?? env_default.S3_ENDPOINT;
3436
3565
  const s3Region = providedS3Region ?? env_default.S3_REGION ?? "auto";
3437
3566
  const s3Bucket = providedS3Bucket ?? env_default.S3_BUCKET;
3438
3567
  const s3AccessKeyId = env_default.S3_ACCESS_KEY_ID;
3439
3568
  const s3SecretAccessKey = env_default.S3_SECRET_ACCESS_KEY;
3440
- if (uploadToMux && (!s3Endpoint || !s3Bucket || !storageAdapter && (!s3AccessKeyId || !s3SecretAccessKey))) {
3569
+ if (uploadToS3 && (!s3Endpoint || !s3Bucket || !storageAdapter && (!s3AccessKeyId || !s3SecretAccessKey))) {
3441
3570
  throw new Error(
3442
- "Storage configuration is required for uploading to Mux. Provide s3Endpoint and s3Bucket. If no storageAdapter is supplied, also provide s3AccessKeyId and s3SecretAccessKey in options or set S3_ENDPOINT, S3_BUCKET, S3_ACCESS_KEY_ID, and S3_SECRET_ACCESS_KEY environment variables."
3571
+ "Storage configuration is required for uploading. Provide s3Endpoint and s3Bucket. If no storageAdapter is supplied, also provide s3AccessKeyId and s3SecretAccessKey in options or set S3_ENDPOINT, S3_BUCKET, S3_ACCESS_KEY_ID, and S3_SECRET_ACCESS_KEY environment variables."
3443
3572
  );
3444
3573
  }
3445
3574
  const { asset: assetData, playbackId, policy } = await getPlaybackIdForAsset(assetId, credentials);
@@ -3512,53 +3641,45 @@ async function editCaptions(assetId, trackId, options) {
3512
3641
  assetDurationSeconds
3513
3642
  }
3514
3643
  } : void 0;
3515
- if (!uploadToMux) {
3516
- return {
3517
- assetId,
3518
- trackId,
3519
- originalVtt: vttContent,
3520
- editedVtt,
3521
- totalReplacementCount,
3522
- autoCensorProfanity: autoCensorResult,
3523
- replacements: replacementsResult,
3524
- usage: usageWithMetadata
3525
- };
3526
- }
3527
3644
  let presignedUrl;
3528
- try {
3529
- presignedUrl = await uploadEditedVttToS3({
3530
- editedVtt,
3531
- assetId,
3532
- trackId,
3533
- s3Endpoint,
3534
- s3Region,
3535
- s3Bucket,
3536
- storageAdapter,
3537
- s3SignedUrlExpirySeconds: options.s3SignedUrlExpirySeconds
3538
- });
3539
- } catch (error) {
3540
- throw new Error(`Failed to upload VTT to S3: ${error instanceof Error ? error.message : "Unknown error"}`);
3541
- }
3542
3645
  let uploadedTrackId;
3543
- try {
3544
- const languageCode = sourceTrack.language_code || "en";
3545
- const suffix = trackNameSuffix ?? "edited";
3546
- const trackName = `${sourceTrack.name || "Subtitles"} (${suffix})`;
3547
- uploadedTrackId = await createTextTrackOnMux(
3548
- assetId,
3549
- languageCode,
3550
- trackName,
3551
- presignedUrl,
3552
- credentials
3553
- );
3554
- } catch (error) {
3555
- console.warn(`Failed to add track to Mux asset: ${error instanceof Error ? error.message : "Unknown error"}`);
3556
- }
3557
- if (deleteOriginal && uploadedTrackId) {
3646
+ if (uploadToS3) {
3558
3647
  try {
3559
- await deleteTrackOnMux(assetId, trackId, credentials);
3648
+ presignedUrl = await uploadEditedVttToS3({
3649
+ editedVtt,
3650
+ assetId,
3651
+ trackId,
3652
+ s3Endpoint,
3653
+ s3Region,
3654
+ s3Bucket,
3655
+ storageAdapter,
3656
+ s3SignedUrlExpirySeconds: options.s3SignedUrlExpirySeconds
3657
+ });
3560
3658
  } catch (error) {
3561
- console.warn(`Failed to delete original track: ${error instanceof Error ? error.message : "Unknown error"}`);
3659
+ throw new Error(`Failed to upload VTT to S3: ${error instanceof Error ? error.message : "Unknown error"}`);
3660
+ }
3661
+ if (uploadToMux) {
3662
+ try {
3663
+ const languageCode = sourceTrack.language_code || "en";
3664
+ const suffix = trackNameSuffix ?? "edited";
3665
+ const trackName = `${sourceTrack.name || "Subtitles"} (${suffix})`;
3666
+ uploadedTrackId = await createTextTrackOnMux(
3667
+ assetId,
3668
+ languageCode,
3669
+ trackName,
3670
+ presignedUrl,
3671
+ credentials
3672
+ );
3673
+ } catch (error) {
3674
+ console.warn(`Failed to add track to Mux asset: ${error instanceof Error ? error.message : "Unknown error"}`);
3675
+ }
3676
+ if (deleteOriginal && uploadedTrackId) {
3677
+ try {
3678
+ await deleteTrackOnMux(assetId, trackId, credentials);
3679
+ } catch (error) {
3680
+ console.warn(`Failed to delete original track: ${error instanceof Error ? error.message : "Unknown error"}`);
3681
+ }
3682
+ }
3562
3683
  }
3563
3684
  }
3564
3685
  return {
@@ -3629,43 +3750,21 @@ async function generateEmbeddingsInternal(assetId, options = {}) {
3629
3750
  const embeddingModel = resolveEmbeddingModelConfig({ ...options, provider, model });
3630
3751
  const { asset: assetData, playbackId, policy } = await getPlaybackIdForAsset(assetId, credentials);
3631
3752
  const assetDurationSeconds = getAssetDurationSecondsFromAsset(assetData);
3632
- const isAudioOnly = isAudioOnlyAsset(assetData);
3633
3753
  const signingContext = await resolveMuxSigningContext(credentials);
3634
3754
  if (policy === "signed" && !signingContext) {
3635
3755
  throw new Error(
3636
3756
  "Signed playback ID requires signing credentials. Set MUX_SIGNING_KEY and MUX_PRIVATE_KEY environment variables."
3637
3757
  );
3638
3758
  }
3639
- const readyTextTracks = getReadyTextTracks(assetData);
3640
3759
  const useVttChunking = chunkingStrategy.type === "vtt";
3641
- let transcriptResult = await fetchTranscriptForAsset(assetData, playbackId, {
3760
+ const transcriptResult = await fetchTranscriptForAsset(assetData, playbackId, {
3642
3761
  languageCode,
3643
3762
  cleanTranscript: !useVttChunking,
3644
3763
  shouldSign: policy === "signed",
3645
- credentials
3764
+ credentials,
3765
+ required: true
3646
3766
  });
3647
- if (isAudioOnly && !transcriptResult.track && readyTextTracks.length === 1) {
3648
- transcriptResult = await fetchTranscriptForAsset(assetData, playbackId, {
3649
- cleanTranscript: !useVttChunking,
3650
- shouldSign: policy === "signed",
3651
- credentials
3652
- });
3653
- }
3654
- if (!transcriptResult.track || !transcriptResult.transcriptText) {
3655
- const availableLanguages = readyTextTracks.map((t) => t.language_code).filter(Boolean).join(", ");
3656
- if (isAudioOnly) {
3657
- throw new Error(
3658
- `No transcript track found${languageCode ? ` for language '${languageCode}'` : ""}. Audio-only assets require a transcript. Available languages: ${availableLanguages || "none"}`
3659
- );
3660
- }
3661
- throw new Error(
3662
- `No caption track found${languageCode ? ` for language '${languageCode}'` : ""}. Available languages: ${availableLanguages || "none"}`
3663
- );
3664
- }
3665
3767
  const transcriptText = transcriptResult.transcriptText;
3666
- if (!transcriptText.trim()) {
3667
- throw new Error("Transcript is empty");
3668
- }
3669
3768
  const chunks = useVttChunking ? chunkVTTCues(
3670
3769
  parseVTTCues(transcriptText),
3671
3770
  chunkingStrategy.maxTokens,
@@ -3791,7 +3890,7 @@ var SYSTEM_PROMPT4 = dedent5`
3791
3890
  - Connect engagement data to content features
3792
3891
  </quality_guidelines>
3793
3892
  `;
3794
- var AUDIO_ONLY_SYSTEM_PROMPT = dedent5`
3893
+ var AUDIO_ONLY_SYSTEM_PROMPT2 = dedent5`
3795
3894
  <role>
3796
3895
  You are a video engagement analyst specializing in understanding viewer behavior for audio content.
3797
3896
  Your job is to explain why specific moments have high or low engagement based on audio/dialogue and viewer watch patterns.
@@ -4129,7 +4228,7 @@ async function generateEngagementInsights(assetId, options = {}) {
4129
4228
  imageUrls.push(storyboardResult.value);
4130
4229
  }
4131
4230
  }
4132
- const systemPrompt = audioOnly ? AUDIO_ONLY_SYSTEM_PROMPT : SYSTEM_PROMPT4;
4231
+ const systemPrompt = audioOnly ? AUDIO_ONLY_SYSTEM_PROMPT2 : SYSTEM_PROMPT4;
4133
4232
  const userPrompt = buildUserPrompt4(
4134
4233
  hotspots,
4135
4234
  transcriptSegments,
@@ -4254,6 +4353,12 @@ var DEFAULT_THRESHOLDS = {
4254
4353
  violence: 0.8
4255
4354
  };
4256
4355
  var DEFAULT_PROVIDER2 = "openai";
4356
+ var OPENAI_MODERATION_RETRIABLE_STATUS_CODES = /* @__PURE__ */ new Set([408, 429, 500, 502, 503, 504]);
4357
+ var OPENAI_MODERATION_MAX_RETRIES = 2;
4358
+ var OPENAI_MODERATION_BASE_DELAY_MS = 750;
4359
+ var OPENAI_MODERATION_MAX_DELAY_MS = 3e3;
4360
+ var MIN_SAMPLE_COVERAGE_FOR_CONFIDENT_THRESHOLDING = 0.5;
4361
+ var MIN_SUCCESSFUL_THUMBNAILS_FOR_CONFIDENT_THRESHOLDING = 3;
4257
4362
  var HIVE_ENDPOINT = "https://api.thehive.ai/api/v2/task/sync";
4258
4363
  var HIVE_SEXUAL_CATEGORIES = [
4259
4364
  "general_nsfw",
@@ -4275,6 +4380,92 @@ var HIVE_VIOLENCE_CATEGORIES = [
4275
4380
  "yes_self_harm",
4276
4381
  "garm_death_injury_or_military_conflict"
4277
4382
  ];
4383
+ var OpenAIModerationRequestError = class extends Error {
4384
+ constructor(message, {
4385
+ status,
4386
+ retriable
4387
+ }) {
4388
+ super(message);
4389
+ this.name = "OpenAIModerationRequestError";
4390
+ this.status = status;
4391
+ this.retriable = retriable;
4392
+ }
4393
+ };
4394
+ function isRetriableOpenAIModerationStatus(status) {
4395
+ return OPENAI_MODERATION_RETRIABLE_STATUS_CODES.has(status);
4396
+ }
4397
+ function compactBodySnippet(body, maxLength = 180) {
4398
+ const compact = body.replace(/\s+/g, " ").trim();
4399
+ if (!compact) {
4400
+ return "(empty response body)";
4401
+ }
4402
+ return compact.length > maxLength ? `${compact.slice(0, maxLength)}...` : compact;
4403
+ }
4404
+ function parseOpenAIModerationResponse(bodyText, status, statusText) {
4405
+ if (!bodyText.trim()) {
4406
+ throw new OpenAIModerationRequestError(
4407
+ `OpenAI moderation returned an empty response (${status} ${statusText}).`,
4408
+ { status, retriable: isRetriableOpenAIModerationStatus(status) }
4409
+ );
4410
+ }
4411
+ try {
4412
+ return JSON.parse(bodyText);
4413
+ } catch {
4414
+ throw new OpenAIModerationRequestError(
4415
+ `OpenAI moderation returned non-JSON response (${status} ${statusText}): ${compactBodySnippet(bodyText)}`,
4416
+ { status, retriable: true }
4417
+ );
4418
+ }
4419
+ }
4420
+ async function callOpenAIModerationApi({
4421
+ model,
4422
+ input,
4423
+ credentials
4424
+ }) {
4425
+ "use step";
4426
+ const apiKey = await getApiKeyFromEnv("openai", credentials);
4427
+ return withRetry(
4428
+ async () => {
4429
+ let res;
4430
+ try {
4431
+ res = await fetch("https://api.openai.com/v1/moderations", {
4432
+ method: "POST",
4433
+ headers: {
4434
+ "Content-Type": "application/json",
4435
+ "Authorization": `Bearer ${apiKey}`
4436
+ },
4437
+ body: JSON.stringify({
4438
+ model,
4439
+ input
4440
+ })
4441
+ });
4442
+ } catch (error) {
4443
+ throw new OpenAIModerationRequestError(
4444
+ `OpenAI moderation request failed: ${error instanceof Error ? error.message : String(error)}`,
4445
+ { retriable: true }
4446
+ );
4447
+ }
4448
+ const bodyText = await res.text();
4449
+ const json = parseOpenAIModerationResponse(bodyText, res.status, res.statusText);
4450
+ if (!res.ok) {
4451
+ throw new OpenAIModerationRequestError(
4452
+ `OpenAI moderation error: ${res.status} ${res.statusText} - ${JSON.stringify(json)}`,
4453
+ {
4454
+ status: res.status,
4455
+ retriable: isRetriableOpenAIModerationStatus(res.status)
4456
+ }
4457
+ );
4458
+ }
4459
+ return json;
4460
+ },
4461
+ {
4462
+ maxRetries: OPENAI_MODERATION_MAX_RETRIES,
4463
+ baseDelay: OPENAI_MODERATION_BASE_DELAY_MS,
4464
+ maxDelay: OPENAI_MODERATION_MAX_DELAY_MS,
4465
+ shouldRetry: (error) => error instanceof OpenAIModerationRequestError ? error.retriable : true
4466
+ }
4467
+ );
4468
+ }
4278
4469
  async function processConcurrently(items, processor, maxConcurrent = 5) {
4279
4470
  "use step";
4280
4471
  const results = [];
@@ -4288,32 +4479,19 @@ async function processConcurrently(items, processor, maxConcurrent = 5) {
4288
4479
  }
4289
4480
  async function moderateImageWithOpenAI(entry) {
4290
4481
  "use step";
4291
- const apiKey = await getApiKeyFromEnv("openai", entry.credentials);
4292
4482
  try {
4293
- const res = await fetch("https://api.openai.com/v1/moderations", {
4294
- method: "POST",
4295
- headers: {
4296
- "Content-Type": "application/json",
4297
- "Authorization": `Bearer ${apiKey}`
4298
- },
4299
- body: JSON.stringify({
4300
- model: entry.model,
4301
- input: [
4302
- {
4303
- type: "image_url",
4304
- image_url: {
4305
- url: entry.image
4306
- }
4483
+ const json = await callOpenAIModerationApi({
4484
+ model: entry.model,
4485
+ input: [
4486
+ {
4487
+ type: "image_url",
4488
+ image_url: {
4489
+ url: entry.image
4307
4490
  }
4308
- ]
4309
- })
4491
+ }
4492
+ ],
4493
+ credentials: entry.credentials
4310
4494
  });
4311
- const json = await res.json();
4312
- if (!res.ok) {
4313
- throw new Error(
4314
- `OpenAI moderation error: ${res.status} ${res.statusText} - ${JSON.stringify(json)}`
4315
- );
4316
- }
4317
4495
  const categoryScores = json.results?.[0]?.category_scores || {};
4318
4496
  return {
4319
4497
  url: entry.url,
@@ -4345,25 +4523,12 @@ async function requestOpenAIModeration(images, model, maxConcurrent = 5, submiss
4345
4523
  }
4346
4524
  async function requestOpenAITextModeration(text, model, url, credentials) {
4347
4525
  "use step";
4348
- const apiKey = await getApiKeyFromEnv("openai", credentials);
4349
4526
  try {
4350
- const res = await fetch("https://api.openai.com/v1/moderations", {
4351
- method: "POST",
4352
- headers: {
4353
- "Content-Type": "application/json",
4354
- "Authorization": `Bearer ${apiKey}`
4355
- },
4356
- body: JSON.stringify({
4357
- model,
4358
- input: text
4359
- })
4527
+ const json = await callOpenAIModerationApi({
4528
+ model,
4529
+ input: text,
4530
+ credentials
4360
4531
  });
4361
- const json = await res.json();
4362
- if (!res.ok) {
4363
- throw new Error(
4364
- `OpenAI moderation error: ${res.status} ${res.statusText} - ${JSON.stringify(json)}`
4365
- );
4366
- }
4367
4532
  const categoryScores = json.results?.[0]?.category_scores || {};
4368
4533
  return {
4369
4534
  url,
@@ -4569,22 +4734,13 @@ async function getModerationScores(assetId, options = {}) {
4569
4734
  let thumbnailCount;
4570
4735
  if (isAudioOnly) {
4571
4736
  mode = "transcript";
4572
- const readyTextTracks = getReadyTextTracks(asset);
4573
- let transcriptResult = await fetchTranscriptForAsset(asset, playbackId, {
4737
+ const transcriptResult = await fetchTranscriptForAsset(asset, playbackId, {
4574
4738
  languageCode,
4575
4739
  cleanTranscript: true,
4576
4740
  shouldSign: policy === "signed",
4577
4741
  credentials,
4578
4742
  required: true
4579
4743
  });
4580
- if (!transcriptResult.track && readyTextTracks.length === 1) {
4581
- transcriptResult = await fetchTranscriptForAsset(asset, playbackId, {
4582
- cleanTranscript: true,
4583
- shouldSign: policy === "signed",
4584
- credentials,
4585
- required: true
4586
- });
4587
- }
4588
4744
  if (provider === "openai") {
4589
4745
  thumbnailScores = await requestOpenAITranscriptModeration(
4590
4746
  transcriptResult.transcriptText,
@@ -4598,35 +4754,28 @@ async function getModerationScores(assetId, options = {}) {
4598
4754
  throw new Error(`Unsupported moderation provider: ${provider}`);
4599
4755
  }
4600
4756
  } else {
4601
- const thumbnailUrls = maxSamples === void 0 ? (
4602
- // Generate thumbnail URLs (signed if needed) using existing interval-based logic.
4603
- await getThumbnailUrls(playbackId, duration, {
4604
- interval: thumbnailInterval,
4757
+ const estimatedIntervalCount = duration <= 50 ? 5 : Math.ceil(duration / thumbnailInterval);
4758
+ const thumbnailUrls = maxSamples !== void 0 && estimatedIntervalCount > maxSamples ? await getThumbnailUrlsFromTimestamps(
4759
+ playbackId,
4760
+ planSamplingTimestamps({
4761
+ duration_sec: duration,
4762
+ max_candidates: maxSamples,
4763
+ trim_start_sec: duration > 2 ? Math.min(5, Math.max(1, duration / 6)) : 0,
4764
+ trim_end_sec: duration > 2 ? Math.min(5, Math.max(1, duration / 6)) : 0,
4765
+ fps: videoTrackFps,
4766
+ base_cadence_hz: thumbnailInterval > 0 ? 1 / thumbnailInterval : void 0
4767
+ }),
4768
+ {
4605
4769
  width: thumbnailWidth,
4606
4770
  shouldSign: policy === "signed",
4607
4771
  credentials
4608
- })
4609
- ) : (
4610
- // In maxSamples mode, sample valid timestamps over the trimmed usable span.
4611
- // Use proportional trims (≈ duration/6, capped at 5s) to stay well inside the
4612
- // renderable range — Mux can't always serve thumbnails at the very edges.
4613
- await getThumbnailUrlsFromTimestamps(
4614
- playbackId,
4615
- planSamplingTimestamps({
4616
- duration_sec: duration,
4617
- max_candidates: maxSamples,
4618
- trim_start_sec: duration > 2 ? Math.min(5, Math.max(1, duration / 6)) : 0,
4619
- trim_end_sec: duration > 2 ? Math.min(5, Math.max(1, duration / 6)) : 0,
4620
- fps: videoTrackFps,
4621
- base_cadence_hz: thumbnailInterval > 0 ? 1 / thumbnailInterval : void 0
4622
- }),
4623
- {
4624
- width: thumbnailWidth,
4625
- shouldSign: policy === "signed",
4626
- credentials
4627
- }
4628
- )
4629
- );
4772
+ }
4773
+ ) : await getThumbnailUrls(playbackId, duration, {
4774
+ interval: thumbnailInterval,
4775
+ width: thumbnailWidth,
4776
+ shouldSign: policy === "signed",
4777
+ credentials
4778
+ });
4630
4779
  thumbnailCount = thumbnailUrls.length;
4631
4780
  if (provider === "openai") {
4632
4781
  thumbnailScores = await requestOpenAIModeration(
@@ -4650,20 +4799,40 @@ async function getModerationScores(assetId, options = {}) {
4650
4799
  }
4651
4800
  }
4652
4801
  const failed = thumbnailScores.filter((s) => s.error);
4653
- if (failed.length > 0) {
4802
+ const successful = thumbnailScores.filter((s) => !s.error);
4803
+ if (successful.length === 0) {
4654
4804
  const details = failed.map((s) => `${s.url}: ${s.errorMessage || "Unknown error"}`).join("; ");
4655
4805
  throw new Error(
4656
- `Moderation failed for ${failed.length}/${thumbnailScores.length} thumbnail(s): ${details}`
4806
+ `Moderation failed for all ${thumbnailScores.length} sample(s): ${details}`
4657
4807
  );
4658
4808
  }
4659
- const maxSexual = Math.max(...thumbnailScores.map((s) => s.sexual));
4660
- const maxViolence = Math.max(...thumbnailScores.map((s) => s.violence));
4809
+ if (failed.length > 0) {
4810
+ console.warn(
4811
+ `Moderation had partial failures (${failed.length}/${thumbnailScores.length}); continuing with successful samples.`
4812
+ );
4813
+ }
4814
+ const maxSexual = Math.max(...successful.map((s) => s.sexual));
4815
+ const maxViolence = Math.max(...successful.map((s) => s.violence));
4661
4816
  const finalThresholds = { ...DEFAULT_THRESHOLDS, ...thresholds };
4817
+ const requestedSampleCount = thumbnailScores.length;
4818
+ const successfulSampleCount = successful.length;
4819
+ const failedSampleCount = failed.length;
4820
+ const sampleCoverage = successfulSampleCount / requestedSampleCount;
4821
+ const hasEnoughSuccessfulSamples = mode === "transcript" || successfulSampleCount >= MIN_SUCCESSFUL_THUMBNAILS_FOR_CONFIDENT_THRESHOLDING;
4822
+ const isLowConfidence = sampleCoverage < MIN_SAMPLE_COVERAGE_FOR_CONFIDENT_THRESHOLDING || !hasEnoughSuccessfulSamples;
4662
4823
  return {
4663
4824
  assetId,
4664
4825
  mode,
4665
4826
  isAudioOnly,
4666
4827
  thumbnailScores,
4828
+ coverage: {
4829
+ requestedSampleCount,
4830
+ successfulSampleCount,
4831
+ failedSampleCount,
4832
+ sampleCoverage,
4833
+ isPartial: failedSampleCount > 0,
4834
+ isLowConfidence
4835
+ },
4667
4836
  usage: {
4668
4837
  metadata: {
4669
4838
  assetDurationSeconds: duration,
@@ -4891,7 +5060,7 @@ var SYSTEM_PROMPT5 = dedent6`
4891
5060
 
4892
5061
  Write as if describing reality, not describing a recording of reality.
4893
5062
  </language_guidelines>`;
4894
- var AUDIO_ONLY_SYSTEM_PROMPT2 = dedent6`
5063
+ var AUDIO_ONLY_SYSTEM_PROMPT3 = dedent6`
4895
5064
  <role>
4896
5065
  You are an audio content analyst specializing in transcript analysis and metadata generation.
4897
5066
  </role>
@@ -5068,6 +5237,7 @@ async function getSummaryAndTags(assetId, options) {
5068
5237
  const {
5069
5238
  provider = "openai",
5070
5239
  model,
5240
+ languageCode,
5071
5241
  tone = "neutral",
5072
5242
  includeTranscript = true,
5073
5243
  cleanTranscript = true,
@@ -5106,6 +5276,7 @@ async function getSummaryAndTags(assetId, options) {
5106
5276
  );
5107
5277
  }
5108
5278
  const transcriptResult = includeTranscript ? await fetchTranscriptForAsset(assetData, playbackId, {
5279
+ languageCode,
5109
5280
  cleanTranscript,
5110
5281
  shouldSign: policy === "signed",
5111
5282
  credentials: workflowCredentials,
@@ -5127,7 +5298,7 @@ async function getSummaryAndTags(assetId, options) {
5127
5298
  });
5128
5299
  let analysisResponse;
5129
5300
  let imageUrl;
5130
- const systemPrompt = isAudioOnly ? AUDIO_ONLY_SYSTEM_PROMPT2 : SYSTEM_PROMPT5;
5301
+ const systemPrompt = isAudioOnly ? AUDIO_ONLY_SYSTEM_PROMPT3 : SYSTEM_PROMPT5;
5131
5302
  try {
5132
5303
  if (isAudioOnly) {
5133
5304
  analysisResponse = await analyzeAudioOnly(
@@ -5424,7 +5595,8 @@ async function translateAudio(assetId, toLanguageCode, options = {}) {
5424
5595
  fromLanguageCode,
5425
5596
  numSpeakers = 0,
5426
5597
  // 0 = auto-detect
5427
- uploadToMux = true,
5598
+ uploadToS3: uploadToS3Option,
5599
+ uploadToMux: uploadToMuxOption,
5428
5600
  storageAdapter,
5429
5601
  credentials: providedCredentials
5430
5602
  } = options;
@@ -5433,13 +5605,15 @@ async function translateAudio(assetId, toLanguageCode, options = {}) {
5433
5605
  }
5434
5606
  const credentials = providedCredentials;
5435
5607
  const effectiveStorageAdapter = storageAdapter;
5608
+ const uploadToMux = uploadToMuxOption !== false;
5609
+ const uploadToS3 = uploadToS3Option || uploadToMux;
5436
5610
  const s3Endpoint = options.s3Endpoint ?? env_default.S3_ENDPOINT;
5437
5611
  const s3Region = options.s3Region ?? env_default.S3_REGION ?? "auto";
5438
5612
  const s3Bucket = options.s3Bucket ?? env_default.S3_BUCKET;
5439
5613
  const s3AccessKeyId = env_default.S3_ACCESS_KEY_ID;
5440
5614
  const s3SecretAccessKey = env_default.S3_SECRET_ACCESS_KEY;
5441
- if (uploadToMux && (!s3Endpoint || !s3Bucket || !effectiveStorageAdapter && (!s3AccessKeyId || !s3SecretAccessKey))) {
5442
- throw new Error("Storage configuration is required for uploading to Mux. Provide s3Endpoint and s3Bucket. If no storageAdapter is supplied, also provide s3AccessKeyId and s3SecretAccessKey in options or set S3_ENDPOINT, S3_BUCKET, S3_ACCESS_KEY_ID, and S3_SECRET_ACCESS_KEY environment variables.");
5615
+ if (uploadToS3 && (!s3Endpoint || !s3Bucket || !effectiveStorageAdapter && (!s3AccessKeyId || !s3SecretAccessKey))) {
5616
+ throw new Error("Storage configuration is required for uploading. Provide s3Endpoint and s3Bucket. If no storageAdapter is supplied, also provide s3AccessKeyId and s3SecretAccessKey in options or set S3_ENDPOINT, S3_BUCKET, S3_ACCESS_KEY_ID, and S3_SECRET_ACCESS_KEY environment variables.");
5443
5617
  }
5444
5618
  const { asset: initialAsset, playbackId, policy } = await getPlaybackIdForAsset(assetId, credentials);
5445
5619
  const assetDurationSeconds = getAssetDurationSecondsFromAsset(initialAsset);
@@ -5516,70 +5690,65 @@ async function translateAudio(assetId, toLanguageCode, options = {}) {
5516
5690
  throw new Error(`Dubbing job timed out or failed. Final status: ${dubbingStatus}`);
5517
5691
  }
5518
5692
  console.warn("\u2705 Dubbing completed successfully!");
5519
- if (!uploadToMux) {
5520
- const targetLanguage2 = getLanguageCodePair(toLanguageCode);
5521
- return {
5522
- assetId,
5523
- targetLanguageCode: targetLanguage2.iso639_1,
5524
- targetLanguage: targetLanguage2,
5525
- dubbingId
5526
- };
5527
- }
5528
- console.warn("\u{1F4E5} Downloading dubbed audio from ElevenLabs...");
5529
- let dubbedAudioBuffer;
5530
- try {
5531
- const requestedLangCode = toISO639_3(toLanguageCode);
5532
- let downloadLangCode = targetLanguages.find(
5533
- (lang) => lang === requestedLangCode
5534
- ) ?? targetLanguages.find(
5535
- (lang) => lang.toLowerCase() === requestedLangCode.toLowerCase()
5536
- );
5537
- if (!downloadLangCode && targetLanguages.length > 0) {
5538
- downloadLangCode = targetLanguages[0];
5539
- console.warn(`\u26A0\uFE0F Requested language "${requestedLangCode}" not found in target_languages. Using "${downloadLangCode}" instead.`);
5540
- }
5541
- if (!downloadLangCode) {
5542
- downloadLangCode = requestedLangCode;
5543
- console.warn(`\u26A0\uFE0F No target_languages available from ElevenLabs status. Using requested language code: ${requestedLangCode}`);
5544
- }
5545
- dubbedAudioBuffer = await downloadDubbedAudioFromElevenLabs({
5546
- dubbingId,
5547
- languageCode: downloadLangCode,
5548
- credentials
5549
- });
5550
- console.warn("\u2705 Dubbed audio downloaded successfully!");
5551
- } catch (error) {
5552
- throw new Error(`Failed to download dubbed audio: ${error instanceof Error ? error.message : "Unknown error"}`);
5553
- }
5554
- console.warn("\u{1F4E4} Uploading dubbed audio to S3-compatible storage...");
5555
5693
  let presignedUrl;
5556
- try {
5557
- presignedUrl = await uploadDubbedAudioToS3({
5558
- dubbedAudioBuffer,
5559
- assetId,
5560
- toLanguageCode,
5561
- s3Endpoint,
5562
- s3Region,
5563
- s3Bucket,
5564
- storageAdapter: effectiveStorageAdapter,
5565
- s3SignedUrlExpirySeconds: options.s3SignedUrlExpirySeconds
5566
- });
5567
- } catch (error) {
5568
- throw new Error(`Failed to upload audio to S3: ${error instanceof Error ? error.message : "Unknown error"}`);
5569
- }
5570
- console.warn("\u{1F4F9} Adding dubbed audio track to Mux asset...");
5571
5694
  let uploadedTrackId;
5572
- const muxLangCode = toISO639_1(toLanguageCode);
5573
- try {
5574
- uploadedTrackId = await createAudioTrackOnMux(assetId, muxLangCode, presignedUrl, credentials);
5575
- const languageName = new Intl.DisplayNames(["en"], { type: "language" }).of(muxLangCode) || muxLangCode.toUpperCase();
5576
- const trackName = `${languageName} (auto-dubbed)`;
5577
- console.warn(`\u2705 Track added to Mux asset with ID: ${uploadedTrackId}`);
5578
- console.warn(`\u{1F4CB} Track name: "${trackName}"`);
5579
- } catch (error) {
5580
- console.warn(`\u26A0\uFE0F Failed to add audio track to Mux asset: ${error instanceof Error ? error.message : "Unknown error"}`);
5581
- console.warn("\u{1F517} You can manually add the track using this presigned URL:");
5582
- console.warn(presignedUrl);
5695
+ if (uploadToS3) {
5696
+ console.warn("\u{1F4E5} Downloading dubbed audio from ElevenLabs...");
5697
+ let dubbedAudioBuffer;
5698
+ try {
5699
+ const requestedLangCode = toISO639_3(toLanguageCode);
5700
+ let downloadLangCode = targetLanguages.find(
5701
+ (lang) => lang === requestedLangCode
5702
+ ) ?? targetLanguages.find(
5703
+ (lang) => lang.toLowerCase() === requestedLangCode.toLowerCase()
5704
+ );
5705
+ if (!downloadLangCode && targetLanguages.length > 0) {
5706
+ downloadLangCode = targetLanguages[0];
5707
+ console.warn(`\u26A0\uFE0F Requested language "${requestedLangCode}" not found in target_languages. Using "${downloadLangCode}" instead.`);
5708
+ }
5709
+ if (!downloadLangCode) {
5710
+ downloadLangCode = requestedLangCode;
5711
+ console.warn(`\u26A0\uFE0F No target_languages available from ElevenLabs status. Using requested language code: ${requestedLangCode}`);
5712
+ }
5713
+ dubbedAudioBuffer = await downloadDubbedAudioFromElevenLabs({
5714
+ dubbingId,
5715
+ languageCode: downloadLangCode,
5716
+ credentials
5717
+ });
5718
+ console.warn("\u2705 Dubbed audio downloaded successfully!");
5719
+ } catch (error) {
5720
+ throw new Error(`Failed to download dubbed audio: ${error instanceof Error ? error.message : "Unknown error"}`);
5721
+ }
5722
+ console.warn("\u{1F4E4} Uploading dubbed audio to S3-compatible storage...");
5723
+ try {
5724
+ presignedUrl = await uploadDubbedAudioToS3({
5725
+ dubbedAudioBuffer,
5726
+ assetId,
5727
+ toLanguageCode,
5728
+ s3Endpoint,
5729
+ s3Region,
5730
+ s3Bucket,
5731
+ storageAdapter: effectiveStorageAdapter,
5732
+ s3SignedUrlExpirySeconds: options.s3SignedUrlExpirySeconds
5733
+ });
5734
+ } catch (error) {
5735
+ throw new Error(`Failed to upload audio to S3: ${error instanceof Error ? error.message : "Unknown error"}`);
5736
+ }
5737
+ if (uploadToMux) {
5738
+ console.warn("\u{1F4F9} Adding dubbed audio track to Mux asset...");
5739
+ const muxLangCode = toISO639_1(toLanguageCode);
5740
+ try {
5741
+ uploadedTrackId = await createAudioTrackOnMux(assetId, muxLangCode, presignedUrl, credentials);
5742
+ const languageName = new Intl.DisplayNames(["en"], { type: "language" }).of(muxLangCode) || muxLangCode.toUpperCase();
5743
+ const trackName = `${languageName} (auto-dubbed)`;
5744
+ console.warn(`\u2705 Track added to Mux asset with ID: ${uploadedTrackId}`);
5745
+ console.warn(`\u{1F4CB} Track name: "${trackName}"`);
5746
+ } catch (error) {
5747
+ console.warn(`\u26A0\uFE0F Failed to add audio track to Mux asset: ${error instanceof Error ? error.message : "Unknown error"}`);
5748
+ console.warn("\u{1F517} You can manually add the track using this presigned URL:");
5749
+ console.warn(presignedUrl);
5750
+ }
5751
+ }
5583
5752
  }
5584
5753
  const targetLanguage = getLanguageCodePair(toLanguageCode);
5585
5754
  return {
@@ -6021,7 +6190,7 @@ async function uploadVttToS3({
6021
6190
  expiresInSeconds: s3SignedUrlExpirySeconds ?? 86400
6022
6191
  }, storageAdapter);
6023
6192
  }
6024
- async function translateCaptions(assetId, fromLanguageCode, toLanguageCode, options) {
6193
+ async function translateCaptions(assetId, trackId, toLanguageCode, options) {
6025
6194
  "use workflow";
6026
6195
  const {
6027
6196
  provider = "openai",
@@ -6029,6 +6198,7 @@ async function translateCaptions(assetId, fromLanguageCode, toLanguageCode, opti
6029
6198
  s3Endpoint: providedS3Endpoint,
6030
6199
  s3Region: providedS3Region,
6031
6200
  s3Bucket: providedS3Bucket,
6201
+ uploadToS3: uploadToS3Option,
6032
6202
  uploadToMux: uploadToMuxOption,
6033
6203
  storageAdapter,
6034
6204
  credentials: providedCredentials,
@@ -6042,17 +6212,17 @@ async function translateCaptions(assetId, fromLanguageCode, toLanguageCode, opti
6042
6212
  const s3AccessKeyId = env_default.S3_ACCESS_KEY_ID;
6043
6213
  const s3SecretAccessKey = env_default.S3_SECRET_ACCESS_KEY;
6044
6214
  const uploadToMux = uploadToMuxOption !== false;
6215
+ const uploadToS3 = uploadToS3Option || uploadToMux;
6045
6216
  const modelConfig = resolveLanguageModelConfig({
6046
6217
  ...options,
6047
6218
  model,
6048
6219
  provider
6049
6220
  });
6050
- if (uploadToMux && (!s3Endpoint || !s3Bucket || !effectiveStorageAdapter && (!s3AccessKeyId || !s3SecretAccessKey))) {
6051
- throw new Error("Storage configuration is required for uploading to Mux. Provide s3Endpoint and s3Bucket. If no storageAdapter is supplied, also provide s3AccessKeyId and s3SecretAccessKey in options or set S3_ENDPOINT, S3_BUCKET, S3_ACCESS_KEY_ID, and S3_SECRET_ACCESS_KEY environment variables.");
6221
+ if (uploadToS3 && (!s3Endpoint || !s3Bucket || !effectiveStorageAdapter && (!s3AccessKeyId || !s3SecretAccessKey))) {
6222
+ throw new Error("Storage configuration is required for uploading. Provide s3Endpoint and s3Bucket. If no storageAdapter is supplied, also provide s3AccessKeyId and s3SecretAccessKey in options or set S3_ENDPOINT, S3_BUCKET, S3_ACCESS_KEY_ID, and S3_SECRET_ACCESS_KEY environment variables.");
6052
6223
  }
6053
6224
  const { asset: assetData, playbackId, policy } = await getPlaybackIdForAsset(assetId, credentials);
6054
6225
  const assetDurationSeconds = getAssetDurationSecondsFromAsset(assetData);
6055
- const isAudioOnly = isAudioOnlyAsset(assetData);
6056
6226
  const signingContext = await resolveMuxSigningContext(credentials);
6057
6227
  if (policy === "signed" && !signingContext) {
6058
6228
  throw new Error(
@@ -6060,30 +6230,20 @@ async function translateCaptions(assetId, fromLanguageCode, toLanguageCode, opti
6060
6230
  );
6061
6231
  }
6062
6232
  const readyTextTracks = getReadyTextTracks(assetData);
6063
- if (!readyTextTracks.length) {
6064
- throw new Error("No ready text tracks found for this asset");
6065
- }
6066
- let sourceTextTrack = readyTextTracks.find(
6067
- (track) => track.text_type === "subtitles" && track.language_code === fromLanguageCode
6068
- );
6069
- if (!sourceTextTrack && isAudioOnly && readyTextTracks.length === 1) {
6070
- sourceTextTrack = readyTextTracks[0];
6071
- }
6233
+ const sourceTextTrack = readyTextTracks.find((t) => t.id === trackId);
6072
6234
  if (!sourceTextTrack) {
6073
- const availableLanguages = readyTextTracks.map((t) => t.language_code).filter(Boolean).join(", ");
6074
- if (isAudioOnly) {
6075
- throw new Error(
6076
- `No transcript track found with language code '${fromLanguageCode}' for this asset. Audio-only assets require a transcript. Available languages: ${availableLanguages || "none"}`
6077
- );
6078
- }
6235
+ const availableTrackIds = readyTextTracks.map((t) => t.id).filter(Boolean).join(", ");
6079
6236
  throw new Error(
6080
- `No ready text track found with language code '${fromLanguageCode}' for this asset. Available languages: ${availableLanguages || "none"}`
6237
+ `Track '${trackId}' not found or not ready on asset '${assetId}'. Available track IDs: ${availableTrackIds || "none"}`
6081
6238
  );
6082
6239
  }
6083
- if (!sourceTextTrack.id) {
6084
- throw new Error("Transcript track is missing an id");
6240
+ const fromLanguageCode = sourceTextTrack.language_code;
6241
+ if (!fromLanguageCode) {
6242
+ throw new Error(
6243
+ `Track '${trackId}' is missing language metadata (language_code). Cannot determine source language for translation.`
6244
+ );
6085
6245
  }
6086
- const vttUrl = await buildTranscriptUrl(playbackId, sourceTextTrack.id, policy === "signed", credentials);
6246
+ const vttUrl = await buildTranscriptUrl(playbackId, trackId, policy === "signed", credentials);
6087
6247
  let vttContent;
6088
6248
  try {
6089
6249
  vttContent = await fetchVttFromMux(vttUrl);
@@ -6116,50 +6276,43 @@ async function translateCaptions(assetId, fromLanguageCode, toLanguageCode, opti
6116
6276
  } : void 0;
6117
6277
  const sourceLanguage = getLanguageCodePair(fromLanguageCode);
6118
6278
  const targetLanguage = getLanguageCodePair(toLanguageCode);
6119
- if (!uploadToMux) {
6120
- return {
6121
- assetId,
6122
- sourceLanguageCode: fromLanguageCode,
6123
- targetLanguageCode: toLanguageCode,
6124
- sourceLanguage,
6125
- targetLanguage,
6126
- originalVtt: vttContent,
6127
- translatedVtt,
6128
- usage: usageWithMetadata
6129
- };
6130
- }
6131
6279
  let presignedUrl;
6132
- try {
6133
- presignedUrl = await uploadVttToS3({
6134
- translatedVtt,
6135
- assetId,
6136
- fromLanguageCode,
6137
- toLanguageCode,
6138
- s3Endpoint,
6139
- s3Region,
6140
- s3Bucket,
6141
- storageAdapter: effectiveStorageAdapter,
6142
- s3SignedUrlExpirySeconds: options.s3SignedUrlExpirySeconds
6143
- });
6144
- } catch (error) {
6145
- throw new Error(`Failed to upload VTT to S3: ${error instanceof Error ? error.message : "Unknown error"}`);
6146
- }
6147
6280
  let uploadedTrackId;
6148
- try {
6149
- const languageName = getLanguageName(toLanguageCode);
6150
- const trackName = `${languageName} (auto-translated)`;
6151
- uploadedTrackId = await createTextTrackOnMux(
6152
- assetId,
6153
- toLanguageCode,
6154
- trackName,
6155
- presignedUrl,
6156
- credentials
6157
- );
6158
- } catch (error) {
6159
- console.warn(`Failed to add track to Mux asset: ${error instanceof Error ? error.message : "Unknown error"}`);
6281
+ if (uploadToS3) {
6282
+ try {
6283
+ presignedUrl = await uploadVttToS3({
6284
+ translatedVtt,
6285
+ assetId,
6286
+ fromLanguageCode,
6287
+ toLanguageCode,
6288
+ s3Endpoint,
6289
+ s3Region,
6290
+ s3Bucket,
6291
+ storageAdapter: effectiveStorageAdapter,
6292
+ s3SignedUrlExpirySeconds: options.s3SignedUrlExpirySeconds
6293
+ });
6294
+ } catch (error) {
6295
+ throw new Error(`Failed to upload VTT to S3: ${error instanceof Error ? error.message : "Unknown error"}`);
6296
+ }
6297
+ if (uploadToMux) {
6298
+ try {
6299
+ const languageName = getLanguageName(toLanguageCode);
6300
+ const trackName = `${languageName} (auto-translated)`;
6301
+ uploadedTrackId = await createTextTrackOnMux(
6302
+ assetId,
6303
+ toLanguageCode,
6304
+ trackName,
6305
+ presignedUrl,
6306
+ credentials
6307
+ );
6308
+ } catch (error) {
6309
+ console.warn(`Failed to add track to Mux asset: ${error instanceof Error ? error.message : "Unknown error"}`);
6310
+ }
6311
+ }
6160
6312
  }
6161
6313
  return {
6162
6314
  assetId,
6315
+ trackId,
6163
6316
  sourceLanguageCode: fromLanguageCode,
6164
6317
  targetLanguageCode: toLanguageCode,
6165
6318
  sourceLanguage,