@mux/ai 0.52.0 → 0.54.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,12 +7771,35 @@ 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 {
7591
7799
  provider = DEFAULT_PROVIDER2,
7592
7800
  model = provider === "openai" ? "omni-moderation-latest" : void 0,
7593
7801
  languageCode,
7802
+ includeTranscript = true,
7594
7803
  thresholds = DEFAULT_THRESHOLDS,
7595
7804
  thumbnailInterval = 10,
7596
7805
  thumbnailWidth = 640,
@@ -7617,47 +7826,31 @@ async function getModerationScores(assetId, options = {}) {
7617
7826
  startTime: Math.min(resolvedScope.startTime, duration),
7618
7827
  endTime: Math.min(resolvedScope.endTime, duration)
7619
7828
  } : 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
7829
  const signingContext = await resolveMuxSigningContext(credentials);
7624
7830
  if (policy === "signed" && !signingContext) {
7625
7831
  throw new Error(
7626
7832
  "Signed playback ID requires signing credentials. Set MUX_SIGNING_KEY and MUX_PRIVATE_KEY environment variables."
7627
7833
  );
7628
7834
  }
7629
- let thumbnailScores;
7630
- let mode = "thumbnails";
7631
- let thumbnailCount;
7835
+ let thumbnailModeration = { status: "completed" };
7836
+ let thumbnailUrls = [];
7837
+ let shouldRunThumbnails = false;
7632
7838
  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
- }
7839
+ thumbnailModeration = {
7840
+ status: "skipped",
7841
+ skipReason: "no_video_track",
7842
+ skipMessage: "Asset has no video track, so there are no thumbnails to moderate."
7843
+ };
7844
+ } else if (renderableScope && renderableScope.startTime >= renderableScope.endTime) {
7845
+ thumbnailModeration = {
7846
+ status: "skipped",
7847
+ skipReason: "no_video_in_scope",
7848
+ skipMessage: "The requested scope does not include any renderable video, so there are no thumbnails to moderate."
7849
+ };
7657
7850
  } else {
7658
7851
  const scopedDuration = renderableScope ? renderableScope.endTime - renderableScope.startTime : duration;
7659
7852
  const estimatedIntervalCount = scopedDuration <= 50 ? 5 : Math.ceil(scopedDuration / thumbnailInterval);
7660
- const thumbnailUrls = maxSamples !== void 0 && estimatedIntervalCount > maxSamples ? await getThumbnailUrlsFromTimestamps(
7853
+ thumbnailUrls = maxSamples !== void 0 && estimatedIntervalCount > maxSamples ? await getThumbnailUrlsFromTimestamps(
7661
7854
  playbackId,
7662
7855
  filterTimestampsWithinExclusiveScope(
7663
7856
  planSamplingTimestamps({
@@ -7682,70 +7875,148 @@ async function getModerationScores(assetId, options = {}) {
7682
7875
  credentials,
7683
7876
  scope: renderableScope
7684
7877
  });
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
- );
7878
+ shouldRunThumbnails = true;
7879
+ }
7880
+ let transcriptModeration = { status: "completed" };
7881
+ let transcriptCues = [];
7882
+ let shouldRunTranscript = false;
7883
+ const transcriptEnabled = includeTranscript || isAudioOnly;
7884
+ const captionTrack = transcriptEnabled && provider === "openai" ? findCaptionTrack(asset, languageCode) : void 0;
7885
+ if (!transcriptEnabled) {
7886
+ transcriptModeration = {
7887
+ status: "skipped",
7888
+ skipReason: "disabled",
7889
+ skipMessage: "Transcript moderation was disabled with includeTranscript: false."
7890
+ };
7891
+ } else if (provider !== "openai") {
7892
+ transcriptModeration = {
7893
+ status: "skipped",
7894
+ skipReason: "unsupported_provider",
7895
+ skipMessage: `Provider '${provider}' is image-only and cannot moderate transcript text; use provider 'openai'.`
7896
+ };
7897
+ } else if (!captionTrack) {
7898
+ transcriptModeration = {
7899
+ status: "skipped",
7900
+ skipReason: "no_ready_text_track",
7901
+ skipMessage: languageCode ? `No ready caption/subtitle track found for language '${languageCode}'.` : "No ready caption/subtitle track found for this asset."
7902
+ };
7903
+ } else {
7904
+ if (!captionTrack.id) {
7905
+ throw new MuxAiError("Transcript track is missing an id.", { type: "validation_error" });
7906
+ }
7907
+ const transcriptUrl = await buildTranscriptUrl(playbackId, captionTrack.id, policy === "signed", credentials);
7908
+ const { transcriptText, emptyReason } = await fetchTranscriptDataForUrl(transcriptUrl, {
7909
+ cleanTranscript: false,
7910
+ scope: effectiveScope
7911
+ });
7912
+ if (emptyReason) {
7913
+ transcriptModeration = {
7914
+ status: "skipped",
7915
+ skipReason: "no_cues",
7916
+ skipMessage: emptyReason === "no_scoped_cues" ? "Transcript has no cues in the requested scope." : "Transcript is empty."
7917
+ };
7711
7918
  } else {
7712
- const exhaustiveCheck = provider;
7713
- throw new Error(`Unsupported moderation provider: ${exhaustiveCheck}`);
7919
+ transcriptCues = parseVTTCues(transcriptText, { capCueText: false });
7920
+ const windows = transcriptCues.length === 0 ? [] : buildTranscriptWindows(transcriptCues, duration);
7921
+ if (windows.length === 0) {
7922
+ transcriptModeration = {
7923
+ status: "skipped",
7924
+ skipReason: "no_cues",
7925
+ skipMessage: "Caption track had no parseable cues."
7926
+ };
7927
+ } else {
7928
+ shouldRunTranscript = true;
7929
+ }
7714
7930
  }
7715
7931
  }
7716
- const failed = thumbnailScores.filter((s) => s.error);
7717
- const successful = thumbnailScores.filter((s) => !s.error);
7932
+ if (!shouldRunThumbnails && !shouldRunTranscript) {
7933
+ throw new MuxAiError(
7934
+ `Nothing to moderate. Thumbnails skipped: ${thumbnailModeration.skipMessage} Transcript skipped: ${transcriptModeration.skipMessage}`,
7935
+ { type: "validation_error" }
7936
+ );
7937
+ }
7938
+ const [thumbnailScores, transcriptScores] = await Promise.all([
7939
+ shouldRunThumbnails ? requestThumbnailModeration(provider, thumbnailUrls, {
7940
+ model,
7941
+ maxConcurrent,
7942
+ imageSubmissionMode,
7943
+ imageDownloadOptions,
7944
+ credentials
7945
+ }) : Promise.resolve([]),
7946
+ shouldRunTranscript ? requestOpenAITranscriptModeration(
7947
+ transcriptCues,
7948
+ duration,
7949
+ model || "omni-moderation-latest",
7950
+ maxConcurrent,
7951
+ credentials
7952
+ ) : Promise.resolve([])
7953
+ ]);
7954
+ const mode = shouldRunThumbnails && shouldRunTranscript ? "combined" : shouldRunTranscript ? "transcript" : "thumbnails";
7955
+ const thumbnailCount = shouldRunThumbnails ? thumbnailUrls.length : void 0;
7956
+ const allScores = [
7957
+ ...thumbnailScores.map((s) => ({ ...s, label: s.url })),
7958
+ ...transcriptScores.map((s) => ({ ...s, error: s.error ?? false, label: `transcript window ${s.startTime}-${s.endTime}s` }))
7959
+ ];
7960
+ const failed = allScores.filter((s) => s.error);
7961
+ const successful = allScores.filter((s) => !s.error);
7718
7962
  if (successful.length === 0) {
7719
- const details = failed.map((s) => `${s.url}: ${s.errorMessage || "Unknown error"}`).join("; ");
7963
+ const details = failed.map((s) => `${s.label}: ${s.errorMessage || "Unknown error"}`).join("; ");
7720
7964
  throw new Error(
7721
- `Moderation failed for all ${thumbnailScores.length} sample(s): ${details}`
7965
+ `Moderation failed for all ${allScores.length} sample(s): ${details}`
7722
7966
  );
7723
7967
  }
7724
7968
  if (failed.length > 0) {
7725
7969
  console.warn(
7726
- `Moderation had partial failures (${failed.length}/${thumbnailScores.length}); continuing with successful samples.`
7970
+ `Moderation had partial failures (${failed.length}/${allScores.length}); continuing with successful samples.`
7727
7971
  );
7728
7972
  }
7973
+ const thumbnailsFailed = shouldRunThumbnails && thumbnailScores.length > 0 && thumbnailScores.every((s) => s.error);
7974
+ const transcriptFailed = shouldRunTranscript && transcriptScores.length > 0 && transcriptScores.every((s) => s.error);
7975
+ if (thumbnailsFailed) {
7976
+ thumbnailModeration = {
7977
+ status: "failed",
7978
+ errorMessage: describeSurfaceFailure("thumbnail", thumbnailScores)
7979
+ };
7980
+ }
7981
+ if (transcriptFailed) {
7982
+ transcriptModeration = {
7983
+ status: "failed",
7984
+ errorMessage: describeSurfaceFailure("transcript window", transcriptScores)
7985
+ };
7986
+ }
7729
7987
  const maxSexual = Math.max(...successful.map((s) => s.sexual));
7730
7988
  const maxViolence = Math.max(...successful.map((s) => s.violence));
7731
7989
  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;
7990
+ const coverageScores = thumbnailScores;
7991
+ const coverageFailed = coverageScores.filter((s) => s.error);
7992
+ const coverageSuccessful = coverageScores.filter((s) => !s.error);
7993
+ const requestedSampleCount = coverageScores.length;
7994
+ const successfulSampleCount = coverageSuccessful.length;
7995
+ const failedSampleCount = coverageFailed.length;
7996
+ const sampleCoverage = requestedSampleCount > 0 ? successfulSampleCount / requestedSampleCount : 0;
7997
+ const hasThumbnails = requestedSampleCount > 0;
7998
+ const hasSuccessfulTranscript = transcriptScores.some((s) => !s.error);
7999
+ let isLowConfidence;
8000
+ if (!hasThumbnails) {
8001
+ isLowConfidence = !hasSuccessfulTranscript;
8002
+ } else {
8003
+ const hasEnoughSuccessfulSamples = successfulSampleCount >= MIN_SUCCESSFUL_THUMBNAILS_FOR_CONFIDENT_THRESHOLDING;
8004
+ isLowConfidence = sampleCoverage < MIN_SAMPLE_COVERAGE_FOR_CONFIDENT_THRESHOLDING || !hasEnoughSuccessfulSamples || transcriptFailed;
8005
+ }
7738
8006
  return {
7739
8007
  assetId,
7740
8008
  mode,
7741
8009
  isAudioOnly,
7742
8010
  thumbnailScores,
8011
+ transcriptScores,
8012
+ thumbnailModeration,
8013
+ transcriptModeration,
7743
8014
  coverage: {
7744
8015
  requestedSampleCount,
7745
8016
  successfulSampleCount,
7746
8017
  failedSampleCount,
7747
8018
  sampleCoverage,
7748
- isPartial: failedSampleCount > 0,
8019
+ isPartial: failed.length > 0,
7749
8020
  isLowConfidence
7750
8021
  },
7751
8022
  usage: {
@@ -7765,7 +8036,7 @@ async function getModerationScores(assetId, options = {}) {
7765
8036
 
7766
8037
  // src/workflows/summarization.ts
7767
8038
  import { generateText as generateText8, Output as Output8 } from "ai";
7768
- import { z as z9 } from "zod";
8039
+ import { z as z10 } from "zod";
7769
8040
 
7770
8041
  // src/lib/markdown-normalization.ts
7771
8042
  var ESCAPED_LINE_BREAK_REGEX = /\\r\\n|\\n|\\r/g;
@@ -7910,17 +8181,17 @@ function createSummarizationGuidance({
7910
8181
  }
7911
8182
 
7912
8183
  // 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)
8184
+ var summarySchema = z10.object({
8185
+ keywords: z10.array(z10.string().max(200)),
8186
+ title: z10.string().max(500),
8187
+ description: z10.string().max(1e4)
7917
8188
  });
7918
8189
  function buildSummarySchema(descriptionLength) {
7919
8190
  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)
8191
+ return z10.object({
8192
+ keywords: z10.array(z10.string().max(200)),
8193
+ title: z10.string().max(500),
8194
+ description: z10.string().max(descriptionMaxChars)
7924
8195
  });
7925
8196
  }
7926
8197
  var VALID_TONES = ["neutral", "playful", "professional"];
@@ -9091,9 +9362,9 @@ import {
9091
9362
  RetryError as RetryError2,
9092
9363
  TypeValidationError
9093
9364
  } from "ai";
9094
- import { z as z10 } from "zod";
9095
- var translationSchema = z10.object({
9096
- translation: z10.string()
9365
+ import { z as z11 } from "zod";
9366
+ var translationSchema = z11.object({
9367
+ translation: z11.string()
9097
9368
  });
9098
9369
  var SYSTEM_PROMPT5 = promptDedent`
9099
9370
  You are a subtitle translation expert. Translate VTT subtitle files to the target language specified by the user.
@@ -9472,8 +9743,8 @@ async function translateCueChunkWithAI({
9472
9743
  }) {
9473
9744
  "use step";
9474
9745
  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)
9746
+ const schema = z11.object({
9747
+ translations: z11.array(z11.string().min(1).max(2e3)).length(cues.length)
9477
9748
  });
9478
9749
  const cuePayload = cues.map((cue, index) => ({
9479
9750
  index,
@@ -9931,6 +10202,7 @@ export {
9931
10202
  DEFAULT_DESCRIPTION_LENGTH,
9932
10203
  DEFAULT_SUMMARY_KEYWORD_LIMIT,
9933
10204
  DEFAULT_TITLE_LENGTH,
10205
+ DEFAULT_TRANSCRIPT_WINDOWING,
9934
10206
  GENERATE_TEXT_CALLS_TO_ACTION,
9935
10207
  GENERATE_TEXT_CHANNELS,
9936
10208
  GENERATE_TEXT_CHANNEL_CEILINGS,
@@ -9956,6 +10228,7 @@ export {
9956
10228
  applyTextRepair,
9957
10229
  askQuestions,
9958
10230
  buildReplacementRegex,
10231
+ buildTranscriptWindows,
9959
10232
  burnedInCaptionsSchema,
9960
10233
  censorVttContent,
9961
10234
  chapterSchema,