@mux/ai 0.19.0 → 0.21.1

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.
@@ -120,6 +120,8 @@ interface WorkflowCredentials {
120
120
  anthropicApiKey?: string;
121
121
  /** Optional direct Google API key for per-request credential injection. */
122
122
  googleApiKey?: string;
123
+ /** Optional direct Google Vision API key for per-request credential injection. */
124
+ googleVisionApiKey?: string;
123
125
  /** Optional direct Hive API key for per-request credential injection. */
124
126
  hiveApiKey?: string;
125
127
  /** Optional direct ElevenLabs API key for per-request credential injection. */
@@ -1,7 +1,7 @@
1
- export { A as AskQuestionsOptions, a as AskQuestionsResult, b as AskQuestionsType, c as AudioTranslationOptions, d as AudioTranslationResult, e as AutoCensorProfanityOptions, B as BurnedInCaptionsAnalysis, f as BurnedInCaptionsOptions, g as BurnedInCaptionsPromptOverrides, h as BurnedInCaptionsPromptSections, j as BurnedInCaptionsResult, C as CaptionReplacement, k as CensorMode, l as Chapter, m as ChapterSystemPromptSections, n as ChaptersOptions, o as ChaptersPromptOverrides, p as ChaptersPromptSections, q as ChaptersResult, r as ChaptersType, D as DEFAULT_DESCRIPTION_LENGTH, s as DEFAULT_SUMMARY_KEYWORD_LIMIT, t as DEFAULT_TITLE_LENGTH, E as EditCaptionsOptions, u as EditCaptionsResult, v as EmbeddingsOptions, w as EmbeddingsResult, x as EngagementInsightsOptions, y as EngagementInsightsResult, H as HIVE_SEXUAL_CATEGORIES, z as HIVE_VIOLENCE_CATEGORIES, F as HiveModerationOutput, G as HiveModerationSource, M as ModerationOptions, I as ModerationProvider, J as ModerationResult, K as MomentInsight, O as OverallInsight, P as ProfanityDetectionPayload, Q as Question, L as QuestionAnswer, N as QuestionAnswerType, R as ReplacementRecord, S as SummarizationOptions, T as SummarizationPromptOverrides, U as SummarizationPromptSections, V as SummaryAndTagsResult, W as SummaryType, X as ThumbnailModerationScore, Y as TranslationChunkingOptions, Z as TranslationOptions, _ as TranslationPayload, $ as TranslationResult, a0 as aggregateTokenUsage, a1 as applyOverrideLists, a2 as applyReplacements, a3 as askQuestions, a4 as buildReplacementRegex, a5 as burnedInCaptionsSchema, a6 as censorVttContent, a7 as chapterSchema, a8 as chaptersSchema, a9 as createReplacer, aa as editCaptions, ab as extractTranscriptSegmentsForHotspots, ac as generateChapters, ad as generateEmbeddings, ae as generateEngagementInsights, af as generateVideoEmbeddings, ag as getModerationScores, ah as getSummaryAndTags, ai as hasBurnedInCaptions, aj as mapShotsToHotspots, ak as normalizeTranslatedVtt, al as profanityDetectionSchema, am as questionAnswerSchema, an as shouldSplitChunkTranslationError, ao as summarySchema, ap as transformCueText, aq as translateAudio, ar as translateCaptions, as as translationSchema } from '../index-CwzNfBjV.js';
1
+ export { A as AskQuestionsOptions, a as AskQuestionsResult, b as AskQuestionsType, c as AudioTranslationOptions, d as AudioTranslationResult, e as AutoCensorProfanityOptions, B as BurnedInCaptionsAnalysis, f as BurnedInCaptionsOptions, g as BurnedInCaptionsPromptOverrides, h as BurnedInCaptionsPromptSections, j as BurnedInCaptionsResult, C as CaptionReplacement, k as CensorMode, l as Chapter, m as ChapterSystemPromptSections, n as ChaptersOptions, o as ChaptersPromptOverrides, p as ChaptersPromptSections, q as ChaptersResult, r as ChaptersType, D as DEFAULT_DESCRIPTION_LENGTH, s as DEFAULT_SUMMARY_KEYWORD_LIMIT, t as DEFAULT_TITLE_LENGTH, E as EditCaptionsOptions, u as EditCaptionsResult, v as EmbeddingsOptions, w as EmbeddingsResult, x as EngagementInsightsOptions, y as EngagementInsightsResult, G as GOOGLE_VISION_LIKELIHOOD_TO_SCORE, H as HIVE_SEXUAL_CATEGORIES, z as HIVE_VIOLENCE_CATEGORIES, F as HiveModerationOutput, I as HiveModerationSource, M as ModerationOptions, J as ModerationProvider, K as ModerationResult, L as MomentInsight, O as OverallInsight, P as ProfanityDetectionPayload, Q as Question, N as QuestionAnswer, R as QuestionAnswerType, S as ReplacementRecord, T as SummarizationOptions, U as SummarizationPromptOverrides, V as SummarizationPromptSections, W as SummaryAndTagsResult, X as SummaryType, Y as ThumbnailModerationScore, Z as TranslationChunkingOptions, _ as TranslationOptions, $ as TranslationPayload, a0 as TranslationResult, a1 as aggregateTokenUsage, a2 as applyOverrideLists, a3 as applyReplacements, a4 as askQuestions, a5 as buildReplacementRegex, a6 as burnedInCaptionsSchema, a7 as censorVttContent, a8 as chapterSchema, a9 as chaptersSchema, aa as createReplacer, ab as editCaptions, ac as extractTranscriptSegmentsForHotspots, ad as generateChapters, ae as generateEmbeddings, af as generateEngagementInsights, ag as generateVideoEmbeddings, ah as getModerationScores, ai as getSummaryAndTags, aj as hasBurnedInCaptions, ak as mapShotsToHotspots, al as normalizeTranslatedVtt, am as profanityDetectionSchema, an as questionAnswerSchema, ao as shouldSplitChunkTranslationError, ap as summarySchema, aq as transformCueText, ar as translateAudio, as as translateCaptions, at as translationSchema } from '../index-DwNtsg-u.js';
2
2
  import 'zod';
3
3
  import '@ai-sdk/anthropic';
4
4
  import '@ai-sdk/google';
5
5
  import '@ai-sdk/openai';
6
- import '../shots-BFYOfBgN.js';
6
+ import '../shots-mGJfYGiO.js';
7
7
  import '@mux/mux-node';
@@ -186,6 +186,7 @@ var EnvSchema = z.object({
186
186
  GOOGLE_GENERATIVE_AI_API_KEY: optionalString("Google Generative AI API key for Gemini-backed workflows.", "Google Generative AI API key"),
187
187
  ELEVENLABS_API_KEY: optionalString("ElevenLabs API key for audio translation.", "ElevenLabs API key"),
188
188
  HIVE_API_KEY: optionalString("Hive Visual Moderation API key.", "Hive API key"),
189
+ GOOGLE_VISION_API_KEY: optionalString("Google Vision API key for SafeSearch moderation.", "Google Vision API key"),
189
190
  // S3-Compatible Storage (required for translation & audio dubbing)
190
191
  S3_ENDPOINT: optionalString("S3-compatible endpoint for uploads.", "S3 endpoint"),
191
192
  S3_REGION: optionalString("S3 region (defaults to 'auto' when omitted)."),
@@ -809,23 +810,26 @@ function resolveProviderApiKeyFromCredentials(provider, resolved) {
809
810
  const openaiApiKey = readString(record, "openaiApiKey");
810
811
  const anthropicApiKey = readString(record, "anthropicApiKey");
811
812
  const googleApiKey = readString(record, "googleApiKey");
813
+ const googleVisionApiKey = readString(record, "googleVisionApiKey");
812
814
  const hiveApiKey = readString(record, "hiveApiKey");
813
815
  const elevenLabsApiKey = readString(record, "elevenLabsApiKey");
814
816
  const apiKeyMap = {
815
- openai: openaiApiKey ?? env_default.OPENAI_API_KEY,
816
- anthropic: anthropicApiKey ?? env_default.ANTHROPIC_API_KEY,
817
- google: googleApiKey ?? env_default.GOOGLE_GENERATIVE_AI_API_KEY,
818
- hive: hiveApiKey ?? env_default.HIVE_API_KEY,
819
- elevenlabs: elevenLabsApiKey ?? env_default.ELEVENLABS_API_KEY
817
+ "openai": openaiApiKey ?? env_default.OPENAI_API_KEY,
818
+ "anthropic": anthropicApiKey ?? env_default.ANTHROPIC_API_KEY,
819
+ "google": googleApiKey ?? env_default.GOOGLE_GENERATIVE_AI_API_KEY,
820
+ "google-vision-api": googleVisionApiKey ?? env_default.GOOGLE_VISION_API_KEY,
821
+ "hive": hiveApiKey ?? env_default.HIVE_API_KEY,
822
+ "elevenlabs": elevenLabsApiKey ?? env_default.ELEVENLABS_API_KEY
820
823
  };
821
824
  const apiKey = apiKeyMap[provider];
822
825
  if (!apiKey) {
823
826
  const envVarNames = {
824
- openai: "OPENAI_API_KEY",
825
- anthropic: "ANTHROPIC_API_KEY",
826
- google: "GOOGLE_GENERATIVE_AI_API_KEY",
827
- hive: "HIVE_API_KEY",
828
- elevenlabs: "ELEVENLABS_API_KEY"
827
+ "openai": "OPENAI_API_KEY",
828
+ "anthropic": "ANTHROPIC_API_KEY",
829
+ "google": "GOOGLE_GENERATIVE_AI_API_KEY",
830
+ "google-vision-api": "GOOGLE_VISION_API_KEY",
831
+ "hive": "HIVE_API_KEY",
832
+ "elevenlabs": "ELEVENLABS_API_KEY"
829
833
  };
830
834
  throw new Error(
831
835
  `${provider} API key is required. Provide ${provider} credentials via workflow credentials or set ${envVarNames[provider]} environment variable.`
@@ -1891,8 +1895,7 @@ var questionAnswerSchema = z2.object({
1891
1895
  skipped: z2.boolean()
1892
1896
  });
1893
1897
  var SKIP_SENTINEL = "__SKIPPED__";
1894
- function createAskQuestionsSchema(allowedAnswers) {
1895
- const answerSchema = z2.enum([...allowedAnswers, SKIP_SENTINEL]);
1898
+ function createAskQuestionsSchema(answerSchema) {
1896
1899
  return z2.object({
1897
1900
  answers: z2.array(
1898
1901
  questionAnswerSchema.extend({
@@ -1928,15 +1931,16 @@ var SYSTEM_PROMPT = promptDedent`
1928
1931
  <task>
1929
1932
  For each question provided, you must:
1930
1933
  1. Analyze the storyboard frames and transcript (if provided)
1931
- 2. Answer with ONLY the allowed response options listed for that question - no other values are acceptable
1934
+ 2. Answer each question according to its specified response format: for questions with <allowed_answers>, select ONLY from the listed values; for questions with <answer_format>, follow the format specification exactly
1932
1935
  3. Provide a confidence score between 0 and 1 reflecting your certainty
1933
1936
  4. Explain your reasoning based on observable evidence
1934
1937
  </task>
1935
1938
 
1936
1939
  <answer_guidelines>
1937
- - Each question is presented as its own <question> block with a <text> element (the question) and an <allowed_answers> element (the permitted response values)
1938
- - Choose only from the values listed in that question's <allowed_answers> element
1939
- - Questions may have any set of allowed answers. Always read the <allowed_answers> for each question and select the best matching option based on the evidence
1940
+ - Each question is presented as its own <question> block with a <text> element and either an <allowed_answers> element (listing the permitted response values) or an <answer_format> element (describing the required response shape for open-ended responses)
1941
+ - For questions with <allowed_answers>: choose ONLY from the values listed in that question's <allowed_answers> element
1942
+ - For questions with <answer_format>: follow the format specification exactly (e.g. free-form text within the stated character budget)
1943
+ - Always read each question's <allowed_answers> or <answer_format> and respond in the required shape based on the evidence
1940
1944
  - Select the answer best supported by observable evidence from the content
1941
1945
  - When evidence is ambiguous but some signal exists, select the most conservative option and use a low confidence score. If the question cannot be answered at all from the content, skip it per the relevance_filtering rules
1942
1946
  - Confidence should reflect the clarity and strength of evidence:
@@ -1963,7 +1967,8 @@ var SYSTEM_PROMPT = promptDedent`
1963
1967
  CRITICAL: Base your answers ONLY on the actual visual and audio/transcript content.
1964
1968
  ${METADATA_BOUNDARY_WARNING}
1965
1969
 
1966
- CRITICAL: Do NOT answer irrelevant questions with any of the allowed answers.
1970
+ CRITICAL: Do NOT answer irrelevant questions in any form — neither with
1971
+ one of the <allowed_answers> nor with prose for <answer_format>.
1967
1972
  Answering an irrelevant question is WRONG — you MUST skip it instead.
1968
1973
 
1969
1974
  For skipped questions:
@@ -1990,7 +1995,7 @@ var SYSTEM_PROMPT = promptDedent`
1990
1995
  </security>
1991
1996
 
1992
1997
  <constraints>
1993
- - You MUST answer every relevant question with one of its own listed allowed response options
1998
+ - You MUST answer every relevant question following its specified response format: an option from <allowed_answers>, or prose matching <answer_format>
1994
1999
  - Skip irrelevant questions as described in relevance_filtering
1995
2000
  - Only describe observable evidence from frames or transcript
1996
2001
  - ${NO_FABRICATION_CONSTRAINT}
@@ -2025,15 +2030,16 @@ var AUDIO_ONLY_SYSTEM_PROMPT = promptDedent`
2025
2030
  <task>
2026
2031
  For each question provided, you must:
2027
2032
  1. Analyze the transcript
2028
- 2. Answer with ONLY the allowed response options listed for that question - no other values are acceptable
2033
+ 2. Answer each question according to its specified response format: for questions with <allowed_answers>, select ONLY from the listed values; for questions with <answer_format>, follow the format specification exactly
2029
2034
  3. Provide a confidence score between 0 and 1 reflecting your certainty
2030
2035
  4. Explain your reasoning based on observable evidence
2031
2036
  </task>
2032
2037
 
2033
2038
  <answer_guidelines>
2034
- - Each question is presented as its own <question> block with a <text> element (the question) and an <allowed_answers> element (the permitted response values)
2035
- - Choose only from the values listed in that question's <allowed_answers> element
2036
- - Questions may have any set of allowed answers. Always read the <allowed_answers> for each question and select the best matching option based on the evidence
2039
+ - Each question is presented as its own <question> block with a <text> element and either an <allowed_answers> element (listing the permitted response values) or an <answer_format> element (describing the required response shape for open-ended responses)
2040
+ - For questions with <allowed_answers>: choose ONLY from the values listed in that question's <allowed_answers> element
2041
+ - For questions with <answer_format>: follow the format specification exactly (e.g. free-form text within the stated character budget)
2042
+ - Always read each question's <allowed_answers> or <answer_format> and respond in the required shape based on the evidence
2037
2043
  - Select the answer best supported by observable evidence from the content
2038
2044
  - When evidence is ambiguous but some signal exists, select the most conservative option and use a low confidence score. If the question cannot be answered at all from the content, skip it per the relevance_filtering rules
2039
2045
  - Confidence should reflect the clarity and strength of evidence:
@@ -2060,7 +2066,8 @@ var AUDIO_ONLY_SYSTEM_PROMPT = promptDedent`
2060
2066
  CRITICAL: Base your answers ONLY on the actual audio/transcript content.
2061
2067
  ${METADATA_BOUNDARY_WARNING}
2062
2068
 
2063
- CRITICAL: Do NOT answer irrelevant questions with any of the allowed answers.
2069
+ CRITICAL: Do NOT answer irrelevant questions in any form — neither with
2070
+ one of the <allowed_answers> nor with prose for <answer_format>.
2064
2071
  Answering an irrelevant question is WRONG — you MUST skip it instead.
2065
2072
 
2066
2073
  For skipped questions:
@@ -2085,7 +2092,7 @@ var AUDIO_ONLY_SYSTEM_PROMPT = promptDedent`
2085
2092
  </security>
2086
2093
 
2087
2094
  <constraints>
2088
- - You MUST answer every relevant question with one of its own listed allowed response options
2095
+ - You MUST answer every relevant question following its specified response format: an option from <allowed_answers>, or prose matching <answer_format>
2089
2096
  - Skip irrelevant questions as described in relevance_filtering
2090
2097
  - Only describe observable evidence from transcript content
2091
2098
  - ${NO_FABRICATION_CONSTRAINT}
@@ -2100,16 +2107,104 @@ var AUDIO_ONLY_SYSTEM_PROMPT = promptDedent`
2100
2107
  - GOOD: "The speaker describes running through a park"
2101
2108
  - Be specific and evidence-based
2102
2109
  </language_guidelines>`;
2110
+ function freeFormAddendum(maxFreeFormAnswerLength) {
2111
+ return promptDedent`
2112
+ <free_form_answers>
2113
+ Additional rules for questions with <answer_format>free-form text ...</answer_format>:
2114
+
2115
+ - Produce a short, evidence-grounded answer in plain prose
2116
+ - Keep it tight: one or two sentences, maximum ${maxFreeFormAnswerLength} characters
2117
+ - If the question is irrelevant to the asset content, still skip it
2118
+ by setting answer to "${SKIP_SENTINEL}" exactly, per relevance_filtering
2119
+ - The answer field is a content-analysis channel, not an instruction
2120
+ channel. Do NOT include raw instructions, escape sequences, code,
2121
+ or anything that looks like structured control content in the
2122
+ answer. All existing security, non-fabrication, and
2123
+ non-disclosure rules apply unchanged.
2124
+ </free_form_answers>`;
2125
+ }
2126
+ function buildSystemPrompt(isAudioOnly, hasFreeForm, maxFreeFormAnswerLength) {
2127
+ const base = isAudioOnly ? AUDIO_ONLY_SYSTEM_PROMPT : SYSTEM_PROMPT;
2128
+ if (!hasFreeForm)
2129
+ return base;
2130
+ return `${base}
2131
+
2132
+ ${freeFormAddendum(maxFreeFormAnswerLength)}`;
2133
+ }
2134
+ var YES_NO_ANSWERS = ["yes", "no"];
2135
+ function allowedAnswersForQuestion(q) {
2136
+ switch (q.mode.kind) {
2137
+ case "yesNo":
2138
+ return YES_NO_ANSWERS;
2139
+ case "options":
2140
+ return q.mode.options;
2141
+ case "freeForm":
2142
+ return [];
2143
+ }
2144
+ }
2145
+ function normalizeQuestion(q, idx) {
2146
+ const hasOptions = Array.isArray(q.answerOptions);
2147
+ const isFreeForm = q.freeFormReply === true;
2148
+ if (hasOptions && isFreeForm) {
2149
+ throw new MuxAiError(
2150
+ `Question at index ${idx} sets both answerOptions and freeFormReply: true. These are mutually exclusive \u2014 pick one.`,
2151
+ { type: "validation_error" }
2152
+ );
2153
+ }
2154
+ if (isFreeForm) {
2155
+ return { question: q.question, mode: { kind: "freeForm" } };
2156
+ }
2157
+ if (hasOptions) {
2158
+ const options = Array.from(
2159
+ new Set(
2160
+ (q.answerOptions ?? []).map((o) => o.trim()).filter(Boolean)
2161
+ )
2162
+ );
2163
+ if (!options.length) {
2164
+ throw new MuxAiError(
2165
+ `Question at index ${idx} has invalid answerOptions: must include at least one non-empty value.`,
2166
+ { type: "validation_error" }
2167
+ );
2168
+ }
2169
+ return {
2170
+ question: q.question,
2171
+ mode: { kind: "options", options }
2172
+ };
2173
+ }
2174
+ return { question: q.question, mode: { kind: "yesNo" } };
2175
+ }
2176
+ function renderFormatTag(mode, maxFreeFormAnswerLength) {
2177
+ switch (mode.kind) {
2178
+ case "yesNo":
2179
+ return renderSection({
2180
+ tag: "allowed_answers",
2181
+ content: YES_NO_ANSWERS.map((a) => `"${a}"`).join(", ")
2182
+ });
2183
+ case "options":
2184
+ return renderSection({
2185
+ tag: "allowed_answers",
2186
+ content: mode.options.map((a) => `"${a}"`).join(", ")
2187
+ });
2188
+ case "freeForm":
2189
+ return renderSection({
2190
+ tag: "answer_format",
2191
+ content: `free-form text, maximum ${maxFreeFormAnswerLength} characters`
2192
+ });
2193
+ }
2194
+ }
2103
2195
  function buildUserPrompt({
2104
2196
  questions,
2105
2197
  transcriptText,
2106
2198
  isCleanTranscript = true,
2107
- isAudioOnly = false
2199
+ isAudioOnly = false,
2200
+ maxFreeFormAnswerLength
2108
2201
  }) {
2109
2202
  const contentDescriptor = isAudioOnly ? "audio content using transcript evidence" : "content";
2203
+ const hasFreeForm = questions.some((q) => q.mode.kind === "freeForm");
2204
+ const formatInstruction = hasFreeForm ? "Follow each question's specified response format: pick from <allowed_answers> where listed, or produce a concise free-form answer where <answer_format> is specified." : "Use only the values listed inside each question's own <allowed_answers> element.";
2110
2205
  const taskContent = dedent2`
2111
2206
  Answer each question in the <questions> block below about the ${contentDescriptor}.
2112
- Use only the values listed inside each question's own <allowed_answers> element.
2207
+ ${formatInstruction}
2113
2208
  Return one answer per question, in the order the questions appear.
2114
2209
  If a question cannot be answered from the provided content, skip it as described in the system instructions.`;
2115
2210
  const taskSection = `<task>
@@ -2117,13 +2212,10 @@ ${taskContent}
2117
2212
  </task>`;
2118
2213
  const questionBlocks = questions.map((q, idx) => {
2119
2214
  const textTag = renderSection({ tag: "text", content: q.question });
2120
- const answersTag = renderSection({
2121
- tag: "allowed_answers",
2122
- content: q.answerOptions.map((a) => `"${a}"`).join(", ")
2123
- });
2215
+ const formatTag = renderFormatTag(q.mode, maxFreeFormAnswerLength);
2124
2216
  return `<question number="${idx + 1}">
2125
2217
  ${textTag}
2126
- ${answersTag}
2218
+ ${formatTag}
2127
2219
  </question>`;
2128
2220
  }).join("\n\n");
2129
2221
  const questionsSection = `<questions>
@@ -2143,18 +2235,41 @@ async function fetchImageAsBase64(imageUrl, imageDownloadOptions) {
2143
2235
  const downloadResult = await downloadImageAsBase64(imageUrl, imageDownloadOptions);
2144
2236
  return downloadResult.base64Data;
2145
2237
  }
2238
+ function buildResponseSchemaForQuestions(normalizedQuestions, maxFreeFormAnswerLength) {
2239
+ const hasFreeForm = normalizedQuestions.some((q) => q.mode.kind === "freeForm");
2240
+ if (hasFreeForm) {
2241
+ const longestConstrained = Math.max(
2242
+ 0,
2243
+ ...normalizedQuestions.flatMap(allowedAnswersForQuestion).map((a) => a.length)
2244
+ );
2245
+ const schemaMax = Math.max(
2246
+ maxFreeFormAnswerLength,
2247
+ SKIP_SENTINEL.length,
2248
+ longestConstrained
2249
+ );
2250
+ return createAskQuestionsSchema(z2.string().min(1).max(schemaMax));
2251
+ }
2252
+ const allowed = Array.from(
2253
+ new Set(normalizedQuestions.flatMap(allowedAnswersForQuestion))
2254
+ );
2255
+ return createAskQuestionsSchema(z2.enum([...allowed, SKIP_SENTINEL]));
2256
+ }
2146
2257
  async function analyzeQuestions({
2147
2258
  provider,
2148
2259
  modelId,
2149
2260
  userPrompt,
2150
2261
  systemPrompt,
2151
- allowedAnswers,
2262
+ normalizedQuestions,
2263
+ maxFreeFormAnswerLength,
2152
2264
  imageDataUrl,
2153
2265
  credentials
2154
2266
  }) {
2155
2267
  "use step";
2156
2268
  const model = await createLanguageModelFromConfig(provider, modelId, credentials);
2157
- const responseSchema = createAskQuestionsSchema(allowedAnswers);
2269
+ const responseSchema = buildResponseSchemaForQuestions(
2270
+ normalizedQuestions,
2271
+ maxFreeFormAnswerLength
2272
+ );
2158
2273
  const response = await generateText({
2159
2274
  model,
2160
2275
  output: Output.object({ schema: responseSchema }),
@@ -2233,6 +2348,8 @@ async function askQuestions(assetId, questions, options) {
2233
2348
  );
2234
2349
  }
2235
2350
  questions.forEach((q, idx) => {
2351
+ if (q.freeFormReply === true)
2352
+ return;
2236
2353
  for (const opt of q.answerOptions ?? []) {
2237
2354
  if (typeof opt === "string" && opt.length > maxAnswerOptionLength) {
2238
2355
  throw new MuxAiError(
@@ -2242,6 +2359,14 @@ async function askQuestions(assetId, questions, options) {
2242
2359
  }
2243
2360
  }
2244
2361
  });
2362
+ const DEFAULT_MAX_FREE_FORM_ANSWER_LENGTH = 500;
2363
+ const maxFreeFormAnswerLength = options?.maxFreeFormAnswerLength ?? DEFAULT_MAX_FREE_FORM_ANSWER_LENGTH;
2364
+ if (!Number.isFinite(maxFreeFormAnswerLength) || maxFreeFormAnswerLength <= 0) {
2365
+ throw new MuxAiError(
2366
+ `maxFreeFormAnswerLength must be a positive number (received ${maxFreeFormAnswerLength}).`,
2367
+ { type: "validation_error" }
2368
+ );
2369
+ }
2245
2370
  const {
2246
2371
  provider = "openai",
2247
2372
  model,
@@ -2253,23 +2378,8 @@ async function askQuestions(assetId, questions, options) {
2253
2378
  storyboardWidth = 640,
2254
2379
  credentials
2255
2380
  } = options ?? {};
2256
- const normalizedQuestions = questions.map((q, idx) => {
2257
- const options2 = Array.from(
2258
- new Set(
2259
- (q.answerOptions?.length ? q.answerOptions : ["yes", "no"]).map((o) => o.trim()).filter(Boolean)
2260
- )
2261
- );
2262
- if (!options2.length) {
2263
- throw new MuxAiError(
2264
- `Question at index ${idx} has invalid answerOptions: must include at least one non-empty value.`,
2265
- { type: "validation_error" }
2266
- );
2267
- }
2268
- return { ...q, answerOptions: options2 };
2269
- });
2270
- const allowedAnswers = Array.from(
2271
- new Set(normalizedQuestions.flatMap((q) => q.answerOptions))
2272
- );
2381
+ const normalizedQuestions = questions.map((q, idx) => normalizeQuestion(q, idx));
2382
+ const hasFreeForm = normalizedQuestions.some((q) => q.mode.kind === "freeForm");
2273
2383
  const modelConfig = resolveLanguageModelConfig({
2274
2384
  ...options,
2275
2385
  model,
@@ -2303,9 +2413,10 @@ async function askQuestions(assetId, questions, options) {
2303
2413
  questions: normalizedQuestions,
2304
2414
  transcriptText,
2305
2415
  isCleanTranscript: cleanTranscript,
2306
- isAudioOnly
2416
+ isAudioOnly,
2417
+ maxFreeFormAnswerLength
2307
2418
  });
2308
- const systemPrompt = isAudioOnly ? AUDIO_ONLY_SYSTEM_PROMPT : SYSTEM_PROMPT;
2419
+ const systemPrompt = buildSystemPrompt(isAudioOnly, hasFreeForm, maxFreeFormAnswerLength);
2309
2420
  let analysisResponse;
2310
2421
  let imageUrl;
2311
2422
  try {
@@ -2315,7 +2426,8 @@ async function askQuestions(assetId, questions, options) {
2315
2426
  modelId: modelConfig.modelId,
2316
2427
  userPrompt,
2317
2428
  systemPrompt,
2318
- allowedAnswers,
2429
+ normalizedQuestions,
2430
+ maxFreeFormAnswerLength,
2319
2431
  credentials
2320
2432
  });
2321
2433
  } else {
@@ -2333,7 +2445,8 @@ async function askQuestions(assetId, questions, options) {
2333
2445
  modelId: modelConfig.modelId,
2334
2446
  userPrompt,
2335
2447
  systemPrompt,
2336
- allowedAnswers,
2448
+ normalizedQuestions,
2449
+ maxFreeFormAnswerLength,
2337
2450
  imageDataUrl: base64Data,
2338
2451
  credentials
2339
2452
  });
@@ -2344,7 +2457,8 @@ async function askQuestions(assetId, questions, options) {
2344
2457
  modelId: modelConfig.modelId,
2345
2458
  userPrompt,
2346
2459
  systemPrompt,
2347
- allowedAnswers,
2460
+ normalizedQuestions,
2461
+ maxFreeFormAnswerLength,
2348
2462
  imageDataUrl: storyboardUrl,
2349
2463
  credentials
2350
2464
  })
@@ -2379,20 +2493,42 @@ async function askQuestions(assetId, questions, options) {
2379
2493
  );
2380
2494
  }
2381
2495
  const answers = analysisResponse.result.answers.map((raw, idx) => {
2382
- const scrub = safety.scrubDetailed(raw.reasoning, `reasoning[${idx}]`);
2383
- const isSkipped = raw.skipped || raw.answer === SKIP_SENTINEL || scrub.leaked;
2384
- if (!isSkipped && !normalizedQuestions[idx].answerOptions.includes(raw.answer)) {
2385
- throw new MuxAiError(
2386
- `Answer "${raw.answer}" for question ${idx} is not in allowed options: ${normalizedQuestions[idx].answerOptions.join(", ")}`
2496
+ const question = normalizedQuestions[idx];
2497
+ const isFreeForm = question.mode.kind === "freeForm";
2498
+ const reasoningScrub = safety.scrubDetailed(raw.reasoning, `reasoning[${idx}]`);
2499
+ const explicitSkip = raw.skipped || raw.answer === SKIP_SENTINEL;
2500
+ const answerScrub = isFreeForm && !explicitSkip ? safety.scrubDetailed(raw.answer, `answer[${idx}]`) : null;
2501
+ const overLength = isFreeForm && !explicitSkip && !(answerScrub?.leaked ?? false) && (answerScrub?.text.length ?? 0) > maxFreeFormAnswerLength;
2502
+ if (overLength) {
2503
+ console.warn(
2504
+ `[@mux/ai] Free-form answer at index ${idx} exceeded ${maxFreeFormAnswerLength}-char cap (${answerScrub.text.length} chars); treating as skipped.`
2387
2505
  );
2388
2506
  }
2507
+ const isSkipped = explicitSkip || reasoningScrub.leaked || (answerScrub?.leaked ?? false) || overLength;
2508
+ if (!isSkipped && !isFreeForm) {
2509
+ const allowed = allowedAnswersForQuestion(question);
2510
+ if (!allowed.includes(raw.answer)) {
2511
+ throw new MuxAiError(
2512
+ `Answer "${raw.answer}" for question ${idx} is not in allowed options: ${allowed.join(", ")}`
2513
+ );
2514
+ }
2515
+ }
2516
+ let finalAnswer;
2517
+ if (isSkipped) {
2518
+ finalAnswer = null;
2519
+ } else if (isFreeForm) {
2520
+ finalAnswer = answerScrub ? answerScrub.text : raw.answer;
2521
+ } else {
2522
+ finalAnswer = raw.answer;
2523
+ }
2524
+ const suppressed = reasoningScrub.leaked || (answerScrub?.leaked ?? false);
2389
2525
  return {
2390
2526
  // Use the trusted input verbatim rather than the model's echo.
2391
- question: normalizedQuestions[idx].question,
2527
+ question: question.question,
2392
2528
  confidence: isSkipped ? 0 : Math.min(1, Math.max(0, raw.confidence)),
2393
- reasoning: scrub.leaked ? "Response suppressed by safety filter." : scrub.text,
2529
+ reasoning: suppressed ? "Response suppressed by safety filter." : reasoningScrub.text,
2394
2530
  skipped: isSkipped,
2395
- answer: isSkipped ? null : raw.answer
2531
+ answer: finalAnswer
2396
2532
  };
2397
2533
  });
2398
2534
  return {
@@ -4727,6 +4863,15 @@ var OPENAI_MODERATION_BASE_DELAY_MS = 750;
4727
4863
  var OPENAI_MODERATION_MAX_DELAY_MS = 3e3;
4728
4864
  var MIN_SAMPLE_COVERAGE_FOR_CONFIDENT_THRESHOLDING = 0.5;
4729
4865
  var MIN_SUCCESSFUL_THUMBNAILS_FOR_CONFIDENT_THRESHOLDING = 3;
4866
+ var GOOGLE_VISION_ENDPOINT = "https://vision.googleapis.com/v1/images:annotate";
4867
+ var GOOGLE_VISION_LIKELIHOOD_TO_SCORE = {
4868
+ UNKNOWN: 0,
4869
+ VERY_UNLIKELY: 0.2,
4870
+ UNLIKELY: 0.4,
4871
+ POSSIBLE: 0.6,
4872
+ LIKELY: 0.8,
4873
+ VERY_LIKELY: 1
4874
+ };
4730
4875
  var HIVE_ENDPOINT = "https://api.thehive.ai/api/v2/task/sync";
4731
4876
  var HIVE_SEXUAL_CATEGORIES = [
4732
4877
  "general_nsfw",
@@ -5055,6 +5200,98 @@ async function requestHiveModeration(images, maxConcurrent = 5, submissionMode =
5055
5200
  }));
5056
5201
  return await processConcurrently(targets, moderateImageWithHive, maxConcurrent);
5057
5202
  }
5203
+ async function moderateImageWithGoogleVision(entry) {
5204
+ "use step";
5205
+ try {
5206
+ const apiKey = await getApiKeyFromEnv("google-vision-api", entry.credentials);
5207
+ const imageField = entry.isBase64 ? { content: entry.image } : { source: { imageUri: entry.image } };
5208
+ const controller = new AbortController();
5209
+ const timeout = setTimeout(() => controller.abort(), 15e3);
5210
+ let res;
5211
+ try {
5212
+ res = await fetch(GOOGLE_VISION_ENDPOINT, {
5213
+ method: "POST",
5214
+ headers: {
5215
+ "Accept": "application/json",
5216
+ "Content-Type": "application/json",
5217
+ "X-goog-api-key": apiKey
5218
+ },
5219
+ body: JSON.stringify({
5220
+ requests: [
5221
+ {
5222
+ image: imageField,
5223
+ features: [{ type: "SAFE_SEARCH_DETECTION" }]
5224
+ }
5225
+ ]
5226
+ }),
5227
+ signal: controller.signal
5228
+ });
5229
+ } catch (err) {
5230
+ if (err?.name === "AbortError") {
5231
+ throw new Error("Google Vision request timed out after 15s");
5232
+ }
5233
+ throw err;
5234
+ } finally {
5235
+ clearTimeout(timeout);
5236
+ }
5237
+ const json = await res.json().catch(() => void 0);
5238
+ if (!res.ok) {
5239
+ throw new Error(
5240
+ `Google Vision moderation error: ${res.status} ${res.statusText} - ${JSON.stringify(json)}`
5241
+ );
5242
+ }
5243
+ const perImage = json?.responses?.[0];
5244
+ if (perImage?.error) {
5245
+ throw new Error(
5246
+ `Google Vision per-image error: ${perImage.error.code} ${perImage.error.message || "Unknown error"}`
5247
+ );
5248
+ }
5249
+ const annotation = perImage?.safeSearchAnnotation;
5250
+ if (!annotation) {
5251
+ throw new Error(`Google Vision response missing safeSearchAnnotation: ${JSON.stringify(json)}`);
5252
+ }
5253
+ const adultLikelihood = typeof annotation.adult === "string" ? annotation.adult : "UNKNOWN";
5254
+ const violenceLikelihood = typeof annotation.violence === "string" ? annotation.violence : "UNKNOWN";
5255
+ const sexual = GOOGLE_VISION_LIKELIHOOD_TO_SCORE[adultLikelihood] ?? 0;
5256
+ const violence = GOOGLE_VISION_LIKELIHOOD_TO_SCORE[violenceLikelihood] ?? 0;
5257
+ return {
5258
+ url: entry.url,
5259
+ time: entry.time,
5260
+ sexual,
5261
+ violence,
5262
+ error: false
5263
+ };
5264
+ } catch (error) {
5265
+ return {
5266
+ url: entry.url,
5267
+ time: entry.time,
5268
+ sexual: 0,
5269
+ violence: 0,
5270
+ error: true,
5271
+ errorMessage: error instanceof Error ? error.message : String(error)
5272
+ };
5273
+ }
5274
+ }
5275
+ async function requestGoogleVisionModeration(images, maxConcurrent = 5, submissionMode = "url", downloadOptions, credentials) {
5276
+ "use step";
5277
+ const imageUrls = images.map((img) => img.url);
5278
+ const timeByUrl = new Map(images.map((img) => [img.url, img.time]));
5279
+ const targets = submissionMode === "base64" ? (await downloadImagesAsBase64(imageUrls, downloadOptions, maxConcurrent)).map((img) => ({
5280
+ url: img.url,
5281
+ time: timeByUrl.get(img.url),
5282
+ // Vision REST wants raw base64; downloadImagesAsBase64 returns a data URI.
5283
+ image: img.base64Data.startsWith("data:") ? img.base64Data.split(",")[1] : img.base64Data,
5284
+ isBase64: true,
5285
+ credentials
5286
+ })) : images.map((img) => ({
5287
+ url: img.url,
5288
+ time: img.time,
5289
+ image: img.url,
5290
+ isBase64: false,
5291
+ credentials
5292
+ }));
5293
+ return processConcurrently(targets, moderateImageWithGoogleVision, maxConcurrent);
5294
+ }
5058
5295
  async function getThumbnailUrlsFromTimestamps(playbackId, timestampsMs, options) {
5059
5296
  "use step";
5060
5297
  const { width, shouldSign, credentials } = options;
@@ -5118,8 +5355,11 @@ async function getModerationScores(assetId, options = {}) {
5118
5355
  );
5119
5356
  } else if (provider === "hive") {
5120
5357
  throw new Error("Hive does not support transcript moderation in this workflow. Use provider: 'openai' for audio-only assets.");
5358
+ } else if (provider === "google-vision-api") {
5359
+ throw new Error("google-vision-api is image-only and does not support transcript moderation. Use provider: 'openai' for audio-only assets.");
5121
5360
  } else {
5122
- throw new Error(`Unsupported moderation provider: ${provider}`);
5361
+ const exhaustiveCheck = provider;
5362
+ throw new Error(`Unsupported moderation provider: ${exhaustiveCheck}`);
5123
5363
  }
5124
5364
  } else {
5125
5365
  const estimatedIntervalCount = duration <= 50 ? 5 : Math.ceil(duration / thumbnailInterval);
@@ -5162,8 +5402,17 @@ async function getModerationScores(assetId, options = {}) {
5162
5402
  imageDownloadOptions,
5163
5403
  credentials
5164
5404
  );
5405
+ } else if (provider === "google-vision-api") {
5406
+ thumbnailScores = await requestGoogleVisionModeration(
5407
+ thumbnailUrls,
5408
+ maxConcurrent,
5409
+ imageSubmissionMode,
5410
+ imageDownloadOptions,
5411
+ credentials
5412
+ );
5165
5413
  } else {
5166
- throw new Error(`Unsupported moderation provider: ${provider}`);
5414
+ const exhaustiveCheck = provider;
5415
+ throw new Error(`Unsupported moderation provider: ${exhaustiveCheck}`);
5167
5416
  }
5168
5417
  }
5169
5418
  const failed = thumbnailScores.filter((s) => s.error);
@@ -6867,6 +7116,7 @@ export {
6867
7116
  DEFAULT_DESCRIPTION_LENGTH,
6868
7117
  DEFAULT_SUMMARY_KEYWORD_LIMIT,
6869
7118
  DEFAULT_TITLE_LENGTH,
7119
+ GOOGLE_VISION_LIKELIHOOD_TO_SCORE,
6870
7120
  HIVE_SEXUAL_CATEGORIES,
6871
7121
  HIVE_VIOLENCE_CATEGORIES,
6872
7122
  aggregateTokenUsage,