@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.
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.52.0";
8
+ var version = "0.54.0";
9
9
 
10
10
  // src/lib/language-codes.ts
11
11
  var ISO639_1_TO_3 = {
@@ -704,6 +704,9 @@ var MuxAiError = class extends Error {
704
704
  super(message);
705
705
  /** Structural brand for duck-type identification. */
706
706
  this.__robots_error = true;
707
+ if (opts?.cause !== void 0) {
708
+ Object.defineProperty(this, "cause", { value: opts.cause, writable: true, configurable: true });
709
+ }
707
710
  this.name = "FatalError";
708
711
  this.publicType = opts?.type ?? "processing_error";
709
712
  this.publicMessage = message;
@@ -1814,6 +1817,7 @@ __export(primitives_exports, {
1814
1817
  estimateTokenCount: () => estimateTokenCount,
1815
1818
  extractTextFromVTT: () => extractTextFromVTT,
1816
1819
  extractTimestampedTranscript: () => extractTimestampedTranscript,
1820
+ fetchTranscriptDataForUrl: () => fetchTranscriptDataForUrl,
1817
1821
  fetchTranscriptForAsset: () => fetchTranscriptForAsset,
1818
1822
  filterVttByScope: () => filterVttByScope,
1819
1823
  findCaptionTrack: () => findCaptionTrack,
@@ -3128,9 +3132,10 @@ function extractTimestampedTranscript(vttContent) {
3128
3132
  segments.map((segment) => `[${Math.floor(segment.time)}s] ${segment.text}`).join("\n")
3129
3133
  );
3130
3134
  }
3131
- function parseVTTCues(vttContent) {
3135
+ function parseVTTCues(vttContent, options = {}) {
3132
3136
  if (!vttContent.trim())
3133
3137
  return [];
3138
+ const { capCueText = true } = options;
3134
3139
  const lines = vttContent.split("\n");
3135
3140
  const cues = [];
3136
3141
  let previousCueIdentifier = null;
@@ -3162,15 +3167,16 @@ function parseVTTCues(vttContent) {
3162
3167
  }
3163
3168
  const textLines = rawTextLines.map((textLine) => textLine.replace(/<[^>]*>/g, "")).filter(Boolean);
3164
3169
  if (textLines.length > 0) {
3170
+ const sanitized = sanitizeUntrustedText(textLines.join(" "));
3165
3171
  cues.push({
3166
3172
  startTime,
3167
3173
  endTime,
3168
3174
  // Sanitize at the cue boundary so every downstream consumer
3169
3175
  // (engagement-insights, translate-captions chunked path, etc.)
3170
3176
  // receives text free of invisible / bidi obfuscation. Length-
3171
- // cap the result so an adversarially oversized cue cannot
3172
- // smuggle a large injection payload into the prompt.
3173
- text: capCueTextLength(sanitizeUntrustedText(textLines.join(" ")))
3177
+ // cap the result by default so an adversarially oversized cue
3178
+ // cannot smuggle a large injection payload into a prompt.
3179
+ text: capCueText ? capCueTextLength(sanitized) : sanitized
3174
3180
  });
3175
3181
  }
3176
3182
  previousCueIdentifier = currentCueIdentifier;
@@ -3294,16 +3300,32 @@ async function buildTranscriptUrl(playbackId, trackId, shouldSign = false, crede
3294
3300
  }
3295
3301
  return baseUrl;
3296
3302
  }
3297
- async function fetchTranscriptForAsset(asset, playbackId, options = {}) {
3303
+ async function fetchTranscriptDataForUrl(transcriptUrl, options = {}) {
3298
3304
  "use step";
3299
- const {
3300
- languageCode,
3301
- cleanTranscript = true,
3302
- scope,
3303
- shouldSign,
3304
- credentials,
3305
- required = false
3306
- } = options;
3305
+ const { cleanTranscript = true, scope } = options;
3306
+ try {
3307
+ const response = await fetch(transcriptUrl);
3308
+ if (!response.ok) {
3309
+ throw new Error(`Failed to fetch transcript (HTTP ${response.status})`);
3310
+ }
3311
+ const rawVtt = await response.text();
3312
+ const effectiveScope = hasWorkflowScopeBoundaries(scope) ? scope : void 0;
3313
+ const scopedVtt = filterVttByScope(rawVtt, effectiveScope);
3314
+ const transcriptText = cleanTranscript ? extractTextFromVTT(scopedVtt) : scopedVtt;
3315
+ const hasScopedCues = !effectiveScope || parseVTTCues(scopedVtt).length > 0;
3316
+ if (!transcriptText.trim() || !hasScopedCues) {
3317
+ return {
3318
+ transcriptText,
3319
+ emptyReason: hasScopedCues ? "empty_body" : "no_scoped_cues"
3320
+ };
3321
+ }
3322
+ return { transcriptText };
3323
+ } catch (error) {
3324
+ wrapError(error, "Failed to fetch transcript");
3325
+ }
3326
+ }
3327
+ async function fetchTranscriptForAsset(asset, playbackId, options = {}) {
3328
+ const { required = false, languageCode, cleanTranscript, scope, shouldSign, credentials } = options;
3307
3329
  const track = findCaptionTrack(asset, languageCode);
3308
3330
  if (!track) {
3309
3331
  if (required) {
@@ -3322,33 +3344,25 @@ async function fetchTranscriptForAsset(asset, playbackId, options = {}) {
3322
3344
  return { transcriptText: "", track };
3323
3345
  }
3324
3346
  const transcriptUrl = await buildTranscriptUrl(playbackId, track.id, shouldSign, credentials);
3347
+ let result;
3325
3348
  try {
3326
- const response = await fetch(transcriptUrl);
3327
- if (!response.ok) {
3328
- if (required) {
3329
- throw new Error(`Failed to fetch transcript (HTTP ${response.status})`);
3330
- }
3331
- return { transcriptText: "", transcriptUrl, track };
3332
- }
3333
- const rawVtt = await response.text();
3334
- const effectiveScope = hasWorkflowScopeBoundaries(scope) ? scope : void 0;
3335
- const scopedVtt = filterVttByScope(rawVtt, effectiveScope);
3336
- const transcriptText = cleanTranscript ? extractTextFromVTT(scopedVtt) : scopedVtt;
3337
- const hasScopedCues = !effectiveScope || parseVTTCues(scopedVtt).length > 0;
3338
- if (required && (!transcriptText.trim() || !hasScopedCues)) {
3339
- throw new MuxAiError(
3340
- effectiveScope ? "Transcript has no cues in the requested scope." : "Transcript is empty.",
3341
- { type: "validation_error" }
3342
- );
3343
- }
3344
- return { transcriptText, transcriptUrl, track };
3349
+ result = await fetchTranscriptDataForUrl(transcriptUrl, { cleanTranscript, scope });
3345
3350
  } catch (error) {
3346
3351
  if (required) {
3347
- wrapError(error, "Failed to fetch transcript");
3352
+ throw error;
3348
3353
  }
3349
3354
  console.warn("Failed to fetch transcript:", error);
3350
3355
  return { transcriptText: "", transcriptUrl, track };
3351
3356
  }
3357
+ if (!required || !result.emptyReason) {
3358
+ return { transcriptText: result.transcriptText, transcriptUrl, track };
3359
+ }
3360
+ switch (result.emptyReason) {
3361
+ case "no_scoped_cues":
3362
+ throw new MuxAiError("Transcript has no cues in the requested scope.", { type: "validation_error" });
3363
+ case "empty_body":
3364
+ throw new MuxAiError("Transcript is empty.", { type: "validation_error" });
3365
+ }
3352
3366
  }
3353
3367
 
3354
3368
  // src/workflows/index.ts
@@ -3361,6 +3375,7 @@ __export(workflows_exports, {
3361
3375
  DEFAULT_DESCRIPTION_LENGTH: () => DEFAULT_DESCRIPTION_LENGTH,
3362
3376
  DEFAULT_SUMMARY_KEYWORD_LIMIT: () => DEFAULT_SUMMARY_KEYWORD_LIMIT,
3363
3377
  DEFAULT_TITLE_LENGTH: () => DEFAULT_TITLE_LENGTH,
3378
+ DEFAULT_TRANSCRIPT_WINDOWING: () => DEFAULT_TRANSCRIPT_WINDOWING,
3364
3379
  GENERATE_TEXT_CALLS_TO_ACTION: () => GENERATE_TEXT_CALLS_TO_ACTION,
3365
3380
  GENERATE_TEXT_CHANNELS: () => GENERATE_TEXT_CHANNELS,
3366
3381
  GENERATE_TEXT_CHANNEL_CEILINGS: () => GENERATE_TEXT_CHANNEL_CEILINGS,
@@ -3386,6 +3401,7 @@ __export(workflows_exports, {
3386
3401
  applyTextRepair: () => applyTextRepair,
3387
3402
  askQuestions: () => askQuestions,
3388
3403
  buildReplacementRegex: () => buildReplacementRegex,
3404
+ buildTranscriptWindows: () => buildTranscriptWindows,
3389
3405
  burnedInCaptionsSchema: () => burnedInCaptionsSchema,
3390
3406
  censorVttContent: () => censorVttContent,
3391
3407
  chapterSchema: () => chapterSchema,
@@ -7277,10 +7293,27 @@ ${renderSection({ tag: "revision_request", content: "The previous draft was empt
7277
7293
  usages.push(...repaired.attempts.flatMap((item) => item.call.usage ? [item.call.usage] : []));
7278
7294
  if (repaired.status !== "valid") {
7279
7295
  const cause = repaired.attempts.at(-1)?.call.errorCause;
7280
- 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}`, {
7281
- type: "processing_error",
7282
- retryable: APICallError3.isInstance(cause) ? cause.isRetryable : true
7283
- });
7296
+ let failure;
7297
+ if (isIncompleteGenerationError(cause)) {
7298
+ failure = new IncompleteGenerationError(cause.finishReason, cause.rawFinishReason);
7299
+ } else if (MuxAiError.is(cause)) {
7300
+ failure = new MuxAiError(cause.publicMessage, { type: cause.publicType, retryable: cause.retryable });
7301
+ } else {
7302
+ const apiError = APICallError3.isInstance(cause) ? cause : void 0;
7303
+ console.warn("[@mux/ai] Bounded text repair failed.", {
7304
+ provider: args.provider,
7305
+ modelId: args.modelId,
7306
+ reason: repaired.reason,
7307
+ errorName: cause instanceof Error ? cause.name : void 0,
7308
+ statusCode: apiError?.statusCode,
7309
+ requestId: apiError?.responseHeaders?.["x-request-id"] ?? apiError?.responseHeaders?.["request-id"]
7310
+ });
7311
+ failure = new MuxAiError("Unable to complete text generation.", {
7312
+ type: "processing_error",
7313
+ retryable: apiError?.isRetryable ?? true,
7314
+ cause
7315
+ });
7316
+ }
7284
7317
  rethrowWithTokenUsage(failure, usages);
7285
7318
  }
7286
7319
  return { content: repaired.content, usages, unexpectedKeys };
@@ -7458,6 +7491,9 @@ async function generateTextInternal(assetId, rawOptions, collectedUsage) {
7458
7491
  };
7459
7492
  }
7460
7493
 
7494
+ // src/workflows/moderation.ts
7495
+ import { z as z9 } from "zod";
7496
+
7461
7497
  // src/lib/sampling-plan.ts
7462
7498
  var DEFAULT_FPS = 30;
7463
7499
  function roundToNearestFrameMs(tsMs, fps = DEFAULT_FPS) {
@@ -7530,6 +7566,15 @@ var OPENAI_MODERATION_BASE_DELAY_MS = 750;
7530
7566
  var OPENAI_MODERATION_MAX_DELAY_MS = 3e3;
7531
7567
  var MIN_SAMPLE_COVERAGE_FOR_CONFIDENT_THRESHOLDING = 0.5;
7532
7568
  var MIN_SUCCESSFUL_THUMBNAILS_FOR_CONFIDENT_THRESHOLDING = 3;
7569
+ var DEFAULT_TRANSCRIPT_WINDOWING = {
7570
+ targetWindowCount: 40,
7571
+ minWindowSeconds: 20,
7572
+ maxWindowSeconds: 120,
7573
+ overlapFraction: 0.15,
7574
+ minOverlapSeconds: 5
7575
+ };
7576
+ var TRANSCRIPT_BATCH_MAX_UTF16_CODE_UNITS = 1e5;
7577
+ var TRANSCRIPT_BATCH_MAX_ITEMS = 100;
7533
7578
  var GOOGLE_VISION_ENDPOINT = "https://vision.googleapis.com/v1/images:annotate";
7534
7579
  var GOOGLE_VISION_LIKELIHOOD_TO_SCORE = {
7535
7580
  UNKNOWN: 0,
@@ -7597,6 +7642,14 @@ function parseOpenAIModerationResponse(bodyText, status, statusText) {
7597
7642
  );
7598
7643
  }
7599
7644
  }
7645
+ var OpenAIModerationResponseSchema = z9.object({
7646
+ results: z9.array(z9.object({
7647
+ category_scores: z9.object({
7648
+ sexual: z9.number().finite().nullish(),
7649
+ violence: z9.number().finite().nullish()
7650
+ })
7651
+ }))
7652
+ });
7600
7653
  async function callOpenAIModerationApi({
7601
7654
  model,
7602
7655
  input,
@@ -7636,7 +7689,21 @@ async function callOpenAIModerationApi({
7636
7689
  }
7637
7690
  );
7638
7691
  }
7639
- return json;
7692
+ const parsed = OpenAIModerationResponseSchema.safeParse(json);
7693
+ if (!parsed.success) {
7694
+ throw new OpenAIModerationRequestError(
7695
+ `OpenAI moderation returned an unexpected response shape: ${compactBodySnippet(bodyText)}`,
7696
+ { status: res.status, retriable: false }
7697
+ );
7698
+ }
7699
+ const expectedResultCount = Array.isArray(input) ? input.length : 1;
7700
+ if (parsed.data.results.length !== expectedResultCount) {
7701
+ throw new OpenAIModerationRequestError(
7702
+ `OpenAI moderation returned ${parsed.data.results.length} result(s) for ${expectedResultCount} input(s).`,
7703
+ { status: res.status, retriable: false }
7704
+ );
7705
+ }
7706
+ return parsed.data;
7640
7707
  },
7641
7708
  {
7642
7709
  maxRetries: OPENAI_MODERATION_MAX_RETRIES,
@@ -7660,7 +7727,7 @@ async function processConcurrently(items, processor, maxConcurrent = 5) {
7660
7727
  async function moderateImageWithOpenAI(entry) {
7661
7728
  "use step";
7662
7729
  try {
7663
- const json = await callOpenAIModerationApi({
7730
+ const { results } = await callOpenAIModerationApi({
7664
7731
  model: entry.model,
7665
7732
  input: [
7666
7733
  {
@@ -7672,12 +7739,12 @@ async function moderateImageWithOpenAI(entry) {
7672
7739
  ],
7673
7740
  credentials: entry.credentials
7674
7741
  });
7675
- const categoryScores = json.results?.[0]?.category_scores || {};
7742
+ const [result] = results;
7676
7743
  return {
7677
7744
  url: entry.url,
7678
7745
  time: entry.time,
7679
- sexual: categoryScores.sexual || 0,
7680
- violence: categoryScores.violence || 0,
7746
+ sexual: result.category_scores.sexual ?? 0,
7747
+ violence: result.category_scores.violence ?? 0,
7681
7748
  error: false
7682
7749
  };
7683
7750
  } catch (error) {
@@ -7701,65 +7768,187 @@ async function requestOpenAIModeration(images, model, maxConcurrent = 5, submiss
7701
7768
  ) : images.map((img) => ({ url: img.url, time: img.time, image: img.url, model, credentials }));
7702
7769
  return processConcurrently(targetUrls, moderateImageWithOpenAI, maxConcurrent);
7703
7770
  }
7704
- async function requestOpenAITextModeration(text, model, url, credentials) {
7705
- "use step";
7706
- try {
7707
- const json = await callOpenAIModerationApi({
7708
- model,
7709
- input: text,
7710
- credentials
7771
+ var TRANSCRIPT_WINDOW_MAX_UTF16_CODE_UNITS = 1e4;
7772
+ function describeSurfaceFailure(itemLabel, scores) {
7773
+ const messages = Array.from(new Set(scores.map((s) => s.errorMessage || "Unknown error")));
7774
+ return `All ${scores.length} ${itemLabel}(s) failed to moderate: ${messages.join("; ")}`;
7775
+ }
7776
+ function clamp(value, min, max) {
7777
+ return Math.min(Math.max(value, min), max);
7778
+ }
7779
+ function splitCuesUnderCharCeiling(cues, maxUnits = TRANSCRIPT_WINDOW_MAX_UTF16_CODE_UNITS) {
7780
+ const windows = [];
7781
+ let current = [];
7782
+ let currentLength = 0;
7783
+ const flush = () => {
7784
+ if (current.length === 0) {
7785
+ return;
7786
+ }
7787
+ windows.push({
7788
+ startTime: Math.min(...current.map((cue) => cue.startTime)),
7789
+ endTime: Math.max(...current.map((cue) => cue.endTime)),
7790
+ text: current.map((cue) => cue.text).join(" ")
7711
7791
  });
7712
- const categoryScores = json.results?.[0]?.category_scores || {};
7713
- return {
7714
- url,
7715
- sexual: categoryScores.sexual || 0,
7716
- violence: categoryScores.violence || 0,
7717
- error: false
7718
- };
7719
- } catch (error) {
7720
- console.error("OpenAI text moderation failed:", error);
7721
- return {
7722
- url,
7723
- sexual: 0,
7724
- violence: 0,
7725
- error: true,
7726
- errorMessage: error instanceof Error ? error.message : String(error)
7727
- };
7792
+ current = [];
7793
+ currentLength = 0;
7794
+ };
7795
+ for (const cue of cues) {
7796
+ const cueText = cue.text;
7797
+ const addedLength = currentLength === 0 ? cueText.length : currentLength + 1 + cueText.length;
7798
+ if (current.length > 0 && addedLength > maxUnits) {
7799
+ flush();
7800
+ }
7801
+ current.push(cue);
7802
+ currentLength = current.length === 1 ? cueText.length : currentLength + 1 + cueText.length;
7728
7803
  }
7804
+ flush();
7805
+ return windows;
7729
7806
  }
7730
- function chunkTextByUtf16CodeUnits(text, maxUnits) {
7731
- if (!text.trim()) {
7807
+ function buildTranscriptWindows(cues, duration) {
7808
+ const usableCues = cues.filter((cue) => cue.text.trim().length > 0).slice().sort((a, b) => a.startTime - b.startTime);
7809
+ if (usableCues.length === 0) {
7732
7810
  return [];
7733
7811
  }
7734
- if (text.length <= maxUnits) {
7735
- return [text];
7812
+ const lastCueEnd = Math.max(...usableCues.map((cue) => cue.endTime));
7813
+ const effectiveDuration = duration && duration > 0 ? duration : lastCueEnd;
7814
+ const { targetWindowCount, minWindowSeconds, maxWindowSeconds, overlapFraction, minOverlapSeconds } = DEFAULT_TRANSCRIPT_WINDOWING;
7815
+ const windowSeconds = clamp(
7816
+ effectiveDuration / targetWindowCount,
7817
+ minWindowSeconds,
7818
+ maxWindowSeconds
7819
+ );
7820
+ const overlapSeconds = Math.max(
7821
+ minOverlapSeconds,
7822
+ windowSeconds * overlapFraction
7823
+ );
7824
+ const stride = Math.max(windowSeconds - overlapSeconds, 1);
7825
+ const rawWindows = [];
7826
+ for (let k = 0; ; k++) {
7827
+ const windowStart = k * stride;
7828
+ if (windowStart > lastCueEnd) {
7829
+ break;
7830
+ }
7831
+ const windowEnd = windowStart + windowSeconds;
7832
+ const windowCues = usableCues.filter(
7833
+ (cue) => cue.startTime < windowEnd && cue.endTime > windowStart
7834
+ );
7835
+ if (windowCues.length > 0) {
7836
+ rawWindows.push(windowCues);
7837
+ }
7838
+ if (stride <= 0) {
7839
+ break;
7840
+ }
7736
7841
  }
7737
- const chunks = [];
7738
- for (let i = 0; i < text.length; i += maxUnits) {
7739
- const chunk = text.slice(i, i + maxUnits).trim();
7740
- if (chunk) {
7741
- chunks.push(chunk);
7842
+ const dedupedCueGroups = [];
7843
+ const cueGroupKey = (group) => group.map((cue) => `${cue.startTime}:${cue.endTime}`).join("|");
7844
+ let previousKey;
7845
+ for (const group of rawWindows) {
7846
+ const key = cueGroupKey(group);
7847
+ if (key === previousKey) {
7848
+ continue;
7742
7849
  }
7850
+ dedupedCueGroups.push(group);
7851
+ previousKey = key;
7743
7852
  }
7744
- return chunks;
7853
+ const windows = [];
7854
+ for (const group of dedupedCueGroups) {
7855
+ const joinedLength = group.reduce(
7856
+ (sum, cue, index) => sum + cue.text.length + (index === 0 ? 0 : 1),
7857
+ 0
7858
+ );
7859
+ if (joinedLength > TRANSCRIPT_WINDOW_MAX_UTF16_CODE_UNITS) {
7860
+ windows.push(...splitCuesUnderCharCeiling(group));
7861
+ } else {
7862
+ windows.push({
7863
+ startTime: Math.min(...group.map((cue) => cue.startTime)),
7864
+ endTime: Math.max(...group.map((cue) => cue.endTime)),
7865
+ text: group.map((cue) => cue.text).join(" ")
7866
+ });
7867
+ }
7868
+ }
7869
+ return windows;
7870
+ }
7871
+ function packWindowsIntoBatches(windows) {
7872
+ const batches = [];
7873
+ let current = [];
7874
+ let currentLength = 0;
7875
+ for (const window of windows) {
7876
+ const wouldExceedChars = current.length > 0 && currentLength + window.text.length > TRANSCRIPT_BATCH_MAX_UTF16_CODE_UNITS;
7877
+ const wouldExceedItems = current.length >= TRANSCRIPT_BATCH_MAX_ITEMS;
7878
+ if (wouldExceedChars || wouldExceedItems) {
7879
+ batches.push(current);
7880
+ current = [];
7881
+ currentLength = 0;
7882
+ }
7883
+ current.push(window);
7884
+ currentLength += window.text.length;
7885
+ }
7886
+ if (current.length > 0) {
7887
+ batches.push(current);
7888
+ }
7889
+ return batches;
7890
+ }
7891
+ function transcriptErrorScore(window, error) {
7892
+ return {
7893
+ startTime: window.startTime,
7894
+ endTime: window.endTime,
7895
+ sexual: 0,
7896
+ violence: 0,
7897
+ error: true,
7898
+ errorMessage: error instanceof Error ? error.message : String(error)
7899
+ };
7745
7900
  }
7746
- async function requestOpenAITranscriptModeration(transcriptText, model, maxConcurrent = 5, credentials) {
7901
+ async function moderateTranscriptBatchWithOpenAI(batch) {
7747
7902
  "use step";
7748
- const chunks = chunkTextByUtf16CodeUnits(transcriptText, 1e4);
7749
- if (!chunks.length) {
7750
- return [
7751
- { url: "transcript:0", sexual: 0, violence: 0, error: true, errorMessage: "No transcript chunks to moderate" }
7752
- ];
7753
- }
7754
- const targets = chunks.map((chunk, idx) => ({
7755
- chunk,
7756
- url: `transcript:${idx}`
7757
- }));
7758
- return processConcurrently(
7759
- targets,
7760
- async (entry) => requestOpenAITextModeration(entry.chunk, model, entry.url, credentials),
7903
+ const { windows, model, credentials } = batch;
7904
+ if (windows.length === 0) {
7905
+ return [];
7906
+ }
7907
+ try {
7908
+ const { results } = await callOpenAIModerationApi({
7909
+ model,
7910
+ input: windows.map((window) => window.text),
7911
+ credentials
7912
+ });
7913
+ return windows.map((window, index) => {
7914
+ const result = results[index];
7915
+ return {
7916
+ startTime: window.startTime,
7917
+ endTime: window.endTime,
7918
+ sexual: result.category_scores.sexual ?? 0,
7919
+ violence: result.category_scores.violence ?? 0,
7920
+ error: false
7921
+ };
7922
+ });
7923
+ } catch (error) {
7924
+ const status = error instanceof OpenAIModerationRequestError ? error.status : void 0;
7925
+ if (status === 400 && windows.length > 1) {
7926
+ const mid = Math.ceil(windows.length / 2);
7927
+ const [left, right] = await Promise.all([
7928
+ moderateTranscriptBatchWithOpenAI({ windows: windows.slice(0, mid), model, credentials }),
7929
+ moderateTranscriptBatchWithOpenAI({ windows: windows.slice(mid), model, credentials })
7930
+ ]);
7931
+ return [...left, ...right];
7932
+ }
7933
+ console.error("OpenAI transcript moderation failed:", error);
7934
+ return windows.map((window) => transcriptErrorScore(window, error));
7935
+ }
7936
+ }
7937
+ async function requestOpenAITranscriptModeration(cues, duration, model, maxConcurrent = 5, credentials) {
7938
+ "use step";
7939
+ const windows = buildTranscriptWindows(cues, duration);
7940
+ if (!windows.length) {
7941
+ return [];
7942
+ }
7943
+ const batches = packWindowsIntoBatches(windows).map(
7944
+ (windowBatch) => ({ windows: windowBatch, model, credentials })
7945
+ );
7946
+ const batchResults = await processConcurrently(
7947
+ batches,
7948
+ moderateTranscriptBatchWithOpenAI,
7761
7949
  maxConcurrent
7762
7950
  );
7951
+ return batchResults.flat();
7763
7952
  }
7764
7953
  function getHiveCategoryScores(classes, categoryNames) {
7765
7954
  const scoreMap = Object.fromEntries(
@@ -7982,12 +8171,35 @@ async function getThumbnailUrlsFromTimestamps(playbackId, timestampsMs, options)
7982
8171
  });
7983
8172
  return Promise.all(urlPromises);
7984
8173
  }
8174
+ async function requestThumbnailModeration(provider, thumbnailUrls, options) {
8175
+ const { model, maxConcurrent, imageSubmissionMode, imageDownloadOptions, credentials } = options;
8176
+ switch (provider) {
8177
+ case "openai":
8178
+ return requestOpenAIModeration(
8179
+ thumbnailUrls,
8180
+ model || "omni-moderation-latest",
8181
+ maxConcurrent,
8182
+ imageSubmissionMode,
8183
+ imageDownloadOptions,
8184
+ credentials
8185
+ );
8186
+ case "hive":
8187
+ return requestHiveModeration(thumbnailUrls, maxConcurrent, imageSubmissionMode, imageDownloadOptions, credentials);
8188
+ case "google-vision-api":
8189
+ return requestGoogleVisionModeration(thumbnailUrls, maxConcurrent, imageSubmissionMode, imageDownloadOptions, credentials);
8190
+ default: {
8191
+ const exhaustiveCheck = provider;
8192
+ throw new Error(`Unsupported moderation provider: ${exhaustiveCheck}`);
8193
+ }
8194
+ }
8195
+ }
7985
8196
  async function getModerationScores(assetId, options = {}) {
7986
8197
  "use workflow";
7987
8198
  const {
7988
8199
  provider = DEFAULT_PROVIDER2,
7989
8200
  model = provider === "openai" ? "omni-moderation-latest" : void 0,
7990
8201
  languageCode,
8202
+ includeTranscript = true,
7991
8203
  thresholds = DEFAULT_THRESHOLDS,
7992
8204
  thumbnailInterval = 10,
7993
8205
  thumbnailWidth = 640,
@@ -8014,47 +8226,31 @@ async function getModerationScores(assetId, options = {}) {
8014
8226
  startTime: Math.min(resolvedScope.startTime, duration),
8015
8227
  endTime: Math.min(resolvedScope.endTime, duration)
8016
8228
  } : void 0;
8017
- if (!isAudioOnly && renderableScope && renderableScope.startTime >= renderableScope.endTime) {
8018
- throw new Error("The requested scope does not include any renderable video.");
8019
- }
8020
8229
  const signingContext = await resolveMuxSigningContext(credentials);
8021
8230
  if (policy === "signed" && !signingContext) {
8022
8231
  throw new Error(
8023
8232
  "Signed playback ID requires signing credentials. Set MUX_SIGNING_KEY and MUX_PRIVATE_KEY environment variables."
8024
8233
  );
8025
8234
  }
8026
- let thumbnailScores;
8027
- let mode = "thumbnails";
8028
- let thumbnailCount;
8235
+ let thumbnailModeration = { status: "completed" };
8236
+ let thumbnailUrls = [];
8237
+ let shouldRunThumbnails = false;
8029
8238
  if (isAudioOnly) {
8030
- mode = "transcript";
8031
- const transcriptResult = await fetchTranscriptForAsset(asset, playbackId, {
8032
- languageCode,
8033
- cleanTranscript: true,
8034
- shouldSign: policy === "signed",
8035
- credentials,
8036
- required: true,
8037
- scope: effectiveScope
8038
- });
8039
- if (provider === "openai") {
8040
- thumbnailScores = await requestOpenAITranscriptModeration(
8041
- transcriptResult.transcriptText,
8042
- model || "omni-moderation-latest",
8043
- maxConcurrent,
8044
- credentials
8045
- );
8046
- } else if (provider === "hive") {
8047
- throw new Error("Hive does not support transcript moderation in this workflow. Use provider: 'openai' for audio-only assets.");
8048
- } else if (provider === "google-vision-api") {
8049
- throw new Error("google-vision-api is image-only and does not support transcript moderation. Use provider: 'openai' for audio-only assets.");
8050
- } else {
8051
- const exhaustiveCheck = provider;
8052
- throw new Error(`Unsupported moderation provider: ${exhaustiveCheck}`);
8053
- }
8239
+ thumbnailModeration = {
8240
+ status: "skipped",
8241
+ skipReason: "no_video_track",
8242
+ skipMessage: "Asset has no video track, so there are no thumbnails to moderate."
8243
+ };
8244
+ } else if (renderableScope && renderableScope.startTime >= renderableScope.endTime) {
8245
+ thumbnailModeration = {
8246
+ status: "skipped",
8247
+ skipReason: "no_video_in_scope",
8248
+ skipMessage: "The requested scope does not include any renderable video, so there are no thumbnails to moderate."
8249
+ };
8054
8250
  } else {
8055
8251
  const scopedDuration = renderableScope ? renderableScope.endTime - renderableScope.startTime : duration;
8056
8252
  const estimatedIntervalCount = scopedDuration <= 50 ? 5 : Math.ceil(scopedDuration / thumbnailInterval);
8057
- const thumbnailUrls = maxSamples !== void 0 && estimatedIntervalCount > maxSamples ? await getThumbnailUrlsFromTimestamps(
8253
+ thumbnailUrls = maxSamples !== void 0 && estimatedIntervalCount > maxSamples ? await getThumbnailUrlsFromTimestamps(
8058
8254
  playbackId,
8059
8255
  filterTimestampsWithinExclusiveScope(
8060
8256
  planSamplingTimestamps({
@@ -8079,70 +8275,148 @@ async function getModerationScores(assetId, options = {}) {
8079
8275
  credentials,
8080
8276
  scope: renderableScope
8081
8277
  });
8082
- thumbnailCount = thumbnailUrls.length;
8083
- if (provider === "openai") {
8084
- thumbnailScores = await requestOpenAIModeration(
8085
- thumbnailUrls,
8086
- model || "omni-moderation-latest",
8087
- maxConcurrent,
8088
- imageSubmissionMode,
8089
- imageDownloadOptions,
8090
- credentials
8091
- );
8092
- } else if (provider === "hive") {
8093
- thumbnailScores = await requestHiveModeration(
8094
- thumbnailUrls,
8095
- maxConcurrent,
8096
- imageSubmissionMode,
8097
- imageDownloadOptions,
8098
- credentials
8099
- );
8100
- } else if (provider === "google-vision-api") {
8101
- thumbnailScores = await requestGoogleVisionModeration(
8102
- thumbnailUrls,
8103
- maxConcurrent,
8104
- imageSubmissionMode,
8105
- imageDownloadOptions,
8106
- credentials
8107
- );
8278
+ shouldRunThumbnails = true;
8279
+ }
8280
+ let transcriptModeration = { status: "completed" };
8281
+ let transcriptCues = [];
8282
+ let shouldRunTranscript = false;
8283
+ const transcriptEnabled = includeTranscript || isAudioOnly;
8284
+ const captionTrack = transcriptEnabled && provider === "openai" ? findCaptionTrack(asset, languageCode) : void 0;
8285
+ if (!transcriptEnabled) {
8286
+ transcriptModeration = {
8287
+ status: "skipped",
8288
+ skipReason: "disabled",
8289
+ skipMessage: "Transcript moderation was disabled with includeTranscript: false."
8290
+ };
8291
+ } else if (provider !== "openai") {
8292
+ transcriptModeration = {
8293
+ status: "skipped",
8294
+ skipReason: "unsupported_provider",
8295
+ skipMessage: `Provider '${provider}' is image-only and cannot moderate transcript text; use provider 'openai'.`
8296
+ };
8297
+ } else if (!captionTrack) {
8298
+ transcriptModeration = {
8299
+ status: "skipped",
8300
+ skipReason: "no_ready_text_track",
8301
+ skipMessage: languageCode ? `No ready caption/subtitle track found for language '${languageCode}'.` : "No ready caption/subtitle track found for this asset."
8302
+ };
8303
+ } else {
8304
+ if (!captionTrack.id) {
8305
+ throw new MuxAiError("Transcript track is missing an id.", { type: "validation_error" });
8306
+ }
8307
+ const transcriptUrl = await buildTranscriptUrl(playbackId, captionTrack.id, policy === "signed", credentials);
8308
+ const { transcriptText, emptyReason } = await fetchTranscriptDataForUrl(transcriptUrl, {
8309
+ cleanTranscript: false,
8310
+ scope: effectiveScope
8311
+ });
8312
+ if (emptyReason) {
8313
+ transcriptModeration = {
8314
+ status: "skipped",
8315
+ skipReason: "no_cues",
8316
+ skipMessage: emptyReason === "no_scoped_cues" ? "Transcript has no cues in the requested scope." : "Transcript is empty."
8317
+ };
8108
8318
  } else {
8109
- const exhaustiveCheck = provider;
8110
- throw new Error(`Unsupported moderation provider: ${exhaustiveCheck}`);
8319
+ transcriptCues = parseVTTCues(transcriptText, { capCueText: false });
8320
+ const windows = transcriptCues.length === 0 ? [] : buildTranscriptWindows(transcriptCues, duration);
8321
+ if (windows.length === 0) {
8322
+ transcriptModeration = {
8323
+ status: "skipped",
8324
+ skipReason: "no_cues",
8325
+ skipMessage: "Caption track had no parseable cues."
8326
+ };
8327
+ } else {
8328
+ shouldRunTranscript = true;
8329
+ }
8111
8330
  }
8112
8331
  }
8113
- const failed = thumbnailScores.filter((s) => s.error);
8114
- const successful = thumbnailScores.filter((s) => !s.error);
8332
+ if (!shouldRunThumbnails && !shouldRunTranscript) {
8333
+ throw new MuxAiError(
8334
+ `Nothing to moderate. Thumbnails skipped: ${thumbnailModeration.skipMessage} Transcript skipped: ${transcriptModeration.skipMessage}`,
8335
+ { type: "validation_error" }
8336
+ );
8337
+ }
8338
+ const [thumbnailScores, transcriptScores] = await Promise.all([
8339
+ shouldRunThumbnails ? requestThumbnailModeration(provider, thumbnailUrls, {
8340
+ model,
8341
+ maxConcurrent,
8342
+ imageSubmissionMode,
8343
+ imageDownloadOptions,
8344
+ credentials
8345
+ }) : Promise.resolve([]),
8346
+ shouldRunTranscript ? requestOpenAITranscriptModeration(
8347
+ transcriptCues,
8348
+ duration,
8349
+ model || "omni-moderation-latest",
8350
+ maxConcurrent,
8351
+ credentials
8352
+ ) : Promise.resolve([])
8353
+ ]);
8354
+ const mode = shouldRunThumbnails && shouldRunTranscript ? "combined" : shouldRunTranscript ? "transcript" : "thumbnails";
8355
+ const thumbnailCount = shouldRunThumbnails ? thumbnailUrls.length : void 0;
8356
+ const allScores = [
8357
+ ...thumbnailScores.map((s) => ({ ...s, label: s.url })),
8358
+ ...transcriptScores.map((s) => ({ ...s, error: s.error ?? false, label: `transcript window ${s.startTime}-${s.endTime}s` }))
8359
+ ];
8360
+ const failed = allScores.filter((s) => s.error);
8361
+ const successful = allScores.filter((s) => !s.error);
8115
8362
  if (successful.length === 0) {
8116
- const details = failed.map((s) => `${s.url}: ${s.errorMessage || "Unknown error"}`).join("; ");
8363
+ const details = failed.map((s) => `${s.label}: ${s.errorMessage || "Unknown error"}`).join("; ");
8117
8364
  throw new Error(
8118
- `Moderation failed for all ${thumbnailScores.length} sample(s): ${details}`
8365
+ `Moderation failed for all ${allScores.length} sample(s): ${details}`
8119
8366
  );
8120
8367
  }
8121
8368
  if (failed.length > 0) {
8122
8369
  console.warn(
8123
- `Moderation had partial failures (${failed.length}/${thumbnailScores.length}); continuing with successful samples.`
8370
+ `Moderation had partial failures (${failed.length}/${allScores.length}); continuing with successful samples.`
8124
8371
  );
8125
8372
  }
8373
+ const thumbnailsFailed = shouldRunThumbnails && thumbnailScores.length > 0 && thumbnailScores.every((s) => s.error);
8374
+ const transcriptFailed = shouldRunTranscript && transcriptScores.length > 0 && transcriptScores.every((s) => s.error);
8375
+ if (thumbnailsFailed) {
8376
+ thumbnailModeration = {
8377
+ status: "failed",
8378
+ errorMessage: describeSurfaceFailure("thumbnail", thumbnailScores)
8379
+ };
8380
+ }
8381
+ if (transcriptFailed) {
8382
+ transcriptModeration = {
8383
+ status: "failed",
8384
+ errorMessage: describeSurfaceFailure("transcript window", transcriptScores)
8385
+ };
8386
+ }
8126
8387
  const maxSexual = Math.max(...successful.map((s) => s.sexual));
8127
8388
  const maxViolence = Math.max(...successful.map((s) => s.violence));
8128
8389
  const finalThresholds = { ...DEFAULT_THRESHOLDS, ...thresholds };
8129
- const requestedSampleCount = thumbnailScores.length;
8130
- const successfulSampleCount = successful.length;
8131
- const failedSampleCount = failed.length;
8132
- const sampleCoverage = successfulSampleCount / requestedSampleCount;
8133
- const hasEnoughSuccessfulSamples = mode === "transcript" || successfulSampleCount >= MIN_SUCCESSFUL_THUMBNAILS_FOR_CONFIDENT_THRESHOLDING;
8134
- const isLowConfidence = sampleCoverage < MIN_SAMPLE_COVERAGE_FOR_CONFIDENT_THRESHOLDING || !hasEnoughSuccessfulSamples;
8390
+ const coverageScores = thumbnailScores;
8391
+ const coverageFailed = coverageScores.filter((s) => s.error);
8392
+ const coverageSuccessful = coverageScores.filter((s) => !s.error);
8393
+ const requestedSampleCount = coverageScores.length;
8394
+ const successfulSampleCount = coverageSuccessful.length;
8395
+ const failedSampleCount = coverageFailed.length;
8396
+ const sampleCoverage = requestedSampleCount > 0 ? successfulSampleCount / requestedSampleCount : 0;
8397
+ const hasThumbnails = requestedSampleCount > 0;
8398
+ const hasSuccessfulTranscript = transcriptScores.some((s) => !s.error);
8399
+ let isLowConfidence;
8400
+ if (!hasThumbnails) {
8401
+ isLowConfidence = !hasSuccessfulTranscript;
8402
+ } else {
8403
+ const hasEnoughSuccessfulSamples = successfulSampleCount >= MIN_SUCCESSFUL_THUMBNAILS_FOR_CONFIDENT_THRESHOLDING;
8404
+ isLowConfidence = sampleCoverage < MIN_SAMPLE_COVERAGE_FOR_CONFIDENT_THRESHOLDING || !hasEnoughSuccessfulSamples || transcriptFailed;
8405
+ }
8135
8406
  return {
8136
8407
  assetId,
8137
8408
  mode,
8138
8409
  isAudioOnly,
8139
8410
  thumbnailScores,
8411
+ transcriptScores,
8412
+ thumbnailModeration,
8413
+ transcriptModeration,
8140
8414
  coverage: {
8141
8415
  requestedSampleCount,
8142
8416
  successfulSampleCount,
8143
8417
  failedSampleCount,
8144
8418
  sampleCoverage,
8145
- isPartial: failedSampleCount > 0,
8419
+ isPartial: failed.length > 0,
8146
8420
  isLowConfidence
8147
8421
  },
8148
8422
  usage: {
@@ -8162,7 +8436,7 @@ async function getModerationScores(assetId, options = {}) {
8162
8436
 
8163
8437
  // src/workflows/summarization.ts
8164
8438
  import { generateText as generateText8, Output as Output8 } from "ai";
8165
- import { z as z9 } from "zod";
8439
+ import { z as z10 } from "zod";
8166
8440
 
8167
8441
  // src/lib/markdown-normalization.ts
8168
8442
  var ESCAPED_LINE_BREAK_REGEX = /\\r\\n|\\n|\\r/g;
@@ -8307,17 +8581,17 @@ function createSummarizationGuidance({
8307
8581
  }
8308
8582
 
8309
8583
  // src/workflows/summarization.ts
8310
- var summarySchema = z9.object({
8311
- keywords: z9.array(z9.string().max(200)),
8312
- title: z9.string().max(500),
8313
- description: z9.string().max(1e4)
8584
+ var summarySchema = z10.object({
8585
+ keywords: z10.array(z10.string().max(200)),
8586
+ title: z10.string().max(500),
8587
+ description: z10.string().max(1e4)
8314
8588
  });
8315
8589
  function buildSummarySchema(descriptionLength) {
8316
8590
  const descriptionMaxChars = Math.max(2e3, descriptionLength * 15);
8317
- return z9.object({
8318
- keywords: z9.array(z9.string().max(200)),
8319
- title: z9.string().max(500),
8320
- description: z9.string().max(descriptionMaxChars)
8591
+ return z10.object({
8592
+ keywords: z10.array(z10.string().max(200)),
8593
+ title: z10.string().max(500),
8594
+ description: z10.string().max(descriptionMaxChars)
8321
8595
  });
8322
8596
  }
8323
8597
  var VALID_TONES = ["neutral", "playful", "professional"];
@@ -9436,9 +9710,9 @@ import {
9436
9710
  RetryError as RetryError2,
9437
9711
  TypeValidationError
9438
9712
  } from "ai";
9439
- import { z as z10 } from "zod";
9440
- var translationSchema = z10.object({
9441
- translation: z10.string()
9713
+ import { z as z11 } from "zod";
9714
+ var translationSchema = z11.object({
9715
+ translation: z11.string()
9442
9716
  });
9443
9717
  var SYSTEM_PROMPT5 = promptDedent`
9444
9718
  You are a subtitle translation expert. Translate VTT subtitle files to the target language specified by the user.
@@ -9817,8 +10091,8 @@ async function translateCueChunkWithAI({
9817
10091
  }) {
9818
10092
  "use step";
9819
10093
  const model = await createLanguageModelFromConfig(provider, modelId, credentials);
9820
- const schema = z10.object({
9821
- translations: z10.array(z10.string().min(1).max(2e3)).length(cues.length)
10094
+ const schema = z11.object({
10095
+ translations: z11.array(z11.string().min(1).max(2e3)).length(cues.length)
9822
10096
  });
9823
10097
  const cuePayload = cues.map((cue, index) => ({
9824
10098
  index,