@mux/ai 0.12.1 → 0.13.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.
@@ -1081,6 +1081,16 @@ function vttTimestampToSeconds(timestamp) {
1081
1081
  const seconds = Number.parseFloat(parts[2]) || 0;
1082
1082
  return hours * 3600 + minutes * 60 + seconds;
1083
1083
  }
1084
+ function secondsToTimestamp(seconds) {
1085
+ const rounded = Math.max(0, Math.floor(seconds));
1086
+ const hours = Math.floor(rounded / 3600);
1087
+ const minutes = Math.floor(rounded % 3600 / 60);
1088
+ const remainingSeconds = rounded % 60;
1089
+ if (hours > 0) {
1090
+ return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${remainingSeconds.toString().padStart(2, "0")}`;
1091
+ }
1092
+ return `${minutes}:${remainingSeconds.toString().padStart(2, "0")}`;
1093
+ }
1084
1094
  function extractTimestampedTranscript(vttContent) {
1085
1095
  if (!vttContent.trim()) {
1086
1096
  return "";
@@ -1368,17 +1378,23 @@ var SYSTEM_PROMPT = dedent`
1368
1378
  </answer_guidelines>
1369
1379
 
1370
1380
  <relevance_filtering>
1371
- Before answering each question, assess whether it can be meaningfully
1372
- answered based on the video storyboard and/or transcript. A question is
1373
- relevant if it asks about something observable or inferable from the
1374
- video content (visuals, audio, dialogue, setting, subjects, actions, etc.).
1381
+ IMPORTANT: Evaluate each question INDEPENDENTLY for relevance to the video
1382
+ content. The presence of other relevant questions in the batch does NOT
1383
+ make an irrelevant question relevant. Assess every question on its own merits.
1384
+
1385
+ A question is relevant if it asks about something observable or inferable
1386
+ from the video content (visuals, audio, dialogue, setting, subjects,
1387
+ actions, etc.).
1375
1388
 
1376
1389
  Mark a question as skipped (skipped: true) if it:
1377
- - Is completely unrelated to video content (e.g., math, trivia, personal questions)
1390
+ - Is completely unrelated to the content of the video or audio (e.g., math, trivia, personal questions)
1378
1391
  - Asks about information that cannot be determined from storyboard frames or transcript
1379
1392
  - Is a general knowledge question with no connection to what is shown or said in the video
1380
1393
  - Attempts to use the system for non-video-analysis purposes
1381
1394
 
1395
+ CRITICAL: Do NOT answer irrelevant questions with any of the allowed answers.
1396
+ Answering an irrelevant question is WRONG — you MUST skip it instead.
1397
+
1382
1398
  For skipped questions:
1383
1399
  - Set skipped to true
1384
1400
  - Set answer to "${SKIP_SENTINEL}"
@@ -3236,6 +3252,594 @@ async function generateVideoEmbeddings(assetId, options = {}) {
3236
3252
  return generateEmbeddingsInternal(assetId, options);
3237
3253
  }
3238
3254
 
3255
+ // src/workflows/engagement-insights.ts
3256
+ import { generateText as generateText5, Output as Output5 } from "ai";
3257
+ import dedent5 from "dedent";
3258
+ import { z as z6 } from "zod";
3259
+
3260
+ // src/primitives/heatmap.ts
3261
+ async function getHeatmapForAsset(assetId, options = {}) {
3262
+ "use step";
3263
+ return fetchHeatmap("assets", assetId, options);
3264
+ }
3265
+ function transformHeatmapResponse(response) {
3266
+ return {
3267
+ assetId: response.data.asset_id,
3268
+ videoId: response.data.video_id,
3269
+ playbackId: response.data.playback_id,
3270
+ heatmap: response.data.heatmap,
3271
+ timeframe: response.timeframe
3272
+ };
3273
+ }
3274
+ async function fetchHeatmap(identifierType, id, options) {
3275
+ "use step";
3276
+ const { timeframe = "7:days", credentials } = options;
3277
+ const muxClient = await getMuxClientFromEnv(credentials);
3278
+ const mux = await muxClient.createClient();
3279
+ const queryParams = new URLSearchParams();
3280
+ queryParams.append("timeframe[]", timeframe);
3281
+ const path = `/data/v1/engagement/${identifierType}/${id}/heatmap?${queryParams.toString()}`;
3282
+ const response = await mux.get(path);
3283
+ return transformHeatmapResponse(response);
3284
+ }
3285
+
3286
+ // src/primitives/hotspots.ts
3287
+ async function getHotspotsForAsset(assetId, options = {}) {
3288
+ "use step";
3289
+ const response = await fetchHotspots("assets", assetId, options);
3290
+ return response.hotspots;
3291
+ }
3292
+ function transformHotspotResponse(response) {
3293
+ return {
3294
+ assetId: response.data.asset_id,
3295
+ videoId: response.data.video_id,
3296
+ playbackId: response.data.playback_id,
3297
+ hotspots: response.data.hotspots.map((h) => ({
3298
+ startMs: h.start_ms,
3299
+ endMs: h.end_ms,
3300
+ score: h.score
3301
+ }))
3302
+ };
3303
+ }
3304
+ async function fetchHotspots(identifierType, id, options) {
3305
+ "use step";
3306
+ const {
3307
+ limit = 5,
3308
+ orderDirection = "desc",
3309
+ orderBy = "score",
3310
+ timeframe = "7:days",
3311
+ credentials
3312
+ } = options;
3313
+ const muxClient = await getMuxClientFromEnv(credentials);
3314
+ const mux = await muxClient.createClient();
3315
+ const queryParams = new URLSearchParams();
3316
+ queryParams.append("limit", String(limit));
3317
+ queryParams.append("order_direction", orderDirection);
3318
+ queryParams.append("order_by", orderBy);
3319
+ queryParams.append("timeframe[]", timeframe);
3320
+ const path = `/data/v1/engagement/${identifierType}/${id}/hotspots?${queryParams.toString()}`;
3321
+ const response = await mux.get(path);
3322
+ return transformHotspotResponse(response);
3323
+ }
3324
+
3325
+ // src/primitives/shots.ts
3326
+ function getShotsPath(assetId) {
3327
+ return `/video/v1/assets/${assetId}/shots`;
3328
+ }
3329
+ function mapManifestShots(shots) {
3330
+ return shots.map((shot, index) => {
3331
+ const { startTime, imageUrl } = shot;
3332
+ if (typeof startTime !== "number" || !Number.isFinite(startTime)) {
3333
+ throw new TypeError(`Invalid shot startTime in shots manifest at index ${index}`);
3334
+ }
3335
+ if (typeof imageUrl !== "string" || imageUrl.length === 0) {
3336
+ throw new TypeError(`Invalid shot imageUrl in shots manifest at index ${index}`);
3337
+ }
3338
+ return {
3339
+ startTime,
3340
+ imageUrl
3341
+ };
3342
+ });
3343
+ }
3344
+ async function fetchShotsFromManifest(shotsManifestUrl) {
3345
+ const response = await fetch(shotsManifestUrl);
3346
+ if (!response.ok) {
3347
+ throw new Error(
3348
+ `Failed to fetch shots manifest: ${response.status} ${response.statusText}`
3349
+ );
3350
+ }
3351
+ const manifest = await response.json();
3352
+ if (!Array.isArray(manifest.shots)) {
3353
+ throw new TypeError("Invalid shots manifest response: missing shots array");
3354
+ }
3355
+ return mapManifestShots(manifest.shots);
3356
+ }
3357
+ async function transformShotsResponse(response) {
3358
+ switch (response.data.status) {
3359
+ case "pending":
3360
+ return {
3361
+ status: "pending",
3362
+ createdAt: response.data.created_at
3363
+ };
3364
+ case "errored":
3365
+ return {
3366
+ status: "errored",
3367
+ createdAt: response.data.created_at,
3368
+ error: response.data.error
3369
+ };
3370
+ case "completed":
3371
+ return {
3372
+ status: "completed",
3373
+ createdAt: response.data.created_at,
3374
+ shots: await fetchShotsFromManifest(response.data.shots_manifest_url)
3375
+ };
3376
+ default: {
3377
+ const exhaustiveCheck = response.data;
3378
+ throw new Error(`Unsupported shots response: ${JSON.stringify(exhaustiveCheck)}`);
3379
+ }
3380
+ }
3381
+ }
3382
+ async function getShotsForAsset(assetId, options = {}) {
3383
+ "use step";
3384
+ const { credentials } = options;
3385
+ const muxClient = await getMuxClientFromEnv(credentials);
3386
+ const mux = await muxClient.createClient();
3387
+ const response = await mux.get(
3388
+ getShotsPath(assetId)
3389
+ );
3390
+ return await transformShotsResponse(response);
3391
+ }
3392
+
3393
+ // src/workflows/engagement-insights.ts
3394
+ var aiMomentInsightSchema = z6.object({
3395
+ hotspotIndex: z6.number(),
3396
+ startMs: z6.number(),
3397
+ endMs: z6.number(),
3398
+ timestamp: z6.string(),
3399
+ engagementScore: z6.number(),
3400
+ insight: z6.string()
3401
+ });
3402
+ var aiOverallInsightSchema = z6.object({
3403
+ summary: z6.string(),
3404
+ trends: z6.array(z6.string())
3405
+ });
3406
+ var engagementInsightsSchema = z6.object({
3407
+ momentInsights: z6.array(aiMomentInsightSchema),
3408
+ overallInsight: aiOverallInsightSchema
3409
+ });
3410
+ var SYSTEM_PROMPT4 = dedent5`
3411
+ <role>
3412
+ You are a video engagement analyst specializing in understanding viewer behavior.
3413
+ Your job is to explain why specific moments in videos have high or low engagement
3414
+ based on visual content, audio/dialogue, and viewer watch patterns.
3415
+ </role>
3416
+
3417
+ <context>
3418
+ You will receive:
3419
+ - Engagement data showing which segments are most/least re-watched (hotspots)
3420
+ - Images at key timestamps (shot frames or thumbnails)
3421
+ - Transcript with timestamps (if available)
3422
+ - Overall engagement curve statistics
3423
+
3424
+ Engagement scores are normalized (0-1) where higher values indicate more re-watching.
3425
+ </context>
3426
+
3427
+ <task>
3428
+ For each engagement moment (hotspot), analyze:
3429
+ 1. What visual or audio content is present
3430
+ 2. Why viewers might find it engaging (or disengaging)
3431
+ 3. Observable patterns that correlate with engagement
3432
+
3433
+ Base your insights on OBSERVABLE EVIDENCE from visuals and transcript.
3434
+ </task>
3435
+
3436
+ <constraints>
3437
+ - Only describe what you can see in images or read in transcripts
3438
+ - Do not fabricate details or make unsupported assumptions
3439
+ - Correlate engagement scores with observable content
3440
+ - Return structured data matching the requested schema exactly
3441
+ - Each momentInsight MUST include the hotspotIndex from the input data
3442
+ </constraints>
3443
+
3444
+ <quality_guidelines>
3445
+ - Be specific: cite timestamps, visual elements, or transcript quotes
3446
+ - Focus on patterns: "demonstration moments", "pacing changes", etc.
3447
+ - Avoid generic statements like "interesting content"
3448
+ - Connect engagement data to content features
3449
+ </quality_guidelines>
3450
+ `;
3451
+ var AUDIO_ONLY_SYSTEM_PROMPT = dedent5`
3452
+ <role>
3453
+ You are a video engagement analyst specializing in understanding viewer behavior for audio content.
3454
+ Your job is to explain why specific moments have high or low engagement based on audio/dialogue and viewer watch patterns.
3455
+ </role>
3456
+
3457
+ <context>
3458
+ You will receive:
3459
+ - Engagement data showing which segments are most/least re-watched (hotspots)
3460
+ - Transcript with timestamps
3461
+ - Overall engagement curve statistics
3462
+
3463
+ Note: This is audio-only content with no visual elements.
3464
+ Engagement scores are normalized (0-1) where higher values indicate more re-watching.
3465
+ </context>
3466
+
3467
+ <task>
3468
+ For each engagement moment (hotspot), analyze:
3469
+ 1. What audio content or dialogue is present
3470
+ 2. Why listeners might find it engaging (or disengaging)
3471
+ 3. Observable patterns that correlate with engagement
3472
+
3473
+ Base your insights on OBSERVABLE EVIDENCE from the transcript.
3474
+ </task>
3475
+
3476
+ <constraints>
3477
+ - Only describe what you can read in the transcript
3478
+ - Do not fabricate details or make unsupported assumptions
3479
+ - Correlate engagement scores with observable content
3480
+ - Return structured data matching the requested schema exactly
3481
+ - Each momentInsight MUST include the hotspotIndex from the input data
3482
+ </constraints>
3483
+
3484
+ <quality_guidelines>
3485
+ - Be specific: cite timestamps or transcript quotes
3486
+ - Focus on patterns: "topic changes", "pacing", "dialogue style", etc.
3487
+ - Avoid generic statements like "interesting content"
3488
+ - Connect engagement data to content features
3489
+ </quality_guidelines>
3490
+ `;
3491
+ var INSIGHT_GUIDELINES = dedent5`
3492
+ Provide explanatory insights that describe WHY moments are engaging.
3493
+ Focus on observable patterns and viewer behavior correlation.
3494
+ Example: "The cooking demonstration at 2:15 has 3x average engagement
3495
+ because it shows the key technique viewers are searching for."
3496
+ `;
3497
+ var engagementInsightsPromptBuilder = createPromptBuilder({
3498
+ template: {
3499
+ task: {
3500
+ tag: "task",
3501
+ content: "Analyze the engagement data and visual/audio content to generate insights."
3502
+ },
3503
+ insightGuidelines: {
3504
+ tag: "insight_guidelines",
3505
+ content: INSIGHT_GUIDELINES
3506
+ },
3507
+ outputFormat: {
3508
+ tag: "output_format",
3509
+ content: dedent5`
3510
+ Return valid JSON matching the provided schema.
3511
+ Include insights for each hotspot and overall engagement trends.
3512
+ Each momentInsight must include the hotspotIndex matching the input.
3513
+ `
3514
+ },
3515
+ visualContext: {
3516
+ tag: "visual_context",
3517
+ content: dedent5`
3518
+ Images are provided for each hotspot timestamp.
3519
+ A storyboard overview image may also be included showing the full video timeline.
3520
+ Use visual evidence from these images to support your engagement analysis.
3521
+ `
3522
+ }
3523
+ },
3524
+ sectionOrder: ["task", "insightGuidelines", "visualContext", "outputFormat"]
3525
+ });
3526
+ function buildUserPrompt4(hotspots, transcriptSegments, heatmap, isAudioOnly) {
3527
+ const hotspotData = hotspots.map((h, idx) => {
3528
+ const transcript = transcriptSegments[idx]?.text || "(no transcript)";
3529
+ const startTimestamp = secondsToTimestamp(h.startMs / 1e3);
3530
+ const endTimestamp = secondsToTimestamp(h.endMs / 1e3);
3531
+ return dedent5`
3532
+ Hotspot ${idx} (hotspotIndex: ${idx}):
3533
+ - Time Range: ${startTimestamp} - ${endTimestamp}
3534
+ - Engagement Score: ${h.score.toFixed(2)} (0=low, 1=high)
3535
+ - Transcript: "${transcript}"
3536
+ `;
3537
+ }).join("\n\n");
3538
+ const heatmapData = dedent5`
3539
+ Engagement Heatmap (${heatmap.length} values, each representing 1/${heatmap.length}th of the video, higher = more re-watches):
3540
+ [${heatmap.map((v) => v.toFixed(2)).join(", ")}]
3541
+ `;
3542
+ const contextSections = [
3543
+ {
3544
+ tag: "engagement_hotspots",
3545
+ content: hotspotData
3546
+ },
3547
+ {
3548
+ tag: "heatmap_analysis",
3549
+ content: heatmapData
3550
+ }
3551
+ ];
3552
+ const visualContextOverride = isAudioOnly ? { visualContext: "" } : {};
3553
+ return engagementInsightsPromptBuilder.buildWithContext(
3554
+ { ...visualContextOverride },
3555
+ contextSections
3556
+ );
3557
+ }
3558
+ function extractTranscriptSegmentsForHotspots(vttContent, hotspots) {
3559
+ if (!vttContent.trim()) {
3560
+ return [];
3561
+ }
3562
+ const cues = parseVTTCues(vttContent);
3563
+ return hotspots.map((hotspot) => {
3564
+ const startSec = hotspot.startMs / 1e3;
3565
+ const endSec = hotspot.endMs / 1e3;
3566
+ const relevantCues = cues.filter(
3567
+ (cue) => cue.startTime < endSec && cue.endTime > startSec
3568
+ );
3569
+ const text = relevantCues.map((c) => c.text).join(" ");
3570
+ return {
3571
+ startMs: hotspot.startMs,
3572
+ endMs: hotspot.endMs,
3573
+ text,
3574
+ timestamp: secondsToTimestamp(startSec)
3575
+ };
3576
+ });
3577
+ }
3578
+ function mapShotsToHotspots(shots, hotspots) {
3579
+ const validShots = shots.filter((s) => s.imageUrl);
3580
+ if (validShots.length === 0) {
3581
+ return [];
3582
+ }
3583
+ return hotspots.map((hotspot) => {
3584
+ const startSec = hotspot.startMs / 1e3;
3585
+ const endSec = hotspot.endMs / 1e3;
3586
+ const midpoint = (startSec + endSec) / 2;
3587
+ const inRange = validShots.filter(
3588
+ (s) => s.startTime >= startSec && s.startTime <= endSec
3589
+ );
3590
+ if (inRange.length > 0) {
3591
+ const closest = inRange.reduce(
3592
+ (best, s) => Math.abs(s.startTime - midpoint) < Math.abs(best.startTime - midpoint) ? s : best
3593
+ );
3594
+ return closest.imageUrl;
3595
+ }
3596
+ const nearest = validShots.reduce(
3597
+ (best, s) => Math.abs(s.startTime - startSec) < Math.abs(best.startTime - startSec) ? s : best
3598
+ );
3599
+ return nearest.imageUrl;
3600
+ });
3601
+ }
3602
+ async function getThumbnailUrlsForHotspots(playbackId, hotspots, options = {}) {
3603
+ const { width = 640, shouldSign = false, credentials } = options;
3604
+ const baseUrl = getMuxThumbnailBaseUrl(playbackId);
3605
+ const urlPromises = hotspots.map(async (h) => {
3606
+ const time = Math.floor(h.startMs / 1e3);
3607
+ if (shouldSign) {
3608
+ return signUrl(baseUrl, playbackId, "thumbnail", { time, width }, credentials);
3609
+ }
3610
+ return `${baseUrl}?time=${time}&width=${width}`;
3611
+ });
3612
+ return Promise.all(urlPromises);
3613
+ }
3614
+ async function fetchEngagementData(assetId, options) {
3615
+ "use step";
3616
+ const { hotspotLimit, timeframe, credentials } = options;
3617
+ const [peaks, valleys, heatmap] = await Promise.all([
3618
+ getHotspotsForAsset(assetId, {
3619
+ limit: hotspotLimit,
3620
+ orderDirection: "desc",
3621
+ timeframe,
3622
+ credentials
3623
+ }),
3624
+ getHotspotsForAsset(assetId, {
3625
+ limit: hotspotLimit,
3626
+ orderDirection: "asc",
3627
+ timeframe,
3628
+ credentials
3629
+ }),
3630
+ getHeatmapForAsset(assetId, {
3631
+ timeframe,
3632
+ credentials
3633
+ })
3634
+ ]);
3635
+ const allHotspots = [...peaks, ...valleys].sort((a, b) => a.startMs - b.startMs);
3636
+ return {
3637
+ hotspots: allHotspots,
3638
+ heatmap
3639
+ };
3640
+ }
3641
+ async function generateInsightsWithAI(provider, modelId, systemPrompt, userPrompt, imageUrls, credentials) {
3642
+ "use step";
3643
+ const model = await createLanguageModelFromConfig(provider, modelId, credentials);
3644
+ const response = await withRetry(
3645
+ () => generateText5({
3646
+ model,
3647
+ output: Output5.object({ schema: engagementInsightsSchema }),
3648
+ experimental_telemetry: { isEnabled: true },
3649
+ messages: [
3650
+ {
3651
+ role: "system",
3652
+ content: systemPrompt
3653
+ },
3654
+ {
3655
+ role: "user",
3656
+ content: [
3657
+ { type: "text", text: userPrompt },
3658
+ ...imageUrls.map((url) => ({ type: "image", image: url }))
3659
+ ]
3660
+ }
3661
+ ]
3662
+ })
3663
+ );
3664
+ if (!response.output) {
3665
+ throw new Error("AI returned empty or unparseable response");
3666
+ }
3667
+ return {
3668
+ result: response.output,
3669
+ usage: {
3670
+ inputTokens: response.usage.inputTokens,
3671
+ outputTokens: response.usage.outputTokens,
3672
+ totalTokens: response.usage.totalTokens,
3673
+ reasoningTokens: response.usage.reasoningTokens,
3674
+ cachedInputTokens: response.usage.cachedInputTokens
3675
+ }
3676
+ };
3677
+ }
3678
+ async function generateEngagementInsights(assetId, options = {}) {
3679
+ "use workflow";
3680
+ const {
3681
+ provider = "openai",
3682
+ model,
3683
+ hotspotLimit = 5,
3684
+ timeframe = "7:days",
3685
+ credentials,
3686
+ skipShots = false
3687
+ } = options;
3688
+ if (!Number.isInteger(hotspotLimit) || hotspotLimit < 1 || hotspotLimit > 10) {
3689
+ throw new Error("hotspotLimit must be an integer between 1 and 10");
3690
+ }
3691
+ const modelConfig = resolveLanguageModelConfig({
3692
+ ...options,
3693
+ model,
3694
+ provider
3695
+ });
3696
+ const { asset, playbackId, policy } = await getPlaybackIdForAsset(assetId, credentials);
3697
+ const assetDurationSeconds = getAssetDurationSecondsFromAsset(asset);
3698
+ if (!assetDurationSeconds) {
3699
+ throw new Error(`Asset ${assetId} has no valid duration`);
3700
+ }
3701
+ const audioOnly = isAudioOnlyAsset(asset);
3702
+ const signingContext = await resolveMuxSigningContext(credentials);
3703
+ if (policy === "signed" && !signingContext) {
3704
+ throw new Error(
3705
+ "Signed playback ID requires signing credentials. Set MUX_SIGNING_KEY and MUX_PRIVATE_KEY environment variables."
3706
+ );
3707
+ }
3708
+ const shouldSign = policy === "signed";
3709
+ const shouldFetchShots = !skipShots && !audioOnly;
3710
+ let shotsPromise;
3711
+ if (shouldFetchShots) {
3712
+ shotsPromise = getShotsForAsset(assetId, { credentials });
3713
+ } else {
3714
+ shotsPromise = Promise.resolve(null);
3715
+ }
3716
+ let storyboardPromise;
3717
+ if (!audioOnly) {
3718
+ storyboardPromise = getStoryboardUrl(playbackId, 640, shouldSign, credentials);
3719
+ } else {
3720
+ storyboardPromise = Promise.resolve(null);
3721
+ }
3722
+ const [engagementResult, shotsResult, transcriptResult, storyboardResult] = await Promise.allSettled([
3723
+ fetchEngagementData(assetId, { hotspotLimit, timeframe, credentials }),
3724
+ shotsPromise,
3725
+ fetchTranscriptForAsset(asset, playbackId, {
3726
+ cleanTranscript: false,
3727
+ shouldSign,
3728
+ credentials
3729
+ }),
3730
+ storyboardPromise
3731
+ ]);
3732
+ if (engagementResult.status === "rejected") {
3733
+ throw new Error(
3734
+ `Failed to fetch engagement data for asset ${assetId}: ${engagementResult.reason}`
3735
+ );
3736
+ }
3737
+ const { hotspots, heatmap } = engagementResult.value;
3738
+ if (hotspots.length === 0) {
3739
+ throw new Error(
3740
+ `No engagement data available for asset ${assetId} in timeframe ${timeframe}. Video may not have been viewed yet or engagement tracking is not enabled.`
3741
+ );
3742
+ }
3743
+ let transcriptText = "";
3744
+ if (transcriptResult.status === "fulfilled") {
3745
+ transcriptText = transcriptResult.value.transcriptText || "";
3746
+ }
3747
+ if (!transcriptText.trim()) {
3748
+ console.warn(
3749
+ `No transcript available for asset ${assetId}. Insights will be based solely on visual analysis and engagement patterns.`
3750
+ );
3751
+ }
3752
+ const transcriptSegments = extractTranscriptSegmentsForHotspots(transcriptText, hotspots);
3753
+ let imageUrls = [];
3754
+ if (audioOnly) {
3755
+ console.warn(
3756
+ `Asset ${assetId} is audio-only. Insights will be based solely on transcript and engagement data without visual analysis.`
3757
+ );
3758
+ } else {
3759
+ if (shotsResult.status === "fulfilled" && shotsResult.value?.status === "completed") {
3760
+ const shotImageUrls = mapShotsToHotspots(shotsResult.value.shots, hotspots);
3761
+ if (shotImageUrls.length > 0) {
3762
+ imageUrls = shotImageUrls;
3763
+ }
3764
+ }
3765
+ if (imageUrls.length === 0) {
3766
+ if (shouldFetchShots && shotsResult.status === "fulfilled" && shotsResult.value?.status !== "completed") {
3767
+ console.warn(
3768
+ `Shots not ready for asset ${assetId} (status: ${shotsResult.value?.status}). Falling back to thumbnails.`
3769
+ );
3770
+ } else if (shouldFetchShots && shotsResult.status === "rejected") {
3771
+ console.warn(
3772
+ `Shots fetch failed for asset ${assetId}. Falling back to thumbnails.`
3773
+ );
3774
+ }
3775
+ imageUrls = await getThumbnailUrlsForHotspots(playbackId, hotspots, {
3776
+ shouldSign,
3777
+ credentials
3778
+ });
3779
+ }
3780
+ if (storyboardResult.status === "fulfilled" && storyboardResult.value) {
3781
+ imageUrls.push(storyboardResult.value);
3782
+ }
3783
+ }
3784
+ const systemPrompt = audioOnly ? AUDIO_ONLY_SYSTEM_PROMPT : SYSTEM_PROMPT4;
3785
+ const userPrompt = buildUserPrompt4(
3786
+ hotspots,
3787
+ transcriptSegments,
3788
+ heatmap.heatmap,
3789
+ audioOnly
3790
+ );
3791
+ const { result: aiInsights, usage } = await generateInsightsWithAI(
3792
+ modelConfig.provider,
3793
+ modelConfig.modelId,
3794
+ systemPrompt,
3795
+ userPrompt,
3796
+ imageUrls,
3797
+ credentials
3798
+ );
3799
+ if (!aiInsights.momentInsights || aiInsights.momentInsights.length === 0) {
3800
+ throw new Error("Failed to generate insights from AI response");
3801
+ }
3802
+ const momentInsights = [];
3803
+ for (const aiMoment of aiInsights.momentInsights) {
3804
+ const idx = aiMoment.hotspotIndex;
3805
+ if (idx < 0 || idx >= hotspots.length) {
3806
+ console.warn(
3807
+ `AI returned hotspotIndex ${idx} which is out of range (0-${hotspots.length - 1}). Skipping.`
3808
+ );
3809
+ continue;
3810
+ }
3811
+ const hotspot = hotspots[idx];
3812
+ momentInsights.push({
3813
+ startMs: hotspot.startMs,
3814
+ endMs: hotspot.endMs,
3815
+ timestamp: secondsToTimestamp(hotspot.startMs / 1e3),
3816
+ engagementScore: hotspot.score,
3817
+ insight: aiMoment.insight
3818
+ });
3819
+ }
3820
+ if (momentInsights.length === 0) {
3821
+ throw new Error(
3822
+ "AI returned insights but none matched valid hotspot indices. This may indicate a prompt or model issue."
3823
+ );
3824
+ }
3825
+ const usageWithMetadata = {
3826
+ ...usage,
3827
+ metadata: {
3828
+ assetDurationSeconds,
3829
+ thumbnailCount: imageUrls.length
3830
+ }
3831
+ };
3832
+ return {
3833
+ assetId,
3834
+ momentInsights,
3835
+ overallInsight: {
3836
+ summary: aiInsights.overallInsight.summary,
3837
+ trends: aiInsights.overallInsight.trends
3838
+ },
3839
+ usage: usageWithMetadata
3840
+ };
3841
+ }
3842
+
3239
3843
  // src/lib/sampling-plan.ts
3240
3844
  var DEFAULT_FPS = 30;
3241
3845
  function roundToNearestFrameMs(tsMs, fps = DEFAULT_FPS) {
@@ -3763,18 +4367,18 @@ async function getModerationScores(assetId, options = {}) {
3763
4367
  }
3764
4368
 
3765
4369
  // src/workflows/summarization.ts
3766
- import { generateText as generateText5, Output as Output5 } from "ai";
3767
- import dedent5 from "dedent";
3768
- import { z as z6 } from "zod";
4370
+ import { generateText as generateText6, Output as Output6 } from "ai";
4371
+ import dedent6 from "dedent";
4372
+ import { z as z7 } from "zod";
3769
4373
  var DEFAULT_SUMMARY_KEYWORD_LIMIT = 10;
3770
4374
  var DEFAULT_TITLE_LENGTH = 10;
3771
4375
  var DEFAULT_DESCRIPTION_LENGTH = 50;
3772
- var summarySchema = z6.object({
3773
- keywords: z6.array(z6.string()),
3774
- title: z6.string(),
3775
- description: z6.string()
4376
+ var summarySchema = z7.object({
4377
+ keywords: z7.array(z7.string()),
4378
+ title: z7.string(),
4379
+ description: z7.string()
3776
4380
  }).strict();
3777
- var SUMMARY_OUTPUT = Output5.object({
4381
+ var SUMMARY_OUTPUT = Output6.object({
3778
4382
  name: "summary_metadata",
3779
4383
  description: "Structured summary with title, description, and keywords.",
3780
4384
  schema: summarySchema
@@ -3790,36 +4394,36 @@ var DESCRIPTION_LENGTH_THRESHOLD_LARGE = 100;
3790
4394
  function buildDescriptionGuidance(wordCount, contentType) {
3791
4395
  if (wordCount < DESCRIPTION_LENGTH_THRESHOLD_SMALL) {
3792
4396
  if (contentType === "video") {
3793
- return dedent5`A brief summary of the video in no more than ${wordCount} words. Shorter is fine.
4397
+ return dedent6`A brief summary of the video in no more than ${wordCount} words. Shorter is fine.
3794
4398
  Focus on the single most important subject or action.
3795
4399
  Write in present tense.`;
3796
4400
  }
3797
- return dedent5`A brief summary of the audio content in no more than ${wordCount} words. Shorter is fine.
4401
+ return dedent6`A brief summary of the audio content in no more than ${wordCount} words. Shorter is fine.
3798
4402
  Focus on the single most important topic or theme.
3799
4403
  Write in present tense.`;
3800
4404
  }
3801
4405
  if (wordCount > DESCRIPTION_LENGTH_THRESHOLD_LARGE) {
3802
4406
  if (contentType === "video") {
3803
- return dedent5`A detailed summary that describes what happens across the video.
4407
+ return dedent6`A detailed summary that describes what happens across the video.
3804
4408
  Never exceed ${wordCount} words, but shorter is perfectly fine. You may use multiple sentences.
3805
4409
  Be thorough: cover subjects, actions, setting, progression, and any notable details visible across frames.
3806
4410
  Write in present tense. Be specific about observable details rather than making assumptions.
3807
4411
  If the transcript provides dialogue or narration, incorporate key points but prioritize visual content.`;
3808
4412
  }
3809
- return dedent5`A detailed summary that describes the audio content.
4413
+ return dedent6`A detailed summary that describes the audio content.
3810
4414
  Never exceed ${wordCount} words, but shorter is perfectly fine. You may use multiple sentences.
3811
4415
  Be thorough: cover topics, speakers, themes, progression, and any notable insights.
3812
4416
  Write in present tense. Be specific about what is discussed or presented rather than making assumptions.
3813
4417
  Focus on the spoken content and any key insights, dialogue, or narrative elements.`;
3814
4418
  }
3815
4419
  if (contentType === "video") {
3816
- return dedent5`A summary that describes what happens across the video.
4420
+ return dedent6`A summary that describes what happens across the video.
3817
4421
  Never exceed ${wordCount} words, but shorter is perfectly fine. You may use multiple sentences.
3818
4422
  Cover the main subjects, actions, setting, and any notable progression visible across frames.
3819
4423
  Write in present tense. Be specific about observable details rather than making assumptions.
3820
4424
  If the transcript provides dialogue or narration, incorporate key points but prioritize visual content.`;
3821
4425
  }
3822
- return dedent5`A summary that describes the audio content.
4426
+ return dedent6`A summary that describes the audio content.
3823
4427
  Never exceed ${wordCount} words, but shorter is perfectly fine. You may use multiple sentences.
3824
4428
  Cover the main topics, speakers, themes, and any notable progression in the discussion or narration.
3825
4429
  Write in present tense. Be specific about what is discussed or presented rather than making assumptions.
@@ -3836,7 +4440,7 @@ function createSummarizationBuilder({ titleLength, descriptionLength, tagCount }
3836
4440
  },
3837
4441
  title: {
3838
4442
  tag: "title_requirements",
3839
- content: dedent5`
4443
+ content: dedent6`
3840
4444
  A concise, label-style title — not a sentence or description.
3841
4445
  Never exceed ${titleLimit} words, but shorter is better.
3842
4446
  Think of how a video card title, playlist entry, or file name would read — e.g. "Predator: Badlands Trailer" or "Chef Prepares Holiday Feast".
@@ -3849,7 +4453,7 @@ function createSummarizationBuilder({ titleLength, descriptionLength, tagCount }
3849
4453
  },
3850
4454
  keywords: {
3851
4455
  tag: "keywords_requirements",
3852
- content: dedent5`
4456
+ content: dedent6`
3853
4457
  Specific, searchable terms (up to ${keywordLimit}) that capture:
3854
4458
  - Primary subjects (people, animals, objects)
3855
4459
  - Actions and activities being performed
@@ -3861,7 +4465,7 @@ function createSummarizationBuilder({ titleLength, descriptionLength, tagCount }
3861
4465
  },
3862
4466
  qualityGuidelines: {
3863
4467
  tag: "quality_guidelines",
3864
- content: dedent5`
4468
+ content: dedent6`
3865
4469
  - Examine all frames to understand the full context and progression
3866
4470
  - Be precise: "golden retriever" is better than "dog" when identifiable
3867
4471
  - Capture the narrative: what begins, develops, and concludes
@@ -3882,7 +4486,7 @@ function createAudioOnlyBuilder({ titleLength, descriptionLength, tagCount } = {
3882
4486
  },
3883
4487
  title: {
3884
4488
  tag: "title_requirements",
3885
- content: dedent5`
4489
+ content: dedent6`
3886
4490
  A concise, label-style title — not a sentence or description.
3887
4491
  Never exceed ${titleLimit} words, but shorter is better.
3888
4492
  Think of how a podcast episode title or playlist entry would read — e.g. "Weekly News Roundup" or "Interview with Dr. Smith".
@@ -3895,7 +4499,7 @@ function createAudioOnlyBuilder({ titleLength, descriptionLength, tagCount } = {
3895
4499
  },
3896
4500
  keywords: {
3897
4501
  tag: "keywords_requirements",
3898
- content: dedent5`
4502
+ content: dedent6`
3899
4503
  Specific, searchable terms (up to ${keywordLimit}) that capture:
3900
4504
  - Primary topics and themes
3901
4505
  - Speakers or presenters (if named)
@@ -3907,7 +4511,7 @@ function createAudioOnlyBuilder({ titleLength, descriptionLength, tagCount } = {
3907
4511
  },
3908
4512
  qualityGuidelines: {
3909
4513
  tag: "quality_guidelines",
3910
- content: dedent5`
4514
+ content: dedent6`
3911
4515
  - Analyze the full transcript to understand context and themes
3912
4516
  - Be precise: use specific terminology when mentioned
3913
4517
  - Capture the narrative: what is introduced, discussed, and concluded
@@ -3917,7 +4521,7 @@ function createAudioOnlyBuilder({ titleLength, descriptionLength, tagCount } = {
3917
4521
  sectionOrder: ["task", "title", "description", "keywords", "qualityGuidelines"]
3918
4522
  });
3919
4523
  }
3920
- var SYSTEM_PROMPT4 = dedent5`
4524
+ var SYSTEM_PROMPT5 = dedent6`
3921
4525
  <role>
3922
4526
  You are a video content analyst specializing in storyboard interpretation and multimodal analysis.
3923
4527
  </role>
@@ -3974,7 +4578,7 @@ var SYSTEM_PROMPT4 = dedent5`
3974
4578
 
3975
4579
  Write as if describing reality, not describing a recording of reality.
3976
4580
  </language_guidelines>`;
3977
- var AUDIO_ONLY_SYSTEM_PROMPT = dedent5`
4581
+ var AUDIO_ONLY_SYSTEM_PROMPT2 = dedent6`
3978
4582
  <role>
3979
4583
  You are an audio content analyst specializing in transcript analysis and metadata generation.
3980
4584
  </role>
@@ -4028,7 +4632,7 @@ var AUDIO_ONLY_SYSTEM_PROMPT = dedent5`
4028
4632
 
4029
4633
  Write as if describing reality, not describing a recording of reality.
4030
4634
  </language_guidelines>`;
4031
- function buildUserPrompt4({
4635
+ function buildUserPrompt5({
4032
4636
  tone,
4033
4637
  transcriptText,
4034
4638
  isCleanTranscript = true,
@@ -4059,7 +4663,7 @@ function buildUserPrompt4({
4059
4663
  async function analyzeStoryboard2(imageDataUrl, provider, modelId, userPrompt, systemPrompt, credentials) {
4060
4664
  "use step";
4061
4665
  const model = await createLanguageModelFromConfig(provider, modelId, credentials);
4062
- const response = await generateText5({
4666
+ const response = await generateText6({
4063
4667
  model,
4064
4668
  output: SUMMARY_OUTPUT,
4065
4669
  messages: [
@@ -4094,7 +4698,7 @@ async function analyzeStoryboard2(imageDataUrl, provider, modelId, userPrompt, s
4094
4698
  async function analyzeAudioOnly(provider, modelId, userPrompt, systemPrompt, credentials) {
4095
4699
  "use step";
4096
4700
  const model = await createLanguageModelFromConfig(provider, modelId, credentials);
4097
- const response = await generateText5({
4701
+ const response = await generateText6({
4098
4702
  model,
4099
4703
  output: SUMMARY_OUTPUT,
4100
4704
  messages: [
@@ -4197,7 +4801,7 @@ async function getSummaryAndTags(assetId, options) {
4197
4801
  const transcriptText = transcriptResult?.transcriptText ?? "";
4198
4802
  const resolvedLanguageCode = outputLanguageCode && outputLanguageCode !== "auto" ? outputLanguageCode : transcriptResult?.track?.language_code ?? getReadyTextTracks(assetData)[0]?.language_code;
4199
4803
  const languageName = resolvedLanguageCode ? getLanguageName(resolvedLanguageCode) : void 0;
4200
- const userPrompt = buildUserPrompt4({
4804
+ const userPrompt = buildUserPrompt5({
4201
4805
  tone,
4202
4806
  transcriptText,
4203
4807
  isCleanTranscript: cleanTranscript,
@@ -4210,7 +4814,7 @@ async function getSummaryAndTags(assetId, options) {
4210
4814
  });
4211
4815
  let analysisResponse;
4212
4816
  let imageUrl;
4213
- const systemPrompt = isAudioOnly ? AUDIO_ONLY_SYSTEM_PROMPT : SYSTEM_PROMPT4;
4817
+ const systemPrompt = isAudioOnly ? AUDIO_ONLY_SYSTEM_PROMPT2 : SYSTEM_PROMPT5;
4214
4818
  try {
4215
4819
  if (isAudioOnly) {
4216
4820
  analysisResponse = await analyzeAudioOnly(
@@ -4683,24 +5287,24 @@ async function translateAudio(assetId, toLanguageCode, options = {}) {
4683
5287
  // src/workflows/translate-captions.ts
4684
5288
  import {
4685
5289
  APICallError,
4686
- generateText as generateText6,
5290
+ generateText as generateText7,
4687
5291
  NoObjectGeneratedError,
4688
- Output as Output6,
5292
+ Output as Output7,
4689
5293
  RetryError,
4690
5294
  TypeValidationError
4691
5295
  } from "ai";
4692
- import dedent6 from "dedent";
4693
- import { z as z7 } from "zod";
4694
- var translationSchema = z7.object({
4695
- translation: z7.string()
5296
+ import dedent7 from "dedent";
5297
+ import { z as z8 } from "zod";
5298
+ var translationSchema = z8.object({
5299
+ translation: z8.string()
4696
5300
  });
4697
- var SYSTEM_PROMPT5 = dedent6`
5301
+ var SYSTEM_PROMPT6 = dedent7`
4698
5302
  You are a subtitle translation expert. Translate VTT subtitle files to the target language specified by the user.
4699
5303
  You may receive either a full VTT file or a chunk from a larger VTT.
4700
5304
  Preserve all timestamps, cue ordering, and VTT formatting exactly as they appear.
4701
5305
  Return JSON with a single key "translation" containing the translated VTT content.
4702
5306
  `;
4703
- var CUE_TRANSLATION_SYSTEM_PROMPT = dedent6`
5307
+ var CUE_TRANSLATION_SYSTEM_PROMPT = dedent7`
4704
5308
  You are a subtitle translation expert.
4705
5309
  You will receive a sequence of subtitle cues extracted from a VTT file.
4706
5310
  Translate the cues to the requested target language while preserving their original order.
@@ -4872,13 +5476,13 @@ async function translateVttWithAI({
4872
5476
  }) {
4873
5477
  "use step";
4874
5478
  const model = await createLanguageModelFromConfig(provider, modelId, credentials);
4875
- const response = await generateText6({
5479
+ const response = await generateText7({
4876
5480
  model,
4877
- output: Output6.object({ schema: translationSchema }),
5481
+ output: Output7.object({ schema: translationSchema }),
4878
5482
  messages: [
4879
5483
  {
4880
5484
  role: "system",
4881
- content: SYSTEM_PROMPT5
5485
+ content: SYSTEM_PROMPT6
4882
5486
  },
4883
5487
  {
4884
5488
  role: "user",
@@ -4909,8 +5513,8 @@ async function translateCueChunkWithAI({
4909
5513
  }) {
4910
5514
  "use step";
4911
5515
  const model = await createLanguageModelFromConfig(provider, modelId, credentials);
4912
- const schema = z7.object({
4913
- translations: z7.array(z7.string().min(1)).length(cues.length)
5516
+ const schema = z8.object({
5517
+ translations: z8.array(z8.string().min(1)).length(cues.length)
4914
5518
  });
4915
5519
  const cuePayload = cues.map((cue, index) => ({
4916
5520
  index,
@@ -4918,9 +5522,9 @@ async function translateCueChunkWithAI({
4918
5522
  endTime: cue.endTime,
4919
5523
  text: cue.text
4920
5524
  }));
4921
- const response = await generateText6({
5525
+ const response = await generateText7({
4922
5526
  model,
4923
- output: Output6.object({ schema }),
5527
+ output: Output7.object({ schema }),
4924
5528
  messages: [
4925
5529
  {
4926
5530
  role: "system",
@@ -5271,12 +5875,15 @@ export {
5271
5875
  chaptersSchema,
5272
5876
  createReplacer,
5273
5877
  editCaptions,
5878
+ extractTranscriptSegmentsForHotspots,
5274
5879
  generateChapters,
5275
5880
  generateEmbeddings,
5881
+ generateEngagementInsights,
5276
5882
  generateVideoEmbeddings,
5277
5883
  getModerationScores,
5278
5884
  getSummaryAndTags,
5279
5885
  hasBurnedInCaptions,
5886
+ mapShotsToHotspots,
5280
5887
  profanityDetectionSchema,
5281
5888
  questionAnswerSchema,
5282
5889
  shouldSplitChunkTranslationError,