@mux/ai 0.52.0 → 0.53.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.
@@ -531,6 +531,9 @@ var MuxAiError = class extends Error {
531
531
  super(message);
532
532
  /** Structural brand for duck-type identification. */
533
533
  this.__robots_error = true;
534
+ if (opts?.cause !== void 0) {
535
+ Object.defineProperty(this, "cause", { value: opts.cause, writable: true, configurable: true });
536
+ }
534
537
  this.name = "FatalError";
535
538
  this.publicType = opts?.type ?? "processing_error";
536
539
  this.publicMessage = message;
@@ -2601,9 +2604,10 @@ function extractTimestampedTranscript(vttContent) {
2601
2604
  segments.map((segment) => `[${Math.floor(segment.time)}s] ${segment.text}`).join("\n")
2602
2605
  );
2603
2606
  }
2604
- function parseVTTCues(vttContent) {
2607
+ function parseVTTCues(vttContent, options = {}) {
2605
2608
  if (!vttContent.trim())
2606
2609
  return [];
2610
+ const { capCueText = true } = options;
2607
2611
  const lines = vttContent.split("\n");
2608
2612
  const cues = [];
2609
2613
  let previousCueIdentifier = null;
@@ -2635,15 +2639,16 @@ function parseVTTCues(vttContent) {
2635
2639
  }
2636
2640
  const textLines = rawTextLines.map((textLine) => textLine.replace(/<[^>]*>/g, "")).filter(Boolean);
2637
2641
  if (textLines.length > 0) {
2642
+ const sanitized = sanitizeUntrustedText(textLines.join(" "));
2638
2643
  cues.push({
2639
2644
  startTime,
2640
2645
  endTime,
2641
2646
  // Sanitize at the cue boundary so every downstream consumer
2642
2647
  // (engagement-insights, translate-captions chunked path, etc.)
2643
2648
  // receives text free of invisible / bidi obfuscation. Length-
2644
- // cap the result so an adversarially oversized cue cannot
2645
- // smuggle a large injection payload into the prompt.
2646
- text: capCueTextLength(sanitizeUntrustedText(textLines.join(" ")))
2649
+ // cap the result by default so an adversarially oversized cue
2650
+ // cannot smuggle a large injection payload into a prompt.
2651
+ text: capCueText ? capCueTextLength(sanitized) : sanitized
2647
2652
  });
2648
2653
  }
2649
2654
  previousCueIdentifier = currentCueIdentifier;
@@ -2767,16 +2772,32 @@ async function buildTranscriptUrl(playbackId, trackId, shouldSign = false, crede
2767
2772
  }
2768
2773
  return baseUrl;
2769
2774
  }
2770
- async function fetchTranscriptForAsset(asset, playbackId, options = {}) {
2775
+ async function fetchTranscriptDataForUrl(transcriptUrl, options = {}) {
2771
2776
  "use step";
2772
- const {
2773
- languageCode,
2774
- cleanTranscript = true,
2775
- scope,
2776
- shouldSign,
2777
- credentials,
2778
- required = false
2779
- } = options;
2777
+ const { cleanTranscript = true, scope } = options;
2778
+ try {
2779
+ const response = await fetch(transcriptUrl);
2780
+ if (!response.ok) {
2781
+ throw new Error(`Failed to fetch transcript (HTTP ${response.status})`);
2782
+ }
2783
+ const rawVtt = await response.text();
2784
+ const effectiveScope = hasWorkflowScopeBoundaries(scope) ? scope : void 0;
2785
+ const scopedVtt = filterVttByScope(rawVtt, effectiveScope);
2786
+ const transcriptText = cleanTranscript ? extractTextFromVTT(scopedVtt) : scopedVtt;
2787
+ const hasScopedCues = !effectiveScope || parseVTTCues(scopedVtt).length > 0;
2788
+ if (!transcriptText.trim() || !hasScopedCues) {
2789
+ return {
2790
+ transcriptText,
2791
+ emptyReason: hasScopedCues ? "empty_body" : "no_scoped_cues"
2792
+ };
2793
+ }
2794
+ return { transcriptText };
2795
+ } catch (error) {
2796
+ wrapError(error, "Failed to fetch transcript");
2797
+ }
2798
+ }
2799
+ async function fetchTranscriptForAsset(asset, playbackId, options = {}) {
2800
+ const { required = false, languageCode, cleanTranscript, scope, shouldSign, credentials } = options;
2780
2801
  const track = findCaptionTrack(asset, languageCode);
2781
2802
  if (!track) {
2782
2803
  if (required) {
@@ -2795,33 +2816,25 @@ async function fetchTranscriptForAsset(asset, playbackId, options = {}) {
2795
2816
  return { transcriptText: "", track };
2796
2817
  }
2797
2818
  const transcriptUrl = await buildTranscriptUrl(playbackId, track.id, shouldSign, credentials);
2819
+ let result;
2798
2820
  try {
2799
- const response = await fetch(transcriptUrl);
2800
- if (!response.ok) {
2801
- if (required) {
2802
- throw new Error(`Failed to fetch transcript (HTTP ${response.status})`);
2803
- }
2804
- return { transcriptText: "", transcriptUrl, track };
2805
- }
2806
- const rawVtt = await response.text();
2807
- const effectiveScope = hasWorkflowScopeBoundaries(scope) ? scope : void 0;
2808
- const scopedVtt = filterVttByScope(rawVtt, effectiveScope);
2809
- const transcriptText = cleanTranscript ? extractTextFromVTT(scopedVtt) : scopedVtt;
2810
- const hasScopedCues = !effectiveScope || parseVTTCues(scopedVtt).length > 0;
2811
- if (required && (!transcriptText.trim() || !hasScopedCues)) {
2812
- throw new MuxAiError(
2813
- effectiveScope ? "Transcript has no cues in the requested scope." : "Transcript is empty.",
2814
- { type: "validation_error" }
2815
- );
2816
- }
2817
- return { transcriptText, transcriptUrl, track };
2821
+ result = await fetchTranscriptDataForUrl(transcriptUrl, { cleanTranscript, scope });
2818
2822
  } catch (error) {
2819
2823
  if (required) {
2820
- wrapError(error, "Failed to fetch transcript");
2824
+ throw error;
2821
2825
  }
2822
2826
  console.warn("Failed to fetch transcript:", error);
2823
2827
  return { transcriptText: "", transcriptUrl, track };
2824
2828
  }
2829
+ if (!required || !result.emptyReason) {
2830
+ return { transcriptText: result.transcriptText, transcriptUrl, track };
2831
+ }
2832
+ switch (result.emptyReason) {
2833
+ case "no_scoped_cues":
2834
+ throw new MuxAiError("Transcript has no cues in the requested scope.", { type: "validation_error" });
2835
+ case "empty_body":
2836
+ throw new MuxAiError("Transcript is empty.", { type: "validation_error" });
2837
+ }
2825
2838
  }
2826
2839
 
2827
2840
  // src/workflows/ask-questions.ts
@@ -6839,10 +6852,27 @@ ${renderSection({ tag: "revision_request", content: "The previous draft was empt
6839
6852
  usages.push(...repaired.attempts.flatMap((item) => item.call.usage ? [item.call.usage] : []));
6840
6853
  if (repaired.status !== "valid") {
6841
6854
  const cause = repaired.attempts.at(-1)?.call.errorCause;
6842
- const failure = isIncompleteGenerationError(cause) ? new IncompleteGenerationError(cause.finishReason, cause.rawFinishReason) : MuxAiError.is(cause) ? new MuxAiError(cause.publicMessage, { type: cause.publicType, retryable: cause.retryable }) : new MuxAiError(`Bounded text repair failed: ${repaired.reason}`, {
6843
- type: "processing_error",
6844
- retryable: APICallError3.isInstance(cause) ? cause.isRetryable : true
6845
- });
6855
+ let failure;
6856
+ if (isIncompleteGenerationError(cause)) {
6857
+ failure = new IncompleteGenerationError(cause.finishReason, cause.rawFinishReason);
6858
+ } else if (MuxAiError.is(cause)) {
6859
+ failure = new MuxAiError(cause.publicMessage, { type: cause.publicType, retryable: cause.retryable });
6860
+ } else {
6861
+ const apiError = APICallError3.isInstance(cause) ? cause : void 0;
6862
+ console.warn("[@mux/ai] Bounded text repair failed.", {
6863
+ provider: args.provider,
6864
+ modelId: args.modelId,
6865
+ reason: repaired.reason,
6866
+ errorName: cause instanceof Error ? cause.name : void 0,
6867
+ statusCode: apiError?.statusCode,
6868
+ requestId: apiError?.responseHeaders?.["x-request-id"] ?? apiError?.responseHeaders?.["request-id"]
6869
+ });
6870
+ failure = new MuxAiError("Unable to complete text generation.", {
6871
+ type: "processing_error",
6872
+ retryable: apiError?.isRetryable ?? true,
6873
+ cause
6874
+ });
6875
+ }
6846
6876
  rethrowWithTokenUsage(failure, usages);
6847
6877
  }
6848
6878
  return { content: repaired.content, usages, unexpectedKeys };
@@ -7020,6 +7050,9 @@ async function generateTextInternal(assetId, rawOptions, collectedUsage) {
7020
7050
  };
7021
7051
  }
7022
7052
 
7053
+ // src/workflows/moderation.ts
7054
+ import { z as z9 } from "zod";
7055
+
7023
7056
  // src/lib/sampling-plan.ts
7024
7057
  var DEFAULT_FPS = 30;
7025
7058
  function roundToNearestFrameMs(tsMs, fps = DEFAULT_FPS) {
@@ -7133,6 +7166,15 @@ var OPENAI_MODERATION_BASE_DELAY_MS = 750;
7133
7166
  var OPENAI_MODERATION_MAX_DELAY_MS = 3e3;
7134
7167
  var MIN_SAMPLE_COVERAGE_FOR_CONFIDENT_THRESHOLDING = 0.5;
7135
7168
  var MIN_SUCCESSFUL_THUMBNAILS_FOR_CONFIDENT_THRESHOLDING = 3;
7169
+ var DEFAULT_TRANSCRIPT_WINDOWING = {
7170
+ targetWindowCount: 40,
7171
+ minWindowSeconds: 20,
7172
+ maxWindowSeconds: 120,
7173
+ overlapFraction: 0.15,
7174
+ minOverlapSeconds: 5
7175
+ };
7176
+ var TRANSCRIPT_BATCH_MAX_UTF16_CODE_UNITS = 1e5;
7177
+ var TRANSCRIPT_BATCH_MAX_ITEMS = 100;
7136
7178
  var GOOGLE_VISION_ENDPOINT = "https://vision.googleapis.com/v1/images:annotate";
7137
7179
  var GOOGLE_VISION_LIKELIHOOD_TO_SCORE = {
7138
7180
  UNKNOWN: 0,
@@ -7200,6 +7242,14 @@ function parseOpenAIModerationResponse(bodyText, status, statusText) {
7200
7242
  );
7201
7243
  }
7202
7244
  }
7245
+ var OpenAIModerationResponseSchema = z9.object({
7246
+ results: z9.array(z9.object({
7247
+ category_scores: z9.object({
7248
+ sexual: z9.number().finite().nullish(),
7249
+ violence: z9.number().finite().nullish()
7250
+ })
7251
+ }))
7252
+ });
7203
7253
  async function callOpenAIModerationApi({
7204
7254
  model,
7205
7255
  input,
@@ -7239,7 +7289,21 @@ async function callOpenAIModerationApi({
7239
7289
  }
7240
7290
  );
7241
7291
  }
7242
- return json;
7292
+ const parsed = OpenAIModerationResponseSchema.safeParse(json);
7293
+ if (!parsed.success) {
7294
+ throw new OpenAIModerationRequestError(
7295
+ `OpenAI moderation returned an unexpected response shape: ${compactBodySnippet(bodyText)}`,
7296
+ { status: res.status, retriable: false }
7297
+ );
7298
+ }
7299
+ const expectedResultCount = Array.isArray(input) ? input.length : 1;
7300
+ if (parsed.data.results.length !== expectedResultCount) {
7301
+ throw new OpenAIModerationRequestError(
7302
+ `OpenAI moderation returned ${parsed.data.results.length} result(s) for ${expectedResultCount} input(s).`,
7303
+ { status: res.status, retriable: false }
7304
+ );
7305
+ }
7306
+ return parsed.data;
7243
7307
  },
7244
7308
  {
7245
7309
  maxRetries: OPENAI_MODERATION_MAX_RETRIES,
@@ -7263,7 +7327,7 @@ async function processConcurrently(items, processor, maxConcurrent = 5) {
7263
7327
  async function moderateImageWithOpenAI(entry) {
7264
7328
  "use step";
7265
7329
  try {
7266
- const json = await callOpenAIModerationApi({
7330
+ const { results } = await callOpenAIModerationApi({
7267
7331
  model: entry.model,
7268
7332
  input: [
7269
7333
  {
@@ -7275,12 +7339,12 @@ async function moderateImageWithOpenAI(entry) {
7275
7339
  ],
7276
7340
  credentials: entry.credentials
7277
7341
  });
7278
- const categoryScores = json.results?.[0]?.category_scores || {};
7342
+ const [result] = results;
7279
7343
  return {
7280
7344
  url: entry.url,
7281
7345
  time: entry.time,
7282
- sexual: categoryScores.sexual || 0,
7283
- violence: categoryScores.violence || 0,
7346
+ sexual: result.category_scores.sexual ?? 0,
7347
+ violence: result.category_scores.violence ?? 0,
7284
7348
  error: false
7285
7349
  };
7286
7350
  } catch (error) {
@@ -7304,65 +7368,187 @@ async function requestOpenAIModeration(images, model, maxConcurrent = 5, submiss
7304
7368
  ) : images.map((img) => ({ url: img.url, time: img.time, image: img.url, model, credentials }));
7305
7369
  return processConcurrently(targetUrls, moderateImageWithOpenAI, maxConcurrent);
7306
7370
  }
7307
- async function requestOpenAITextModeration(text, model, url, credentials) {
7308
- "use step";
7309
- try {
7310
- const json = await callOpenAIModerationApi({
7311
- model,
7312
- input: text,
7313
- credentials
7371
+ var TRANSCRIPT_WINDOW_MAX_UTF16_CODE_UNITS = 1e4;
7372
+ function describeSurfaceFailure(itemLabel, scores) {
7373
+ const messages = Array.from(new Set(scores.map((s) => s.errorMessage || "Unknown error")));
7374
+ return `All ${scores.length} ${itemLabel}(s) failed to moderate: ${messages.join("; ")}`;
7375
+ }
7376
+ function clamp(value, min, max) {
7377
+ return Math.min(Math.max(value, min), max);
7378
+ }
7379
+ function splitCuesUnderCharCeiling(cues, maxUnits = TRANSCRIPT_WINDOW_MAX_UTF16_CODE_UNITS) {
7380
+ const windows = [];
7381
+ let current = [];
7382
+ let currentLength = 0;
7383
+ const flush = () => {
7384
+ if (current.length === 0) {
7385
+ return;
7386
+ }
7387
+ windows.push({
7388
+ startTime: Math.min(...current.map((cue) => cue.startTime)),
7389
+ endTime: Math.max(...current.map((cue) => cue.endTime)),
7390
+ text: current.map((cue) => cue.text).join(" ")
7314
7391
  });
7315
- const categoryScores = json.results?.[0]?.category_scores || {};
7316
- return {
7317
- url,
7318
- sexual: categoryScores.sexual || 0,
7319
- violence: categoryScores.violence || 0,
7320
- error: false
7321
- };
7322
- } catch (error) {
7323
- console.error("OpenAI text moderation failed:", error);
7324
- return {
7325
- url,
7326
- sexual: 0,
7327
- violence: 0,
7328
- error: true,
7329
- errorMessage: error instanceof Error ? error.message : String(error)
7330
- };
7392
+ current = [];
7393
+ currentLength = 0;
7394
+ };
7395
+ for (const cue of cues) {
7396
+ const cueText = cue.text;
7397
+ const addedLength = currentLength === 0 ? cueText.length : currentLength + 1 + cueText.length;
7398
+ if (current.length > 0 && addedLength > maxUnits) {
7399
+ flush();
7400
+ }
7401
+ current.push(cue);
7402
+ currentLength = current.length === 1 ? cueText.length : currentLength + 1 + cueText.length;
7331
7403
  }
7404
+ flush();
7405
+ return windows;
7332
7406
  }
7333
- function chunkTextByUtf16CodeUnits(text, maxUnits) {
7334
- if (!text.trim()) {
7407
+ function buildTranscriptWindows(cues, duration) {
7408
+ const usableCues = cues.filter((cue) => cue.text.trim().length > 0).slice().sort((a, b) => a.startTime - b.startTime);
7409
+ if (usableCues.length === 0) {
7335
7410
  return [];
7336
7411
  }
7337
- if (text.length <= maxUnits) {
7338
- return [text];
7412
+ const lastCueEnd = Math.max(...usableCues.map((cue) => cue.endTime));
7413
+ const effectiveDuration = duration && duration > 0 ? duration : lastCueEnd;
7414
+ const { targetWindowCount, minWindowSeconds, maxWindowSeconds, overlapFraction, minOverlapSeconds } = DEFAULT_TRANSCRIPT_WINDOWING;
7415
+ const windowSeconds = clamp(
7416
+ effectiveDuration / targetWindowCount,
7417
+ minWindowSeconds,
7418
+ maxWindowSeconds
7419
+ );
7420
+ const overlapSeconds = Math.max(
7421
+ minOverlapSeconds,
7422
+ windowSeconds * overlapFraction
7423
+ );
7424
+ const stride = Math.max(windowSeconds - overlapSeconds, 1);
7425
+ const rawWindows = [];
7426
+ for (let k = 0; ; k++) {
7427
+ const windowStart = k * stride;
7428
+ if (windowStart > lastCueEnd) {
7429
+ break;
7430
+ }
7431
+ const windowEnd = windowStart + windowSeconds;
7432
+ const windowCues = usableCues.filter(
7433
+ (cue) => cue.startTime < windowEnd && cue.endTime > windowStart
7434
+ );
7435
+ if (windowCues.length > 0) {
7436
+ rawWindows.push(windowCues);
7437
+ }
7438
+ if (stride <= 0) {
7439
+ break;
7440
+ }
7339
7441
  }
7340
- const chunks = [];
7341
- for (let i = 0; i < text.length; i += maxUnits) {
7342
- const chunk = text.slice(i, i + maxUnits).trim();
7343
- if (chunk) {
7344
- chunks.push(chunk);
7442
+ const dedupedCueGroups = [];
7443
+ const cueGroupKey = (group) => group.map((cue) => `${cue.startTime}:${cue.endTime}`).join("|");
7444
+ let previousKey;
7445
+ for (const group of rawWindows) {
7446
+ const key = cueGroupKey(group);
7447
+ if (key === previousKey) {
7448
+ continue;
7345
7449
  }
7450
+ dedupedCueGroups.push(group);
7451
+ previousKey = key;
7346
7452
  }
7347
- return chunks;
7453
+ const windows = [];
7454
+ for (const group of dedupedCueGroups) {
7455
+ const joinedLength = group.reduce(
7456
+ (sum, cue, index) => sum + cue.text.length + (index === 0 ? 0 : 1),
7457
+ 0
7458
+ );
7459
+ if (joinedLength > TRANSCRIPT_WINDOW_MAX_UTF16_CODE_UNITS) {
7460
+ windows.push(...splitCuesUnderCharCeiling(group));
7461
+ } else {
7462
+ windows.push({
7463
+ startTime: Math.min(...group.map((cue) => cue.startTime)),
7464
+ endTime: Math.max(...group.map((cue) => cue.endTime)),
7465
+ text: group.map((cue) => cue.text).join(" ")
7466
+ });
7467
+ }
7468
+ }
7469
+ return windows;
7470
+ }
7471
+ function packWindowsIntoBatches(windows) {
7472
+ const batches = [];
7473
+ let current = [];
7474
+ let currentLength = 0;
7475
+ for (const window of windows) {
7476
+ const wouldExceedChars = current.length > 0 && currentLength + window.text.length > TRANSCRIPT_BATCH_MAX_UTF16_CODE_UNITS;
7477
+ const wouldExceedItems = current.length >= TRANSCRIPT_BATCH_MAX_ITEMS;
7478
+ if (wouldExceedChars || wouldExceedItems) {
7479
+ batches.push(current);
7480
+ current = [];
7481
+ currentLength = 0;
7482
+ }
7483
+ current.push(window);
7484
+ currentLength += window.text.length;
7485
+ }
7486
+ if (current.length > 0) {
7487
+ batches.push(current);
7488
+ }
7489
+ return batches;
7490
+ }
7491
+ function transcriptErrorScore(window, error) {
7492
+ return {
7493
+ startTime: window.startTime,
7494
+ endTime: window.endTime,
7495
+ sexual: 0,
7496
+ violence: 0,
7497
+ error: true,
7498
+ errorMessage: error instanceof Error ? error.message : String(error)
7499
+ };
7348
7500
  }
7349
- async function requestOpenAITranscriptModeration(transcriptText, model, maxConcurrent = 5, credentials) {
7501
+ async function moderateTranscriptBatchWithOpenAI(batch) {
7350
7502
  "use step";
7351
- const chunks = chunkTextByUtf16CodeUnits(transcriptText, 1e4);
7352
- if (!chunks.length) {
7353
- return [
7354
- { url: "transcript:0", sexual: 0, violence: 0, error: true, errorMessage: "No transcript chunks to moderate" }
7355
- ];
7356
- }
7357
- const targets = chunks.map((chunk, idx) => ({
7358
- chunk,
7359
- url: `transcript:${idx}`
7360
- }));
7361
- return processConcurrently(
7362
- targets,
7363
- async (entry) => requestOpenAITextModeration(entry.chunk, model, entry.url, credentials),
7503
+ const { windows, model, credentials } = batch;
7504
+ if (windows.length === 0) {
7505
+ return [];
7506
+ }
7507
+ try {
7508
+ const { results } = await callOpenAIModerationApi({
7509
+ model,
7510
+ input: windows.map((window) => window.text),
7511
+ credentials
7512
+ });
7513
+ return windows.map((window, index) => {
7514
+ const result = results[index];
7515
+ return {
7516
+ startTime: window.startTime,
7517
+ endTime: window.endTime,
7518
+ sexual: result.category_scores.sexual ?? 0,
7519
+ violence: result.category_scores.violence ?? 0,
7520
+ error: false
7521
+ };
7522
+ });
7523
+ } catch (error) {
7524
+ const status = error instanceof OpenAIModerationRequestError ? error.status : void 0;
7525
+ if (status === 400 && windows.length > 1) {
7526
+ const mid = Math.ceil(windows.length / 2);
7527
+ const [left, right] = await Promise.all([
7528
+ moderateTranscriptBatchWithOpenAI({ windows: windows.slice(0, mid), model, credentials }),
7529
+ moderateTranscriptBatchWithOpenAI({ windows: windows.slice(mid), model, credentials })
7530
+ ]);
7531
+ return [...left, ...right];
7532
+ }
7533
+ console.error("OpenAI transcript moderation failed:", error);
7534
+ return windows.map((window) => transcriptErrorScore(window, error));
7535
+ }
7536
+ }
7537
+ async function requestOpenAITranscriptModeration(cues, duration, model, maxConcurrent = 5, credentials) {
7538
+ "use step";
7539
+ const windows = buildTranscriptWindows(cues, duration);
7540
+ if (!windows.length) {
7541
+ return [];
7542
+ }
7543
+ const batches = packWindowsIntoBatches(windows).map(
7544
+ (windowBatch) => ({ windows: windowBatch, model, credentials })
7545
+ );
7546
+ const batchResults = await processConcurrently(
7547
+ batches,
7548
+ moderateTranscriptBatchWithOpenAI,
7364
7549
  maxConcurrent
7365
7550
  );
7551
+ return batchResults.flat();
7366
7552
  }
7367
7553
  function getHiveCategoryScores(classes, categoryNames) {
7368
7554
  const scoreMap = Object.fromEntries(
@@ -7585,6 +7771,28 @@ async function getThumbnailUrlsFromTimestamps(playbackId, timestampsMs, options)
7585
7771
  });
7586
7772
  return Promise.all(urlPromises);
7587
7773
  }
7774
+ async function requestThumbnailModeration(provider, thumbnailUrls, options) {
7775
+ const { model, maxConcurrent, imageSubmissionMode, imageDownloadOptions, credentials } = options;
7776
+ switch (provider) {
7777
+ case "openai":
7778
+ return requestOpenAIModeration(
7779
+ thumbnailUrls,
7780
+ model || "omni-moderation-latest",
7781
+ maxConcurrent,
7782
+ imageSubmissionMode,
7783
+ imageDownloadOptions,
7784
+ credentials
7785
+ );
7786
+ case "hive":
7787
+ return requestHiveModeration(thumbnailUrls, maxConcurrent, imageSubmissionMode, imageDownloadOptions, credentials);
7788
+ case "google-vision-api":
7789
+ return requestGoogleVisionModeration(thumbnailUrls, maxConcurrent, imageSubmissionMode, imageDownloadOptions, credentials);
7790
+ default: {
7791
+ const exhaustiveCheck = provider;
7792
+ throw new Error(`Unsupported moderation provider: ${exhaustiveCheck}`);
7793
+ }
7794
+ }
7795
+ }
7588
7796
  async function getModerationScores(assetId, options = {}) {
7589
7797
  "use workflow";
7590
7798
  const {
@@ -7617,47 +7825,31 @@ async function getModerationScores(assetId, options = {}) {
7617
7825
  startTime: Math.min(resolvedScope.startTime, duration),
7618
7826
  endTime: Math.min(resolvedScope.endTime, duration)
7619
7827
  } : void 0;
7620
- if (!isAudioOnly && renderableScope && renderableScope.startTime >= renderableScope.endTime) {
7621
- throw new Error("The requested scope does not include any renderable video.");
7622
- }
7623
7828
  const signingContext = await resolveMuxSigningContext(credentials);
7624
7829
  if (policy === "signed" && !signingContext) {
7625
7830
  throw new Error(
7626
7831
  "Signed playback ID requires signing credentials. Set MUX_SIGNING_KEY and MUX_PRIVATE_KEY environment variables."
7627
7832
  );
7628
7833
  }
7629
- let thumbnailScores;
7630
- let mode = "thumbnails";
7631
- let thumbnailCount;
7834
+ let thumbnailModeration = { status: "completed" };
7835
+ let thumbnailUrls = [];
7836
+ let shouldRunThumbnails = false;
7632
7837
  if (isAudioOnly) {
7633
- mode = "transcript";
7634
- const transcriptResult = await fetchTranscriptForAsset(asset, playbackId, {
7635
- languageCode,
7636
- cleanTranscript: true,
7637
- shouldSign: policy === "signed",
7638
- credentials,
7639
- required: true,
7640
- scope: effectiveScope
7641
- });
7642
- if (provider === "openai") {
7643
- thumbnailScores = await requestOpenAITranscriptModeration(
7644
- transcriptResult.transcriptText,
7645
- model || "omni-moderation-latest",
7646
- maxConcurrent,
7647
- credentials
7648
- );
7649
- } else if (provider === "hive") {
7650
- throw new Error("Hive does not support transcript moderation in this workflow. Use provider: 'openai' for audio-only assets.");
7651
- } else if (provider === "google-vision-api") {
7652
- throw new Error("google-vision-api is image-only and does not support transcript moderation. Use provider: 'openai' for audio-only assets.");
7653
- } else {
7654
- const exhaustiveCheck = provider;
7655
- throw new Error(`Unsupported moderation provider: ${exhaustiveCheck}`);
7656
- }
7838
+ thumbnailModeration = {
7839
+ status: "skipped",
7840
+ skipReason: "no_video_track",
7841
+ skipMessage: "Asset has no video track, so there are no thumbnails to moderate."
7842
+ };
7843
+ } else if (renderableScope && renderableScope.startTime >= renderableScope.endTime) {
7844
+ thumbnailModeration = {
7845
+ status: "skipped",
7846
+ skipReason: "no_video_in_scope",
7847
+ skipMessage: "The requested scope does not include any renderable video, so there are no thumbnails to moderate."
7848
+ };
7657
7849
  } else {
7658
7850
  const scopedDuration = renderableScope ? renderableScope.endTime - renderableScope.startTime : duration;
7659
7851
  const estimatedIntervalCount = scopedDuration <= 50 ? 5 : Math.ceil(scopedDuration / thumbnailInterval);
7660
- const thumbnailUrls = maxSamples !== void 0 && estimatedIntervalCount > maxSamples ? await getThumbnailUrlsFromTimestamps(
7852
+ thumbnailUrls = maxSamples !== void 0 && estimatedIntervalCount > maxSamples ? await getThumbnailUrlsFromTimestamps(
7661
7853
  playbackId,
7662
7854
  filterTimestampsWithinExclusiveScope(
7663
7855
  planSamplingTimestamps({
@@ -7682,70 +7874,141 @@ async function getModerationScores(assetId, options = {}) {
7682
7874
  credentials,
7683
7875
  scope: renderableScope
7684
7876
  });
7685
- thumbnailCount = thumbnailUrls.length;
7686
- if (provider === "openai") {
7687
- thumbnailScores = await requestOpenAIModeration(
7688
- thumbnailUrls,
7689
- model || "omni-moderation-latest",
7690
- maxConcurrent,
7691
- imageSubmissionMode,
7692
- imageDownloadOptions,
7693
- credentials
7694
- );
7695
- } else if (provider === "hive") {
7696
- thumbnailScores = await requestHiveModeration(
7697
- thumbnailUrls,
7698
- maxConcurrent,
7699
- imageSubmissionMode,
7700
- imageDownloadOptions,
7701
- credentials
7702
- );
7703
- } else if (provider === "google-vision-api") {
7704
- thumbnailScores = await requestGoogleVisionModeration(
7705
- thumbnailUrls,
7706
- maxConcurrent,
7707
- imageSubmissionMode,
7708
- imageDownloadOptions,
7709
- credentials
7710
- );
7877
+ shouldRunThumbnails = true;
7878
+ }
7879
+ let transcriptModeration = { status: "completed" };
7880
+ let transcriptCues = [];
7881
+ let shouldRunTranscript = false;
7882
+ const captionTrack = provider === "openai" ? findCaptionTrack(asset, languageCode) : void 0;
7883
+ if (provider !== "openai") {
7884
+ transcriptModeration = {
7885
+ status: "skipped",
7886
+ skipReason: "unsupported_provider",
7887
+ skipMessage: `Provider '${provider}' is image-only and cannot moderate transcript text; use provider 'openai'.`
7888
+ };
7889
+ } else if (!captionTrack) {
7890
+ transcriptModeration = {
7891
+ status: "skipped",
7892
+ skipReason: "no_ready_text_track",
7893
+ skipMessage: languageCode ? `No ready caption/subtitle track found for language '${languageCode}'.` : "No ready caption/subtitle track found for this asset."
7894
+ };
7895
+ } else {
7896
+ if (!captionTrack.id) {
7897
+ throw new MuxAiError("Transcript track is missing an id.", { type: "validation_error" });
7898
+ }
7899
+ const transcriptUrl = await buildTranscriptUrl(playbackId, captionTrack.id, policy === "signed", credentials);
7900
+ const { transcriptText, emptyReason } = await fetchTranscriptDataForUrl(transcriptUrl, {
7901
+ cleanTranscript: false,
7902
+ scope: effectiveScope
7903
+ });
7904
+ if (emptyReason) {
7905
+ transcriptModeration = {
7906
+ status: "skipped",
7907
+ skipReason: "no_cues",
7908
+ skipMessage: emptyReason === "no_scoped_cues" ? "Transcript has no cues in the requested scope." : "Transcript is empty."
7909
+ };
7711
7910
  } else {
7712
- const exhaustiveCheck = provider;
7713
- throw new Error(`Unsupported moderation provider: ${exhaustiveCheck}`);
7911
+ transcriptCues = parseVTTCues(transcriptText, { capCueText: false });
7912
+ const windows = transcriptCues.length === 0 ? [] : buildTranscriptWindows(transcriptCues, duration);
7913
+ if (windows.length === 0) {
7914
+ transcriptModeration = {
7915
+ status: "skipped",
7916
+ skipReason: "no_cues",
7917
+ skipMessage: "Caption track had no parseable cues."
7918
+ };
7919
+ } else {
7920
+ shouldRunTranscript = true;
7921
+ }
7714
7922
  }
7715
7923
  }
7716
- const failed = thumbnailScores.filter((s) => s.error);
7717
- const successful = thumbnailScores.filter((s) => !s.error);
7924
+ if (!shouldRunThumbnails && !shouldRunTranscript) {
7925
+ throw new MuxAiError(
7926
+ `Nothing to moderate. Thumbnails skipped: ${thumbnailModeration.skipMessage} Transcript skipped: ${transcriptModeration.skipMessage}`,
7927
+ { type: "validation_error" }
7928
+ );
7929
+ }
7930
+ const [thumbnailScores, transcriptScores] = await Promise.all([
7931
+ shouldRunThumbnails ? requestThumbnailModeration(provider, thumbnailUrls, {
7932
+ model,
7933
+ maxConcurrent,
7934
+ imageSubmissionMode,
7935
+ imageDownloadOptions,
7936
+ credentials
7937
+ }) : Promise.resolve([]),
7938
+ shouldRunTranscript ? requestOpenAITranscriptModeration(
7939
+ transcriptCues,
7940
+ duration,
7941
+ model || "omni-moderation-latest",
7942
+ maxConcurrent,
7943
+ credentials
7944
+ ) : Promise.resolve([])
7945
+ ]);
7946
+ const mode = shouldRunThumbnails && shouldRunTranscript ? "combined" : shouldRunTranscript ? "transcript" : "thumbnails";
7947
+ const thumbnailCount = shouldRunThumbnails ? thumbnailUrls.length : void 0;
7948
+ const allScores = [
7949
+ ...thumbnailScores.map((s) => ({ ...s, label: s.url })),
7950
+ ...transcriptScores.map((s) => ({ ...s, error: s.error ?? false, label: `transcript window ${s.startTime}-${s.endTime}s` }))
7951
+ ];
7952
+ const failed = allScores.filter((s) => s.error);
7953
+ const successful = allScores.filter((s) => !s.error);
7718
7954
  if (successful.length === 0) {
7719
- const details = failed.map((s) => `${s.url}: ${s.errorMessage || "Unknown error"}`).join("; ");
7955
+ const details = failed.map((s) => `${s.label}: ${s.errorMessage || "Unknown error"}`).join("; ");
7720
7956
  throw new Error(
7721
- `Moderation failed for all ${thumbnailScores.length} sample(s): ${details}`
7957
+ `Moderation failed for all ${allScores.length} sample(s): ${details}`
7722
7958
  );
7723
7959
  }
7724
7960
  if (failed.length > 0) {
7725
7961
  console.warn(
7726
- `Moderation had partial failures (${failed.length}/${thumbnailScores.length}); continuing with successful samples.`
7962
+ `Moderation had partial failures (${failed.length}/${allScores.length}); continuing with successful samples.`
7727
7963
  );
7728
7964
  }
7965
+ const thumbnailsFailed = shouldRunThumbnails && thumbnailScores.length > 0 && thumbnailScores.every((s) => s.error);
7966
+ const transcriptFailed = shouldRunTranscript && transcriptScores.length > 0 && transcriptScores.every((s) => s.error);
7967
+ if (thumbnailsFailed) {
7968
+ thumbnailModeration = {
7969
+ status: "failed",
7970
+ errorMessage: describeSurfaceFailure("thumbnail", thumbnailScores)
7971
+ };
7972
+ }
7973
+ if (transcriptFailed) {
7974
+ transcriptModeration = {
7975
+ status: "failed",
7976
+ errorMessage: describeSurfaceFailure("transcript window", transcriptScores)
7977
+ };
7978
+ }
7729
7979
  const maxSexual = Math.max(...successful.map((s) => s.sexual));
7730
7980
  const maxViolence = Math.max(...successful.map((s) => s.violence));
7731
7981
  const finalThresholds = { ...DEFAULT_THRESHOLDS, ...thresholds };
7732
- const requestedSampleCount = thumbnailScores.length;
7733
- const successfulSampleCount = successful.length;
7734
- const failedSampleCount = failed.length;
7735
- const sampleCoverage = successfulSampleCount / requestedSampleCount;
7736
- const hasEnoughSuccessfulSamples = mode === "transcript" || successfulSampleCount >= MIN_SUCCESSFUL_THUMBNAILS_FOR_CONFIDENT_THRESHOLDING;
7737
- const isLowConfidence = sampleCoverage < MIN_SAMPLE_COVERAGE_FOR_CONFIDENT_THRESHOLDING || !hasEnoughSuccessfulSamples;
7982
+ const coverageScores = thumbnailScores;
7983
+ const coverageFailed = coverageScores.filter((s) => s.error);
7984
+ const coverageSuccessful = coverageScores.filter((s) => !s.error);
7985
+ const requestedSampleCount = coverageScores.length;
7986
+ const successfulSampleCount = coverageSuccessful.length;
7987
+ const failedSampleCount = coverageFailed.length;
7988
+ const sampleCoverage = requestedSampleCount > 0 ? successfulSampleCount / requestedSampleCount : 0;
7989
+ const hasThumbnails = requestedSampleCount > 0;
7990
+ const hasSuccessfulTranscript = transcriptScores.some((s) => !s.error);
7991
+ let isLowConfidence;
7992
+ if (!hasThumbnails) {
7993
+ isLowConfidence = !hasSuccessfulTranscript;
7994
+ } else {
7995
+ const hasEnoughSuccessfulSamples = successfulSampleCount >= MIN_SUCCESSFUL_THUMBNAILS_FOR_CONFIDENT_THRESHOLDING;
7996
+ isLowConfidence = sampleCoverage < MIN_SAMPLE_COVERAGE_FOR_CONFIDENT_THRESHOLDING || !hasEnoughSuccessfulSamples || transcriptFailed;
7997
+ }
7738
7998
  return {
7739
7999
  assetId,
7740
8000
  mode,
7741
8001
  isAudioOnly,
7742
8002
  thumbnailScores,
8003
+ transcriptScores,
8004
+ thumbnailModeration,
8005
+ transcriptModeration,
7743
8006
  coverage: {
7744
8007
  requestedSampleCount,
7745
8008
  successfulSampleCount,
7746
8009
  failedSampleCount,
7747
8010
  sampleCoverage,
7748
- isPartial: failedSampleCount > 0,
8011
+ isPartial: failed.length > 0,
7749
8012
  isLowConfidence
7750
8013
  },
7751
8014
  usage: {
@@ -7765,7 +8028,7 @@ async function getModerationScores(assetId, options = {}) {
7765
8028
 
7766
8029
  // src/workflows/summarization.ts
7767
8030
  import { generateText as generateText8, Output as Output8 } from "ai";
7768
- import { z as z9 } from "zod";
8031
+ import { z as z10 } from "zod";
7769
8032
 
7770
8033
  // src/lib/markdown-normalization.ts
7771
8034
  var ESCAPED_LINE_BREAK_REGEX = /\\r\\n|\\n|\\r/g;
@@ -7910,17 +8173,17 @@ function createSummarizationGuidance({
7910
8173
  }
7911
8174
 
7912
8175
  // src/workflows/summarization.ts
7913
- var summarySchema = z9.object({
7914
- keywords: z9.array(z9.string().max(200)),
7915
- title: z9.string().max(500),
7916
- description: z9.string().max(1e4)
8176
+ var summarySchema = z10.object({
8177
+ keywords: z10.array(z10.string().max(200)),
8178
+ title: z10.string().max(500),
8179
+ description: z10.string().max(1e4)
7917
8180
  });
7918
8181
  function buildSummarySchema(descriptionLength) {
7919
8182
  const descriptionMaxChars = Math.max(2e3, descriptionLength * 15);
7920
- return z9.object({
7921
- keywords: z9.array(z9.string().max(200)),
7922
- title: z9.string().max(500),
7923
- description: z9.string().max(descriptionMaxChars)
8183
+ return z10.object({
8184
+ keywords: z10.array(z10.string().max(200)),
8185
+ title: z10.string().max(500),
8186
+ description: z10.string().max(descriptionMaxChars)
7924
8187
  });
7925
8188
  }
7926
8189
  var VALID_TONES = ["neutral", "playful", "professional"];
@@ -9091,9 +9354,9 @@ import {
9091
9354
  RetryError as RetryError2,
9092
9355
  TypeValidationError
9093
9356
  } from "ai";
9094
- import { z as z10 } from "zod";
9095
- var translationSchema = z10.object({
9096
- translation: z10.string()
9357
+ import { z as z11 } from "zod";
9358
+ var translationSchema = z11.object({
9359
+ translation: z11.string()
9097
9360
  });
9098
9361
  var SYSTEM_PROMPT5 = promptDedent`
9099
9362
  You are a subtitle translation expert. Translate VTT subtitle files to the target language specified by the user.
@@ -9472,8 +9735,8 @@ async function translateCueChunkWithAI({
9472
9735
  }) {
9473
9736
  "use step";
9474
9737
  const model = await createLanguageModelFromConfig(provider, modelId, credentials);
9475
- const schema = z10.object({
9476
- translations: z10.array(z10.string().min(1).max(2e3)).length(cues.length)
9738
+ const schema = z11.object({
9739
+ translations: z11.array(z11.string().min(1).max(2e3)).length(cues.length)
9477
9740
  });
9478
9741
  const cuePayload = cues.map((cue, index) => ({
9479
9742
  index,
@@ -9931,6 +10194,7 @@ export {
9931
10194
  DEFAULT_DESCRIPTION_LENGTH,
9932
10195
  DEFAULT_SUMMARY_KEYWORD_LIMIT,
9933
10196
  DEFAULT_TITLE_LENGTH,
10197
+ DEFAULT_TRANSCRIPT_WINDOWING,
9934
10198
  GENERATE_TEXT_CALLS_TO_ACTION,
9935
10199
  GENERATE_TEXT_CHANNELS,
9936
10200
  GENERATE_TEXT_CHANNEL_CEILINGS,
@@ -9956,6 +10220,7 @@ export {
9956
10220
  applyTextRepair,
9957
10221
  askQuestions,
9958
10222
  buildReplacementRegex,
10223
+ buildTranscriptWindows,
9959
10224
  burnedInCaptionsSchema,
9960
10225
  censorVttContent,
9961
10226
  chapterSchema,