'use client'; /** * App-side implementations of the video-timeline compiler's DI boundary * ({@link TimingProbe} / {@link AssetSource} from `lib/video-export/deps`). * * The compiler is pure and its DI interfaces are **synchronous** by design: the * app resolves every duration and asset descriptor up front (Dexie reads, * durations stored at TTS time #861, video durations probed here from the * blobs), hands the compiler a set of in-memory tables, and the interface * methods are then plain synchronous lookups. This factory does that async * pre-load and returns the sync deps plus the loaded records, which the * byte-collection layer (#865 collection layer) reuses so Dexie is read once. * * This module lives in `lib/video-export-app/` — the impure, app-side companion * to the pure `lib/video-export/` compiler — precisely because it reaches into * `@/lib/utils/database` and (for video probing) the DOM, the two concerns the * compiler's purity boundary keeps out. */ import type { PPTElement, PPTVideoElement, PlayVideoAction, SpeechAction, SceneCore, SpotlightAction, LaserAction, } from '@openmaic/dsl'; import type { AssetMeta, AssetSource, GeometryProbe, InteractiveHtmlSource, TimingProbe, } from '@/lib/video-export'; import type { Scene, SlideContent } from '@/lib/types/stage'; import { isMediaPlaceholder } from '@/lib/store/media-generation'; import { measureSlideElementGeometry, type MeasuredGeometry } from '@openmaic/renderer/snapshot'; import { db, type AudioFileRecord, type MediaFileRecord } from '@/lib/utils/database'; import { emptyPreparedInteractiveHtmlSet, prepareInteractiveHtmlScenes, type PreparedInteractiveHtmlSet, } from './prepare-interactive-html'; import { useMediaGenerationStore } from '@/lib/store/media-generation'; import { resolveVideoMediaForElement } from '@/lib/media/media-task-resolution'; import { resolveAudioBlob } from '@/lib/media/resolve-audio-bytes'; /** Loaded source records, keyed for both metadata (compiler) and byte collection. */ export interface VideoTimelineRecords { /** Audio records by `audioId`. */ audioById: Map; /** Media records by `elementId` (the `stageId:` prefix stripped). */ mediaByElementId: Map; /** Probed video durations (ms) by `elementId`; absent when unprobeable. */ videoDurationMsByElementId: Map; /** Prepared self-contained HTML pages, addressable by asset id. */ interactiveHtml: PreparedInteractiveHtmlSet; } export interface VideoTimelineDeps { timing: TimingProbe; assets: AssetSource; geometry: GeometryProbe; interactive: InteractiveHtmlSource; records: VideoTimelineRecords; } /** * A media record is "present" when its bytes are recoverable at collect time: * either a real local blob, or a CDN `ossKey` to fetch from (live-mode records * whose local blob was LRU-evicted). Failed tasks (`error`) are never present. */ function mediaPresent(record: MediaFileRecord | undefined): record is MediaFileRecord { return !!record && !record.error && (record.blob.size > 0 || !!record.ossKey); } /** File-extension hint from a mime type (`video/mp4` → `mp4`), for the asset-plan naming. */ function formatFromMime(mimeType: string | undefined): string | undefined { return mimeType?.split('/')[1] || undefined; } /** * The generated-media reference a slide element points at, mirroring the live * playback engine's bridge (`lib/action/engine.ts` `resolveMediaPlaceholderId`): * use the unified video binding so a playable concrete `src` wins over a stale * opaque `mediaRef`; images retain their legacy placeholder selection. * * A `play_video` action targets the slide element by its `.id`, but the media * records are keyed by this ref (`gen_vid_…`), so the asset/duration lookups must * bridge id → ref or every generated video misses and is dropped from the export. */ function elementMediaRef( el: PPTElement, tasks: ReturnType['tasks'], stageId: string, documentElements: readonly PPTElement[], ): string | undefined { if (el.type === 'video') { return resolveVideoMediaForElement(tasks, el as PPTVideoElement, stageId, documentElements) .sourceRef; } if (el.type === 'image' && typeof el.src === 'string' && isMediaPlaceholder(el.src)) { return el.src; } return undefined; } /** Per-probe timeout (ms). A blob whose metadata never loads must not wedge export. */ const PROBE_TIMEOUT_MS = 10_000; /** How many media-duration probes run at once (bounded so a big deck can't thrash). */ const PROBE_CONCURRENCY = 6; /** * Run `worker` over `items` with bounded concurrency, collecting results. Order * is not significant to callers (they key results into a Map), so this drains a * shared cursor from `PROBE_CONCURRENCY` lanes. */ async function mapWithConcurrency( items: T[], limit: number, worker: (item: T) => Promise, ): Promise { let cursor = 0; const lanes = Array.from({ length: Math.min(limit, items.length) }, async () => { while (cursor < items.length) { const i = cursor++; await worker(items[i]); } }); await Promise.all(lanes); } /** * Probe a video blob's natural duration (ms) via an off-document `