@editframe/elements 0.47.1 → 0.47.2
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/LICENSE.md +58 -0
- package/dist/elements/EFMedia/BufferedSeekingInput.js +6 -2
- package/dist/elements/EFMedia/BufferedSeekingInput.js.map +1 -1
- package/dist/elements/EFMedia/SegmentIndex.d.ts +2 -0
- package/dist/elements/EFMedia/SegmentIndex.js +9 -1
- package/dist/elements/EFMedia/SegmentIndex.js.map +1 -1
- package/dist/elements/EFMedia.d.ts +1 -1
- package/dist/elements/EFMedia.js +1 -1
- package/dist/elements/EFMedia.js.map +1 -1
- package/dist/elements/EFMotionBlur.js.map +1 -1
- package/dist/elements/EFVideo.d.ts +29 -1
- package/dist/elements/EFVideo.js +50 -3
- package/dist/elements/EFVideo.js.map +1 -1
- package/dist/gui/PlaybackController.js +35 -14
- package/dist/gui/PlaybackController.js.map +1 -1
- package/dist/gui/timeline/EFTimeline.js.map +1 -1
- package/dist/preview/rendering/serializeTimelineDirect.js +2 -0
- package/dist/preview/rendering/serializeTimelineDirect.js.map +1 -1
- package/dist/version.js +1 -1
- package/package.json +2 -2
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"EFVideo.js","names":["#resolve","EFVideo","#currentRenditionId","#cachedVideoSample","#cachedVideoSampleTimeMs","#delayedLoadingState","#fetchVideoSampleForFrame","#getMainVideoSampleForFrame","#getScrubVideoSampleForFrame","#maybeScheduleQualityUpgrade","#scrubInputCache","initSegment: ArrayBuffer | undefined","mediaSegment: ArrayBuffer | undefined","#mainVideoInputCache","#invalidateUpgradeState","#prewarmQualityUpgrade","videoSample: any","#renderingToVideo","#upgradeState","#upgradeOwnerId","scheduler","#computeLookaheadSegments","#fetchStandalone","results: { segmentId: number; deadlineMs: number }[]","#standaloneUpgradeController"],"sources":["../../src/elements/EFVideo.ts"],"sourcesContent":["import { context, trace } from \"@opentelemetry/api\";\nimport debug from \"debug\";\nimport { css, html, type PropertyValueMap } from \"lit\";\nimport { customElement, state } from \"lit/decorators.js\";\nimport { createRef, ref } from \"lit/directives/ref.js\";\nimport type { VideoSample } from \"mediabunny\";\nimport { DelayedLoadingState } from \"../DelayedLoadingState.js\";\nimport { TWMixin } from \"../gui/TWMixin.js\";\nimport { withSpanSync } from \"../otel/tracingHelpers.js\";\nimport {\n type FrameRenderable,\n type FrameState,\n PRIORITY_VIDEO,\n} from \"../preview/FrameController.js\";\nimport type { MediaEngine } from \"../transcoding/types/index.ts\";\nimport { MainVideoInputCache } from \"./EFMedia/videoTasks/MainVideoInputCache.ts\";\nimport { ScrubInputCache } from \"./EFMedia/videoTasks/ScrubInputCache.ts\";\nimport { EFMedia } from \"./EFMedia.js\";\nimport { updateAnimations } from \"./updateAnimations.js\";\n\n// NOTE: These caches are intentionally per-instance (created in EFVideo class body),\n// not module-level singletons. Sharing between live player and render clones causes\n// seek-lock deadlocks: the thumbnail generator holds a BufferedSeekingInput seek lock\n// while the live player waits on the same lock indefinitely.\n\n// EF_FRAMEGEN is a global instance created in EF_FRAMEGEN.ts\ndeclare global {\n var EF_FRAMEGEN: import(\"../EF_FRAMEGEN.js\").EFFramegen;\n}\n\nconst log = debug(\"ef:elements:EFVideo\");\n\ninterface LoadingState {\n isLoading: boolean;\n operation: \"scrub-segment\" | \"video-segment\" | \"seeking\" | \"decoding\" | null;\n message: string;\n}\n\n/**\n * Event detail for scrub segment loading progress.\n * Dispatched during prefetchScrubSegments to indicate network activity.\n */\nexport interface ScrubSegmentLoadingDetail {\n /** The segment ID being loaded (0-indexed) */\n segmentId: number;\n /** Time range covered by this segment [startMs, endMs] */\n timeRangeMs: [number, number];\n /** Number of segments loaded so far */\n loaded: number;\n /** Total number of segments to load */\n total: number;\n /** Current status: \"loading\" or \"loaded\" */\n status: \"loading\" | \"loaded\";\n}\n\nclass VideoSeekTask {\n value: VideoSample | undefined = undefined;\n task: ((...args: any[]) => any) | undefined = undefined;\n\n #resolve: ((v: VideoSample | undefined) => void) | undefined;\n taskComplete: Promise<VideoSample | undefined> = Promise.resolve(undefined);\n\n begin(): void {\n this.taskComplete = new Promise<VideoSample | undefined>((resolve) => {\n this.#resolve = resolve;\n });\n }\n\n complete(sample: VideoSample | undefined): void {\n this.value = sample;\n this.#resolve?.(sample);\n }\n\n abort(): void {\n this.#resolve?.(undefined);\n }\n}\n\n@customElement(\"ef-video\")\nexport class EFVideo extends TWMixin(EFMedia) implements FrameRenderable {\n static styles = [\n css`\n :host {\n display: block;\n position: relative;\n object-fit: contain;\n object-position: center;\n }\n canvas {\n overflow: hidden;\n position: static;\n width: 100%;\n height: 100%;\n object-fit: inherit;\n object-position: inherit;\n margin: 0;\n padding: 0;\n border: none;\n outline: none;\n box-shadow: none;\n }\n .loading-overlay {\n position: absolute;\n top: 0;\n left: 0;\n right: 0;\n height: 2px;\n overflow: hidden;\n z-index: 10;\n pointer-events: none;\n background: var(--ef-color-loading-spinner-track, rgba(255, 255, 255, 0.1));\n }\n .loading-bar {\n position: absolute;\n top: 0;\n height: 100%;\n width: 40%;\n background: var(--ef-color-loading-spinner-fill, rgba(255, 255, 255, 0.8));\n animation: loading-sweep 1.4s ease-in-out infinite;\n }\n @keyframes loading-sweep {\n 0% { left: -40%; }\n 100% { left: 140%; }\n }\n `,\n ];\n canvasRef = createRef<HTMLCanvasElement>();\n unifiedVideoSeekTask = new VideoSeekTask();\n\n // ============================================================================\n // FrameRenderable Implementation\n // Centralized frame control - no more Lit Tasks\n // ============================================================================\n\n /**\n * Cached video sample for the current frame.\n * Set by prepareFrame(), consumed by renderFrame().\n */\n #cachedVideoSample: VideoSample | undefined = undefined;\n #cachedVideoSampleTimeMs: number | undefined = undefined;\n\n /**\n * Quality upgrade intent tracking.\n * Tracks what upgrade tasks were last submitted to avoid redundant scheduler calls.\n */\n #upgradeState: {\n sourceTimeMs: number;\n segmentId: number;\n startTimeMs: number;\n submittedKeys: Set<string>;\n } | null = null;\n\n /**\n * Standalone upgrade controller for elements without a timegroup.\n */\n #standaloneUpgradeController: AbortController | null = null;\n\n // Per-instance caches. NOT module-level singletons: sharing BufferedSeekingInput\n // instances between the live player and render clones causes seek-lock deadlocks —\n // the thumbnail generator holds a lock while the live player waits on it indefinitely.\n #mainVideoInputCache = new MainVideoInputCache();\n #scrubInputCache = new ScrubInputCache();\n\n /**\n * Set to true while renderToVideo is executing to suppress background\n * quality upgrade tasks that would race with the render pipeline.\n */\n #renderingToVideo = false;\n\n /**\n * Stable per-instance identifier for the quality upgrade scheduler.\n * Uses this.id when available; falls back to a generated unique string so\n * elements without an id attribute never collide with each other.\n */\n #upgradeOwnerId: string = crypto.randomUUID();\n\n /**\n * Current rendition being displayed (for observability).\n */\n #currentRenditionId: \"main\" | \"scrub\" | undefined = undefined;\n\n /**\n * Get the current rendition being displayed.\n * @public\n */\n get currentRenditionId(): \"main\" | \"scrub\" | undefined {\n return this.#currentRenditionId;\n }\n\n /**\n * Query readiness state for a given time.\n * @implements FrameRenderable\n *\n * Note: The timeMs parameter is the root timegroup's time. We check against\n * this.currentSourceTimeMs since that's what we cache in prepareFrame.\n */\n getFrameState(_timeMs: number): FrameState {\n // Use element's source time to match what prepareFrame caches\n const sourceTimeMs = this.currentSourceTimeMs;\n\n // Check if we have a cached sample for this exact source time\n const hasCache =\n this.#cachedVideoSample !== undefined && this.#cachedVideoSampleTimeMs === sourceTimeMs;\n\n // If we're on scrub quality and main segment is cached, signal upgrade needed.\n // Skip in render clones — they must stay on scrub for thumbnail generation speed.\n if (this.#currentRenditionId === \"scrub\" && !this.rootTimegroup?.isRenderClone) {\n const mediaEngine = this.mediaEngineTask.value;\n if (mediaEngine) {\n const mainTrack = mediaEngine.tracks.video;\n if (mainTrack) {\n const mainSegmentId = mediaEngine.index.segmentAt(sourceTimeMs, mainTrack);\n if (\n mainSegmentId !== undefined &&\n mediaEngine.transport.isCached(mainSegmentId, mainTrack)\n ) {\n return { needsPreparation: true, isReady: false, priority: PRIORITY_VIDEO };\n }\n }\n }\n }\n\n return { needsPreparation: !hasCache, isReady: hasCache, priority: PRIORITY_VIDEO };\n }\n\n /**\n * Async preparation - seeks video and caches the sample.\n * @implements FrameRenderable\n *\n * Note: The timeMs parameter is the root timegroup's time. We ignore it and\n * use this.currentSourceTimeMs instead, which accounts for:\n * - Our position within the parent timegroup (ownCurrentTimeMs)\n * - Source trimming (sourceIn/sourceOut or trimStart/trimEnd)\n */\n async prepareFrame(_timeMs: number, signal: AbortSignal): Promise<void> {\n signal.throwIfAborted();\n this.unifiedVideoSeekTask.begin();\n this.#delayedLoadingState.startLoading(\"prepare-frame\", \"\");\n\n try {\n // Use element's source time, not the passed root timegroup time.\n // currentSourceTimeMs = ownCurrentTimeMs + (sourceIn || trimStart || 0)\n // This correctly maps timeline position to actual media time.\n const sourceTimeMs = this.currentSourceTimeMs;\n\n const mediaEngine = await this.getMediaEngine(signal);\n if (!mediaEngine) {\n this.#cachedVideoSample = undefined;\n this.#cachedVideoSampleTimeMs = sourceTimeMs;\n this.unifiedVideoSeekTask.complete(undefined);\n return;\n }\n\n signal.throwIfAborted();\n\n // Fetch video sample at the correct source time\n // Handle errors gracefully so one failed seek doesn't break subsequent frames\n try {\n const videoSample = await this.#fetchVideoSampleForFrame(mediaEngine, sourceTimeMs, signal);\n\n // Cache before checking abort: a successful seek result is valuable even\n // if the render is being superseded. Writing the cache here ensures the\n // next render (triggered by the same abort) finds a warm cache and avoids\n // re-seeking. Without this ordering the abort fires, cache is never written,\n // the next render re-seeks, gets aborted again — infinite loop, black canvas.\n this.#cachedVideoSample = videoSample;\n this.#cachedVideoSampleTimeMs = sourceTimeMs;\n this.unifiedVideoSeekTask.complete(videoSample);\n\n signal.throwIfAborted();\n } catch (error) {\n // Re-throw abort errors to properly handle cancellation.\n // If complete() was already called above, abort() is a no-op on the\n // already-resolved promise and does not clear .value.\n if (error instanceof DOMException && error.name === \"AbortError\") {\n this.unifiedVideoSeekTask.abort();\n throw error;\n }\n\n // For seek errors (NoSample, out of bounds, etc.), just clear cache\n // This allows subsequent frames to retry instead of being stuck\n console.warn(`Video seek error at ${sourceTimeMs}ms:`, error);\n this.#cachedVideoSample = undefined;\n this.#cachedVideoSampleTimeMs = sourceTimeMs;\n this.unifiedVideoSeekTask.complete(undefined);\n }\n } finally {\n this.#delayedLoadingState.clearLoading(\"prepare-frame\");\n }\n }\n\n /**\n * Synchronous render - paints cached video sample to canvas.\n * @implements FrameRenderable\n *\n * Note: The timeMs parameter is the root timegroup's time. We use\n * this.currentSourceTimeMs to match what prepareFrame cached.\n */\n renderFrame(_timeMs: number): void {\n // Use element's source time to match what was cached in prepareFrame\n const sourceTimeMs = this.currentSourceTimeMs;\n\n // Use cached sample if available for this source time\n if (this.#cachedVideoSampleTimeMs === sourceTimeMs && this.#cachedVideoSample) {\n try {\n const videoFrame = this.#cachedVideoSample.toVideoFrame();\n try {\n this.displayFrame(videoFrame, sourceTimeMs);\n } finally {\n videoFrame.close();\n }\n } catch {\n // VideoSample was evicted from SampleBuffer between prepareFrame and renderFrame.\n // Clear the cache so the next render cycle re-seeks.\n this.#cachedVideoSample = undefined;\n }\n }\n\n // Update animations if not in parent timegroup\n if (!this.parentTimegroup) {\n updateAnimations(this);\n }\n }\n\n /**\n * Fetch video sample for a given time.\n *\n * Uses a quality routing strategy:\n * - In production rendering: always use main (full quality) track\n * - In preview mode: try scrub track first for faster scrubbing, fall back to main\n * - If main track segment is already cached: use it (avoid redundant lower-quality fetch)\n */\n async #fetchVideoSampleForFrame(\n mediaEngine: MediaEngine,\n desiredSeekTimeMs: number,\n signal: AbortSignal,\n ): Promise<VideoSample | undefined> {\n const mainTrack = mediaEngine.tracks.video;\n\n // FIRST: Check if main quality content is already cached - use it if so\n if (mainTrack) {\n const mainSegmentId = mediaEngine.index.segmentAt(desiredSeekTimeMs, mainTrack);\n const mainCached =\n mainSegmentId !== undefined && mediaEngine.transport.isCached(mainSegmentId, mainTrack);\n if (mainCached) {\n this.#currentRenditionId = \"main\";\n return this.#getMainVideoSampleForFrame(mediaEngine, desiredSeekTimeMs, signal);\n }\n }\n\n // SECOND: In production rendering mode, always use main (full quality) track\n if (this.isInProductionRenderingMode()) {\n this.#currentRenditionId = \"main\";\n return this.#getMainVideoSampleForFrame(mediaEngine, desiredSeekTimeMs, signal);\n }\n\n // THIRD: In preview mode, try scrub track first for faster scrubbing\n const scrubTrack = mediaEngine.tracks.scrub;\n if (scrubTrack) {\n const scrubSample = await this.#getScrubVideoSampleForFrame(\n mediaEngine,\n desiredSeekTimeMs,\n signal,\n );\n if (scrubSample) {\n this.#currentRenditionId = \"scrub\";\n // Got scrub - schedule background quality upgrade\n this.#maybeScheduleQualityUpgrade(mediaEngine, desiredSeekTimeMs);\n return scrubSample;\n }\n }\n\n // FOURTH: Fall back to main video path\n this.#currentRenditionId = \"main\";\n return this.#getMainVideoSampleForFrame(mediaEngine, desiredSeekTimeMs, signal);\n }\n\n /**\n * Get scrub (low-resolution) video sample for fast preview scrubbing.\n * Used in preview mode for faster response during timeline scrubbing.\n */\n async #getScrubVideoSampleForFrame(\n mediaEngine: MediaEngine,\n desiredSeekTimeMs: number,\n signal: AbortSignal,\n ): Promise<VideoSample | undefined> {\n const scrubTrack = mediaEngine.tracks.scrub;\n if (!scrubTrack) {\n return undefined;\n }\n\n const segmentId = mediaEngine.index.segmentAt(desiredSeekTimeMs, scrubTrack);\n if (segmentId === undefined) {\n return undefined;\n }\n\n const scrubInput = await this.#scrubInputCache.getOrCreateInput(\n mediaEngine.src,\n segmentId,\n async () => {\n // Do NOT use the caller's signal — same reasoning as #getMainVideoSampleForFrame:\n // aborting this caller must not cancel the shared BufferedSeekingInput construction\n // for all other callers waiting on the same segment.\n let initSegment: ArrayBuffer | undefined;\n let mediaSegment: ArrayBuffer | undefined;\n\n try {\n [initSegment, mediaSegment] = await Promise.all([\n mediaEngine.transport.fetchInitSegment(scrubTrack),\n mediaEngine.transport.fetchMediaSegment(segmentId, scrubTrack),\n ]);\n } catch (error) {\n return undefined;\n }\n\n if (!initSegment || !mediaSegment) {\n return undefined;\n }\n\n const combinedBlob = new Blob([initSegment, mediaSegment]);\n const arrayBuffer = await combinedBlob.arrayBuffer();\n\n const { BufferedSeekingInput } = await import(\"./EFMedia/BufferedSeekingInput.js\");\n\n return new BufferedSeekingInput(arrayBuffer, {\n videoBufferSize: EFMedia.VIDEO_SAMPLE_BUFFER_SIZE,\n audioBufferSize: EFMedia.AUDIO_SAMPLE_BUFFER_SIZE,\n startTimeOffsetMs: scrubTrack.startTimeOffsetMs,\n });\n },\n );\n\n if (!scrubInput) {\n return undefined;\n }\n\n signal.throwIfAborted();\n\n const videoTrack = await scrubInput.getFirstVideoTrack();\n if (!videoTrack) {\n return undefined;\n }\n\n signal.throwIfAborted();\n\n return scrubInput.seek(videoTrack.id, desiredSeekTimeMs) as Promise<VideoSample | undefined>;\n }\n\n /**\n * Get main video sample for a given time.\n */\n async #getMainVideoSampleForFrame(\n mediaEngine: MediaEngine,\n desiredSeekTimeMs: number,\n signal: AbortSignal,\n ): Promise<VideoSample | undefined> {\n const videoTrack = mediaEngine.tracks.video;\n if (!videoTrack) {\n return undefined;\n }\n\n const segmentId = mediaEngine.index.segmentAt(desiredSeekTimeMs, videoTrack);\n if (segmentId === undefined) {\n return undefined;\n }\n\n const mainInput = await this.#mainVideoInputCache.getOrCreateInput(\n mediaEngine.src,\n segmentId,\n String(videoTrack.id),\n async () => {\n // Do NOT use the caller's signal here. This factory creates a shared\n // BufferedSeekingInput that will be reused by all callers for this\n // segment. If the caller aborts (e.g. seek away mid-upgrade), we must\n // still complete construction and cache the result — otherwise every\n // upgrade attempt that gets aborted restarts from scratch, and the\n // element is stuck on scrub quality indefinitely.\n let initSegment: ArrayBuffer | undefined;\n let mediaSegment: ArrayBuffer | undefined;\n\n try {\n const initP = mediaEngine.transport.fetchInitSegment(videoTrack);\n const mediaP = mediaEngine.transport.fetchMediaSegment(segmentId, videoTrack);\n [initSegment, mediaSegment] = await Promise.all([initP, mediaP]);\n } catch (error) {\n if (\n error instanceof Error &&\n (error.message.includes(\"401\") ||\n error.message.includes(\"UNAUTHORIZED\") ||\n error.message.includes(\"Failed to fetch\") ||\n error.message.includes(\"File not found\") ||\n error.message.includes(\"Media segment not found\") ||\n error.message.includes(\"Init segment not found\") ||\n error.message.includes(\"Track not found\"))\n ) {\n return undefined;\n }\n throw error;\n }\n\n if (!initSegment || !mediaSegment) {\n return undefined;\n }\n\n const combinedBlob = new Blob([initSegment, mediaSegment]);\n const arrayBuffer = await combinedBlob.arrayBuffer();\n\n const { BufferedSeekingInput } = await import(\"./EFMedia/BufferedSeekingInput.js\");\n\n return new BufferedSeekingInput(arrayBuffer, {\n videoBufferSize: EFMedia.VIDEO_SAMPLE_BUFFER_SIZE,\n audioBufferSize: EFMedia.AUDIO_SAMPLE_BUFFER_SIZE,\n startTimeOffsetMs: videoTrack.startTimeOffsetMs,\n });\n },\n );\n\n if (!mainInput) {\n return undefined;\n }\n\n signal.throwIfAborted();\n\n const videoTrackInfo = await mainInput.getFirstVideoTrack();\n if (!videoTrackInfo) {\n return undefined;\n }\n\n signal.throwIfAborted();\n\n const sample = (await mainInput.seek(videoTrackInfo.id, desiredSeekTimeMs)) as\n | VideoSample\n | undefined;\n return sample;\n }\n\n // ============================================================================\n // End FrameRenderable Implementation\n // ============================================================================\n\n /**\n * Delayed loading state manager for user feedback\n */\n #delayedLoadingState: DelayedLoadingState;\n\n /**\n * Loading state for user feedback\n */\n @state()\n loadingState = {\n isLoading: false,\n operation: null as LoadingState[\"operation\"],\n message: \"\",\n };\n\n constructor() {\n super();\n\n // Initialize delayed loading state with callback to update UI\n this.#delayedLoadingState = new DelayedLoadingState(100, (isLoading, message) => {\n this.setLoadingState(isLoading, null, message);\n });\n }\n\n protected updated(changedProperties: PropertyValueMap<any> | Map<PropertyKey, unknown>): void {\n super.updated(changedProperties);\n\n // Invalidate upgrade state on src/fileId change\n if (changedProperties.has(\"src\") || changedProperties.has(\"fileId\")) {\n this.#cachedVideoSample = undefined;\n this.#cachedVideoSampleTimeMs = undefined;\n this.#mainVideoInputCache.clear();\n this.#scrubInputCache.clear();\n this.#invalidateUpgradeState(\"src-change\");\n this.#prewarmQualityUpgrade();\n }\n\n // Invalidate upgrade state on trim/source changes\n const durationAffectingProps = [\"_trimStartMs\", \"_trimEndMs\", \"_sourceInMs\", \"_sourceOutMs\"];\n const hasDurationChange = durationAffectingProps.some((prop) => changedProperties.has(prop));\n if (hasDurationChange) {\n this.#invalidateUpgradeState(\"bounds-change\");\n this.#prewarmQualityUpgrade();\n }\n\n // No need to clear canvas - displayFrame() overwrites it completely\n // and clearing creates blank frame gaps during transitions\n }\n\n /**\n * Eagerly load the media engine and pre-warm main-quality segments for the\n * start of this clip. Called when src/fileId or source bounds change so that\n * segments are already in cache by the time the element first becomes visible.\n *\n * Without pre-warming, quality upgrade only begins after the first scrub frame\n * is displayed, causing ~12 frames of blur at the cold-start of every clip.\n */\n #prewarmQualityUpgrade(): void {\n if (this.isInProductionRenderingMode()) return;\n if (!this.src && !this.fileId) return;\n\n this.getMediaEngine()\n .then((engine) => {\n if (!engine) return;\n // Use currentSourceTimeMs so the prewarm targets wherever the playhead\n // actually is, not just the clip start. Falls back to sourceInMs if the\n // element has no temporal context yet (e.g. not yet parented to a timegroup).\n const targetTimeMs = this.currentSourceTimeMs ?? this.sourceInMs ?? 0;\n this.#maybeScheduleQualityUpgrade(engine, targetTimeMs);\n })\n .catch(() => {});\n }\n\n render() {\n return html`\n <canvas ${ref(this.canvasRef)}></canvas>\n ${\n this.loadingState.isLoading\n ? html`<div class=\"loading-overlay\"><div class=\"loading-bar\"></div></div>`\n : \"\"\n }\n `;\n }\n\n get canvasElement() {\n const referencedCanvas = this.canvasRef.value;\n if (referencedCanvas) {\n return referencedCanvas;\n }\n const shadowCanvas = this.shadowRoot?.querySelector(\"canvas\");\n if (shadowCanvas) {\n return shadowCanvas;\n }\n return undefined;\n }\n\n /**\n * Start a delayed loading operation for testing\n */\n startDelayedLoading(\n operationId: string,\n message: string,\n options: { background?: boolean } = {},\n ): void {\n this.#delayedLoadingState.startLoading(operationId, message, options);\n }\n\n /**\n * Clear a delayed loading operation for testing\n */\n clearDelayedLoading(operationId: string): void {\n this.#delayedLoadingState.clearLoading(operationId);\n }\n\n /**\n * Set loading state for user feedback\n */\n private setLoadingState(\n isLoading: boolean,\n operation: LoadingState[\"operation\"] = null,\n message = \"\",\n ): void {\n this.loadingState = {\n isLoading,\n operation,\n message,\n };\n }\n\n /**\n * Paint the current video frame to canvas\n */\n paint(seekToMs: number, parentSpan?: any): void {\n const parentContext = parentSpan ? trace.setSpan(context.active(), parentSpan) : undefined;\n\n withSpanSync(\n \"video.paint\",\n {\n elementId: this.id || \"unknown\",\n seekToMs,\n src: this.src || \"none\",\n },\n parentContext,\n (span) => {\n const t0 = performance.now();\n\n // Check if we're in production rendering mode vs preview mode\n const isProductionRendering = this.isInProductionRenderingMode();\n const t1 = performance.now();\n span.setAttribute(\"isProductionRendering\", isProductionRendering);\n span.setAttribute(\"modeCheckMs\", t1 - t0);\n\n // Use cached video sample from prepareFrame\n try {\n const t2 = performance.now();\n const videoSample = this.#cachedVideoSample;\n span.setAttribute(\"hasVideoSample\", !!videoSample);\n span.setAttribute(\"valueAccessMs\", t2 - t1);\n\n if (videoSample) {\n const t3 = performance.now();\n const videoFrame = videoSample.toVideoFrame();\n const t4 = performance.now();\n span.setAttribute(\"toVideoFrameMs\", t4 - t3);\n\n try {\n const t5 = performance.now();\n this.displayFrame(videoFrame, seekToMs, span);\n const t6 = performance.now();\n span.setAttribute(\"displayFrameMs\", t6 - t5);\n } finally {\n videoFrame.close();\n }\n }\n } catch (error) {\n console.warn(\"Video pipeline error:\", error);\n }\n\n // EF_FRAMEGEN-aware rendering mode detection\n if (!isProductionRendering) {\n // Preview mode: always render\n // Visibility is handled by the phase/visibility system (CSS display:none)\n // No need to skip initialization frames - if element shouldn't be visible,\n // it will be hidden by CSS\n } else {\n // Production rendering mode: only render when EF_FRAMEGEN has explicitly started frame rendering\n // This prevents initialization frames before the actual render sequence begins\n if (!this.rootTimegroup) {\n span.setAttribute(\"skipped\", \"no-root-timegroup\");\n return;\n }\n\n if (!this.isFrameRenderingActive()) {\n span.setAttribute(\"skipped\", \"frame-rendering-not-active\");\n return; // Wait for EF_FRAMEGEN to start frame sequence\n }\n\n // Production mode: EF_FRAMEGEN has started frame sequence, proceed with rendering\n }\n\n const tEnd = performance.now();\n span.setAttribute(\"totalPaintMs\", tEnd - t0);\n },\n );\n }\n\n /**\n * Clear the canvas when element becomes inactive\n */\n clearCanvas(): void {\n if (!this.canvasElement) return;\n\n const ctx = this.canvasElement.getContext(\"2d\", {\n willReadFrequently: true,\n });\n if (ctx) {\n ctx.clearRect(0, 0, this.canvasElement.width, this.canvasElement.height);\n }\n }\n\n /**\n * Display a video frame on the canvas\n */\n displayFrame(frame: VideoFrame, seekToMs: number, parentSpan?: any): void {\n const parentContext = parentSpan ? trace.setSpan(context.active(), parentSpan) : undefined;\n\n withSpanSync(\n \"video.displayFrame\",\n {\n elementId: this.id || \"unknown\",\n seekToMs,\n format: frame.format || \"unknown\",\n width: frame.codedWidth,\n height: frame.codedHeight,\n },\n parentContext,\n (span) => {\n const t0 = performance.now();\n\n log(\"trace: displayFrame start\", {\n seekToMs,\n frameFormat: frame.format,\n });\n\n if (!this.canvasElement) {\n log(\"trace: displayFrame aborted - no canvas element\");\n throw new Error(\n `Frame display failed: Canvas element is not available at time ${seekToMs}ms. The video component may not be properly initialized.`,\n );\n }\n const t1 = performance.now();\n span.setAttribute(\"getCanvasMs\", Math.round((t1 - t0) * 100) / 100);\n\n const ctx = this.canvasElement.getContext(\"2d\", {\n willReadFrequently: true,\n });\n const t2 = performance.now();\n span.setAttribute(\"getCtxMs\", Math.round((t2 - t1) * 100) / 100);\n\n if (!ctx) {\n log(\"trace: displayFrame aborted - no canvas context\");\n throw new Error(\n `Frame display failed: Unable to get 2D canvas context at time ${seekToMs}ms. This may indicate a browser compatibility issue or canvas corruption.`,\n );\n }\n\n const frameWidth = frame.displayWidth;\n const frameHeight = frame.displayHeight;\n\n let resized = false;\n if (frameWidth && frameHeight) {\n const needsResize =\n frameWidth > this.canvasElement.width || frameHeight > this.canvasElement.height;\n if (needsResize) {\n const newWidth = Math.max(this.canvasElement.width, frameWidth);\n const newHeight = Math.max(this.canvasElement.height, frameHeight);\n log(\"trace: updating canvas dimensions\", {\n width: newWidth,\n height: newHeight,\n });\n this.canvasElement.width = newWidth;\n this.canvasElement.height = newHeight;\n resized = true;\n const t3 = performance.now();\n span.setAttribute(\"resizeMs\", Math.round((t3 - t2) * 100) / 100);\n }\n }\n span.setAttribute(\"canvasResized\", resized);\n\n if (frame.format === null) {\n log(\"trace: displayFrame aborted - null frame format\");\n throw new Error(\n `Frame display failed: Video frame has null format at time ${seekToMs}ms. This indicates corrupted or incompatible video data.`,\n );\n }\n\n const tDrawStart = performance.now();\n ctx.drawImage(frame, 0, 0, this.canvasElement.width, this.canvasElement.height);\n const tDrawEnd = performance.now();\n span.setAttribute(\"drawImageMs\", Math.round((tDrawEnd - tDrawStart) * 100) / 100);\n span.setAttribute(\"totalDisplayMs\", Math.round((tDrawEnd - t0) * 100) / 100);\n span.setAttribute(\"canvasWidth\", this.canvasElement.width);\n span.setAttribute(\"canvasHeight\", this.canvasElement.height);\n\n log(\"trace: frame drawn to canvas\", { seekToMs });\n },\n );\n }\n\n /**\n * Check if we're in production rendering mode (EF_FRAMEGEN active) vs preview mode\n */\n private isInProductionRenderingMode(): boolean {\n // Check if EF_RENDERING function exists and returns true (production rendering)\n if (typeof window.EF_RENDERING === \"function\") {\n return window.EF_RENDERING();\n }\n\n // Check if workbench is in rendering mode\n const workbench = document.querySelector(\"ef-workbench\") as any;\n if (workbench?.rendering) {\n return true;\n }\n\n // Check if EF_FRAMEGEN exists and has render options (indicates active rendering)\n if (window.EF_FRAMEGEN?.renderOptions) {\n return true;\n }\n\n // Default to preview mode\n return false;\n }\n\n /**\n * Check if EF_FRAMEGEN has explicitly started frame rendering (not just initialization)\n */\n private isFrameRenderingActive(): boolean {\n if (!window.EF_FRAMEGEN?.renderOptions) {\n return false;\n }\n\n // In production mode, only render when EF_FRAMEGEN has actually begun frame sequence\n // Check if we're past the initialization phase by looking for explicit frame control\n const renderOptions = window.EF_FRAMEGEN.renderOptions;\n const renderStartTime = renderOptions.encoderOptions.fromMs;\n const currentTime = this.rootTimegroup?.currentTimeMs || 0;\n\n // We're in active frame rendering if:\n // 1. currentTime >= renderStartTime (includes the starting frame)\n return currentTime >= renderStartTime;\n }\n\n /**\n * Get a decoded VideoFrame at a specific source media timestamp.\n * Returns a standard WebCodecs VideoFrame — caller MUST call .close() when done.\n *\n * Uses the same routing logic as the unified video system:\n * - \"auto\": main track for production rendering, follows normal routing otherwise\n * - \"scrub\": force low-res scrub track (for thumbnails)\n * - \"main\": force full-quality main track\n *\n * @param sourceTimeMs - Timestamp in source media coordinates (not timeline)\n * @param options - Quality and abort signal\n * @returns VideoFrame that the caller must close()\n * @public\n */\n async getVideoFrameAtSourceTime(\n sourceTimeMs: number,\n options: {\n quality?: \"auto\" | \"scrub\" | \"main\";\n signal?: AbortSignal;\n } = {},\n ): Promise<VideoFrame> {\n const { quality = \"auto\", signal: providedSignal } = options;\n\n const signal = providedSignal ?? new AbortController().signal;\n signal.throwIfAborted();\n\n this.playbackController?.suspendSelfRender();\n try {\n const mediaEngine = await this.getMediaEngine(signal);\n signal.throwIfAborted();\n\n if (!mediaEngine) {\n throw new Error(\"No media engine available for frame capture\");\n }\n\n const useMainTrack =\n quality === \"main\" || (quality === \"auto\" && this.isInProductionRenderingMode());\n\n let videoSample: any;\n\n const { BufferedSeekingInput } = await import(\"./EFMedia/BufferedSeekingInput.js\");\n signal.throwIfAborted();\n\n if (useMainTrack) {\n const videoTrack = mediaEngine.tracks.video;\n if (!videoTrack) {\n throw new Error(\"No video rendition available\");\n }\n\n const segmentId = mediaEngine.index.segmentAt(sourceTimeMs, videoTrack);\n if (segmentId === undefined) {\n throw new Error(`Cannot compute segment ID for time ${sourceTimeMs}ms`);\n }\n\n const seekingInput = await this.#mainVideoInputCache.getOrCreateInput(\n mediaEngine.src,\n segmentId,\n String(videoTrack.id),\n async () => {\n // Do NOT use the caller's signal — same reasoning as #getMainVideoSampleForFrame:\n // aborting this caller must not cancel the shared BufferedSeekingInput construction\n // for all other callers waiting on the same segment.\n const initP = mediaEngine.transport.fetchInitSegment(videoTrack);\n const mediaP = mediaEngine.transport.fetchMediaSegment(segmentId, videoTrack);\n initP.catch(() => {});\n mediaP.catch(() => {});\n const [initSegment, mediaSegment] = await Promise.all([initP, mediaP]);\n\n if (!initSegment || !mediaSegment) {\n return undefined;\n }\n\n const combinedBlob = new Blob([initSegment, mediaSegment]);\n const arrayBuffer = await combinedBlob.arrayBuffer();\n\n return new BufferedSeekingInput(arrayBuffer, {\n videoBufferSize: EFMedia.VIDEO_SAMPLE_BUFFER_SIZE,\n audioBufferSize: EFMedia.AUDIO_SAMPLE_BUFFER_SIZE,\n startTimeOffsetMs: videoTrack.startTimeOffsetMs,\n });\n },\n );\n signal.throwIfAborted();\n\n if (!seekingInput) {\n throw new Error(`Failed to fetch video segments for time ${sourceTimeMs}ms`);\n }\n\n const seekingVideoTrack = await seekingInput.getFirstVideoTrack();\n signal.throwIfAborted();\n\n if (!seekingVideoTrack) {\n throw new Error(\"No video track found in segment\");\n }\n\n videoSample = await seekingInput.seek(seekingVideoTrack.id, sourceTimeMs);\n signal.throwIfAborted();\n } else {\n const scrubTrack = mediaEngine.tracks.scrub;\n if (!scrubTrack) {\n return this.getVideoFrameAtSourceTime(sourceTimeMs, {\n quality: \"main\",\n signal,\n });\n }\n\n const segmentId = mediaEngine.index.segmentAt(sourceTimeMs, scrubTrack);\n\n if (segmentId === undefined) {\n throw new Error(`Cannot compute scrub segment ID for time ${sourceTimeMs}ms`);\n }\n\n const seekingInput = await this.#scrubInputCache.getOrCreateInput(\n mediaEngine.src,\n segmentId,\n async () => {\n // Do NOT use the caller's signal — same reasoning as #getScrubVideoSampleForFrame:\n // aborting this caller must not cancel the shared BufferedSeekingInput construction\n // for all other callers waiting on the same segment.\n const initP = mediaEngine.transport.fetchInitSegment(scrubTrack);\n const mediaP = mediaEngine.transport.fetchMediaSegment(segmentId, scrubTrack);\n initP.catch(() => {});\n mediaP.catch(() => {});\n const [initSegment, mediaSegment] = await Promise.all([initP, mediaP]);\n\n if (!initSegment || !mediaSegment) {\n return undefined;\n }\n\n const combinedBlob = new Blob([initSegment, mediaSegment]);\n const arrayBuffer = await combinedBlob.arrayBuffer();\n\n return new BufferedSeekingInput(arrayBuffer, {\n videoBufferSize: EFMedia.VIDEO_SAMPLE_BUFFER_SIZE,\n audioBufferSize: EFMedia.AUDIO_SAMPLE_BUFFER_SIZE,\n startTimeOffsetMs: scrubTrack.startTimeOffsetMs,\n });\n },\n );\n signal.throwIfAborted();\n\n if (!seekingInput) {\n return this.getVideoFrameAtSourceTime(sourceTimeMs, {\n quality: \"main\",\n signal,\n });\n }\n\n const seekingVideoTrack = await seekingInput.getFirstVideoTrack();\n signal.throwIfAborted();\n\n if (!seekingVideoTrack) {\n return this.getVideoFrameAtSourceTime(sourceTimeMs, {\n quality: \"main\",\n signal,\n });\n }\n\n videoSample = await seekingInput.seek(seekingVideoTrack.id, sourceTimeMs);\n signal.throwIfAborted();\n }\n\n if (!videoSample) {\n throw new Error(`No video sample found at ${sourceTimeMs}ms`);\n }\n\n return videoSample.toVideoFrame();\n } finally {\n this.playbackController?.resumeSelfRender();\n }\n }\n\n /**\n * Capture a video frame directly at a source media timestamp.\n * Designed for export/rendering.\n * Does NOT paint to the element's internal canvas.\n *\n * Uses the same routing logic as unified video system:\n * - \"auto\": main track for production rendering, follows normal routing otherwise\n * - \"scrub\": force low-res scrub track (for thumbnails)\n * - \"main\": force full-quality main track\n *\n * @param sourceTimeMs - Timestamp in source media coordinates (not timeline)\n * @param options - Capture options including quality and abort signal\n * @returns Frame data for serialization\n * @public\n */\n async captureFrameAtSourceTime(\n sourceTimeMs: number,\n options: {\n quality?: \"auto\" | \"scrub\" | \"main\";\n signal?: AbortSignal;\n } = {},\n ): Promise<{\n dataUrl: string;\n width: number;\n height: number;\n }> {\n const videoFrame = await this.getVideoFrameAtSourceTime(sourceTimeMs, options);\n\n try {\n options.signal?.throwIfAborted();\n\n const canvas = new OffscreenCanvas(videoFrame.codedWidth, videoFrame.codedHeight);\n const ctx = canvas.getContext(\"2d\");\n if (!ctx) {\n throw new Error(\"Failed to get 2d context from OffscreenCanvas\");\n }\n ctx.drawImage(videoFrame, 0, 0);\n\n options.signal?.throwIfAborted();\n\n const blob = await canvas.convertToBlob({\n type: \"image/jpeg\",\n quality: 0.92,\n });\n options.signal?.throwIfAborted();\n\n const dataUrl = await new Promise<string>((resolve, reject) => {\n const reader = new FileReader();\n reader.onload = () => resolve(reader.result as string);\n reader.onerror = reject;\n reader.readAsDataURL(blob);\n });\n options.signal?.throwIfAborted();\n\n return {\n dataUrl,\n width: videoFrame.codedWidth,\n height: videoFrame.codedHeight,\n };\n } finally {\n videoFrame.close();\n }\n }\n\n /**\n * Pre-fetch scrub segments for given timestamps.\n * Loads 30-second segments sequentially, emitting progress events.\n * This ensures scrub track is cached for fast thumbnail generation.\n *\n * @param timestamps - Array of timestamps (in ms) that will be captured\n * @param onProgress - Optional callback for loading progress\n * @param signal - Optional AbortSignal for cancellation\n * @returns Promise that resolves when all segments are cached\n * @public\n */\n async prefetchScrubSegments(\n timestamps: number[],\n onProgress?: (loaded: number, total: number, segmentTimeRange: [number, number]) => void,\n signal?: AbortSignal,\n ): Promise<void> {\n // Wait for media engine to be ready\n const mediaEngine = await this.getMediaEngine(signal);\n if (!mediaEngine) {\n log(\"prefetchScrubSegments: no media engine available\");\n return;\n }\n\n const scrubTrack = mediaEngine.tracks.scrub;\n if (!scrubTrack) {\n log(\"prefetchScrubSegments: no scrub rendition available\");\n return;\n }\n\n // Compute unique segment IDs needed for all timestamps\n const segmentIds = new Set<number>();\n for (const ts of timestamps) {\n const segmentId = mediaEngine.index.segmentAt(ts, scrubTrack);\n if (segmentId !== undefined) {\n segmentIds.add(segmentId);\n }\n }\n\n if (segmentIds.size === 0) {\n log(\"prefetchScrubSegments: no segments to prefetch\");\n return;\n }\n\n // Check if ANY segment is already cached (meaning the file is loaded).\n const firstSegmentId = Array.from(segmentIds)[0]!;\n if (mediaEngine.transport.isCached(firstSegmentId, scrubTrack)) {\n log(\"prefetchScrubSegments: scrub track already cached\");\n return;\n }\n\n log(`prefetchScrubSegments: fetching scrub track for ${segmentIds.size} segments...`);\n\n const durationMs = mediaEngine.durationMs || 0;\n this.dispatchEvent(\n new CustomEvent(\"scrub-segment-loading\", {\n detail: {\n segmentId: 0,\n timeRangeMs: [0, durationMs] as [number, number],\n loaded: 0,\n total: 1,\n status: \"loading\",\n },\n bubbles: true,\n composed: true,\n }),\n );\n\n const fetchSignal = signal ?? new AbortController().signal;\n try {\n await mediaEngine.transport.fetchMediaSegment(firstSegmentId, scrubTrack, fetchSignal);\n log(`prefetchScrubSegments: scrub track loaded`);\n } catch (error) {\n log(`prefetchScrubSegments: failed to load scrub track`, error);\n }\n\n this.dispatchEvent(\n new CustomEvent(\"scrub-segment-loading\", {\n detail: {\n segmentId: 0,\n timeRangeMs: [0, durationMs] as [number, number],\n loaded: 1,\n total: 1,\n status: \"loaded\",\n },\n bubbles: true,\n composed: true,\n }),\n );\n\n onProgress?.(1, 1, [0, durationMs]);\n log(`prefetchScrubSegments: complete`);\n }\n\n /**\n * Warm the scrub BufferedSeekingInput cache for all segments covering [fromMs, toMs].\n *\n * Unlike prefetchScrubSegments (which only warms the network layer), this method\n * constructs BufferedSeekingInput instances for each segment so that subsequent\n * scrub seeks within the range complete without a network round-trip or BSI\n * construction overhead.\n *\n * Returns a Promise that resolves after the range has been computed and segment\n * fetches have been kicked off (but before individual fetches complete).\n * Callers may await this or discard the promise — both are valid.\n */\n async warmScrubCacheForRange(fromMs: number, toMs: number, signal?: AbortSignal): Promise<void> {\n const mediaEngine = await this.getMediaEngine(signal);\n if (!mediaEngine) return;\n if (signal?.aborted) return;\n\n const scrubTrack = mediaEngine.tracks.scrub;\n if (!scrubTrack) return;\n\n const segments = mediaEngine.index.segmentsInRange(fromMs, toMs, scrubTrack);\n if (segments.length === 0) return;\n\n const { src } = mediaEngine;\n\n // Kick off all segment fetches concurrently. ScrubInputCache deduplicates\n // concurrent requests for the same segment, so this is safe.\n for (const { segmentId } of segments) {\n if (signal?.aborted) return;\n\n // getOrCreateInput is idempotent — returns immediately if already cached.\n const capturedSegmentId = segmentId;\n this.#scrubInputCache\n .getOrCreateInput(src, capturedSegmentId, async () => {\n if (signal?.aborted) return undefined;\n let initSegment: ArrayBuffer | undefined;\n let mediaSegment: ArrayBuffer | undefined;\n try {\n [initSegment, mediaSegment] = await Promise.all([\n mediaEngine.transport.fetchInitSegment(scrubTrack),\n mediaEngine.transport.fetchMediaSegment(capturedSegmentId, scrubTrack),\n ]);\n } catch {\n return undefined;\n }\n if (!initSegment || !mediaSegment) return undefined;\n\n const combinedBlob = new Blob([initSegment, mediaSegment]);\n const arrayBuffer = await combinedBlob.arrayBuffer();\n const { BufferedSeekingInput } = await import(\"./EFMedia/BufferedSeekingInput.js\");\n return new BufferedSeekingInput(arrayBuffer, {\n videoBufferSize: EFMedia.VIDEO_SAMPLE_BUFFER_SIZE,\n audioBufferSize: EFMedia.AUDIO_SAMPLE_BUFFER_SIZE,\n startTimeOffsetMs: scrubTrack.startTimeOffsetMs,\n });\n })\n .catch(() => {\n // Ignore prefetch errors — they're best-effort\n });\n }\n }\n\n /**\n * Maybe schedule quality upgrade tasks for this element.\n * Called when returning a scrub sample - checks if state has changed and submits tasks.\n */\n #maybeScheduleQualityUpgrade(mediaEngine: MediaEngine, sourceTimeMs: number): void {\n if (this.#renderingToVideo) return;\n if (this.rootTimegroup?.isRenderClone) return;\n const mainTrack = mediaEngine.tracks.video;\n if (!mainTrack) return;\n\n const segmentId = mediaEngine.index.segmentAt(sourceTimeMs, mainTrack);\n if (segmentId === undefined) return;\n\n const startTimeMs = this.startTimeMs;\n\n const stateChanged =\n this.#upgradeState === null ||\n this.#upgradeState.segmentId !== segmentId ||\n this.#upgradeState.startTimeMs !== startTimeMs;\n\n if (!stateChanged) {\n const currentTaskKey = `${this.#upgradeOwnerId}:${segmentId}:${mainTrack.id}`;\n const scheduler = this.rootTimegroup?.qualityUpgradeScheduler;\n if (scheduler?.isActive(currentTaskKey) || scheduler?.isPending(currentTaskKey)) {\n return;\n }\n this.rootTimegroup?.qualityUpgradeScheduler?.cancelForOwner(this.#upgradeOwnerId);\n this.#upgradeState = null;\n }\n\n const segments = this.#computeLookaheadSegments(mediaEngine, sourceTimeMs, mainTrack);\n if (segments.length === 0) return;\n\n // Capture src at task-creation time to guard the isCached closure against\n // stale mediaEngine references after a src change.\n const capturedSrc = mediaEngine.src;\n const tasks = segments.map((seg) => ({\n key: `${this.#upgradeOwnerId}:${seg.segmentId}:${mainTrack.id}`,\n fetch: async (signal: AbortSignal) => {\n await mediaEngine.transport.fetchInitSegment(mainTrack, signal);\n await mediaEngine.transport.fetchMediaSegment(seg.segmentId, mainTrack, signal);\n },\n isCached: () => {\n // Guard: if src changed since tasks were created, treat as not cached so\n // replaceForOwner does not skip re-queuing for the new video's segments.\n if (this.mediaEngineTask?.value?.src !== capturedSrc) return false;\n return mediaEngine.transport.isCached(seg.segmentId, mainTrack);\n },\n deadlineMs: seg.deadlineMs,\n owner: this.#upgradeOwnerId,\n }));\n\n const scheduler = this.rootTimegroup?.qualityUpgradeScheduler;\n if (scheduler) {\n scheduler.replaceForOwner(this.#upgradeOwnerId, tasks);\n } else {\n this.#fetchStandalone(tasks);\n }\n\n this.#upgradeState = {\n sourceTimeMs,\n segmentId,\n startTimeMs,\n submittedKeys: new Set(tasks.map((t) => t.key)),\n };\n }\n\n /**\n * Compute lookahead segments with deadlines in timeline space.\n */\n #computeLookaheadSegments(\n mediaEngine: MediaEngine,\n currentSourceTimeMs: number,\n track: import(\"./EFMedia/SegmentIndex.js\").TrackRef,\n maxLookahead: number = 5,\n ): { segmentId: number; deadlineMs: number }[] {\n const results: { segmentId: number; deadlineMs: number }[] = [];\n const playheadMs = this.rootTimegroup?.currentTimeMs ?? 0;\n const seen = new Set<number>();\n\n let probeTimeMs = currentSourceTimeMs;\n\n while (seen.size < maxLookahead) {\n const segmentId = mediaEngine.index.segmentAt(probeTimeMs, track);\n if (segmentId === undefined) break;\n if (seen.has(segmentId)) break;\n\n seen.add(segmentId);\n\n if (!mediaEngine.transport.isCached(segmentId, track)) {\n const offsetFromCurrentMs = probeTimeMs - currentSourceTimeMs;\n const deadlineMs = playheadMs + offsetFromCurrentMs;\n results.push({ segmentId, deadlineMs });\n }\n\n const thisDuration =\n track.segmentDurationsMs?.[segmentId - 1] ?? track.segmentDurationMs ?? 2000;\n probeTimeMs += thisDuration;\n }\n\n return results;\n }\n\n /**\n * Standalone mode: fetch tasks sequentially without scheduler.\n */\n #fetchStandalone(tasks: any[]): void {\n // Abort any previous standalone batch (e.g., after seek)\n this.#standaloneUpgradeController?.abort();\n this.#standaloneUpgradeController = new AbortController();\n const signal = this.#standaloneUpgradeController.signal;\n\n // Process sequentially\n (async () => {\n for (const task of tasks) {\n if (signal.aborted) break;\n try {\n await task.fetch(signal);\n } catch {\n // Continue on error\n }\n }\n // After all tasks complete, trigger re-render.\n // By the time the fetch completes the element is typically connected to a\n // timegroup, so prefer rootTimegroup.requestFrameRender(). Fall back to\n // playbackController for the standalone (no-timegroup) case.\n if (!signal.aborted) {\n if (this.rootTimegroup) {\n this.rootTimegroup.requestFrameRender();\n } else {\n this.playbackController?.runThrottledFrameTask().catch(() => {});\n }\n }\n })().catch(() => {});\n }\n\n /**\n * Invalidate upgrade state and optionally cancel queued tasks.\n */\n #invalidateUpgradeState(reason: \"src-change\" | \"bounds-change\" | \"disconnect\"): void {\n if (reason === \"src-change\" || reason === \"disconnect\") {\n // Full cancel - old tasks reference a stale media engine\n this.rootTimegroup?.qualityUpgradeScheduler?.cancelForOwner(this.#upgradeOwnerId);\n }\n // For bounds-change, don't cancel - old tasks may still be valid segments,\n // just with stale deadlines. replaceForOwner on next prepareFrame handles it.\n this.#upgradeState = null;\n }\n\n /**\n * Reset per-instance caches and rendition state. Allows tests to force\n * the scrub fallback path on the next render without clearing the shared\n * mediaCache (which races with in-flight fetches from other elements).\n * @public – test-only\n */\n clearInstanceCaches(): void {\n this.#cachedVideoSample = undefined;\n this.#cachedVideoSampleTimeMs = undefined;\n this.#currentRenditionId = undefined;\n this.#upgradeState = null;\n this.#mainVideoInputCache.clear();\n this.#scrubInputCache.clear();\n }\n\n /**\n * Returns scrub cache statistics for inspection in tests.\n * @public – test-only\n */\n getScrubCacheStats(): ReturnType<ScrubInputCache[\"getStats\"]> {\n return this.#scrubInputCache.getStats();\n }\n\n /**\n * Clean up resources when component is disconnected\n */\n disconnectedCallback(): void {\n super.disconnectedCallback();\n\n this.#cachedVideoSample = undefined;\n this.#cachedVideoSampleTimeMs = undefined;\n\n // Clean up delayed loading state\n this.#delayedLoadingState.clearAllLoading();\n\n // Cancel upgrade tasks (centralized or standalone)\n this.#invalidateUpgradeState(\"disconnect\");\n this.#standaloneUpgradeController?.abort();\n this.#standaloneUpgradeController = null;\n }\n\n didBecomeRoot() {\n super.didBecomeRoot();\n }\n didBecomeChild() {\n super.didBecomeChild();\n }\n\n /**\n * Get the natural dimensions of the video (coded width and height).\n * Returns null if the video hasn't loaded yet or canvas isn't available.\n *\n * @public\n */\n getNaturalDimensions(): { width: number; height: number } | null {\n const canvas = this.canvasElement;\n if (!canvas || canvas.width === 0 || canvas.height === 0) {\n return null;\n }\n return {\n width: canvas.width,\n height: canvas.height,\n };\n }\n\n /**\n * Render this video element to an MP4 using the direct video-to-video fast path.\n * Bypasses DOM serialization — decodes frames directly and re-encodes to MP4.\n * Respects trim, CSS filter, and opacity.\n *\n * @param options - Rendering options (fps, codec, bitrate, etc.)\n * @returns Promise resolving to video buffer (if returnBuffer), or undefined\n * @public\n */\n async renderToVideo(\n options?: import(\"../preview/renderTimegroupToVideo.types.js\").RenderToVideoOptions,\n ): Promise<Uint8Array | undefined> {\n this.#renderingToVideo = true;\n try {\n const { renderVideoToVideo } = await import(\"../preview/renderVideoToVideo.js\");\n return await renderVideoToVideo(this, options);\n } finally {\n this.#renderingToVideo = false;\n }\n }\n}\n\ndeclare global {\n interface HTMLElementTagNameMap {\n \"ef-video\": EFVideo;\n }\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"EFVideo.js","names":["#resolve","EFVideo","#readPinnedSourceTimeMs","#currentRenditionId","#cachedVideoSample","#cachedVideoSampleTimeMs","#delayedLoadingState","#fetchVideoSampleForFrame","#getMainVideoSampleForFrame","#getScrubVideoSampleForFrame","#maybeScheduleQualityUpgrade","#scrubInputCache","initSegment: ArrayBuffer | undefined","mediaSegment: ArrayBuffer | undefined","#mainVideoInputCache","#invalidateUpgradeState","#prewarmQualityUpgrade","videoSample: any","#renderingToVideo","#upgradeState","#upgradeOwnerId","scheduler","#computeLookaheadSegments","#fetchStandalone","results: { segmentId: number; deadlineMs: number }[]","#standaloneUpgradeController"],"sources":["../../src/elements/EFVideo.ts"],"sourcesContent":["import { context, trace } from \"@opentelemetry/api\";\nimport debug from \"debug\";\nimport { css, html, type PropertyValueMap } from \"lit\";\nimport { customElement, property, state } from \"lit/decorators.js\";\nimport { createRef, ref } from \"lit/directives/ref.js\";\nimport type { VideoSample } from \"mediabunny\";\nimport { DelayedLoadingState } from \"../DelayedLoadingState.js\";\nimport { TWMixin } from \"../gui/TWMixin.js\";\nimport { withSpanSync } from \"../otel/tracingHelpers.js\";\nimport {\n type FrameRenderable,\n type FrameState,\n PRIORITY_VIDEO,\n} from \"../preview/FrameController.js\";\nimport type { MediaEngine } from \"../transcoding/types/index.ts\";\nimport { MainVideoInputCache } from \"./EFMedia/videoTasks/MainVideoInputCache.ts\";\nimport { ScrubInputCache } from \"./EFMedia/videoTasks/ScrubInputCache.ts\";\nimport { durationConverter } from \"./durationConverter.js\";\nimport { parseTimeToMs } from \"./parseTimeToMs.js\";\nimport { EFMedia } from \"./EFMedia.js\";\nimport { updateAnimations } from \"./updateAnimations.js\";\n\n// NOTE: These caches are intentionally per-instance (created in EFVideo class body),\n// not module-level singletons. Sharing between live player and render clones causes\n// seek-lock deadlocks: the thumbnail generator holds a BufferedSeekingInput seek lock\n// while the live player waits on the same lock indefinitely.\n\n// EF_FRAMEGEN is a global instance created in EF_FRAMEGEN.ts\ndeclare global {\n var EF_FRAMEGEN: import(\"../EF_FRAMEGEN.js\").EFFramegen;\n}\n\nconst log = debug(\"ef:elements:EFVideo\");\n\ninterface LoadingState {\n isLoading: boolean;\n operation: \"scrub-segment\" | \"video-segment\" | \"seeking\" | \"decoding\" | null;\n message: string;\n}\n\n/**\n * Event detail for scrub segment loading progress.\n * Dispatched during prefetchScrubSegments to indicate network activity.\n */\nexport interface ScrubSegmentLoadingDetail {\n /** The segment ID being loaded (0-indexed) */\n segmentId: number;\n /** Time range covered by this segment [startMs, endMs] */\n timeRangeMs: [number, number];\n /** Number of segments loaded so far */\n loaded: number;\n /** Total number of segments to load */\n total: number;\n /** Current status: \"loading\" or \"loaded\" */\n status: \"loading\" | \"loaded\";\n}\n\nclass VideoSeekTask {\n value: VideoSample | undefined = undefined;\n task: ((...args: any[]) => any) | undefined = undefined;\n\n #resolve: ((v: VideoSample | undefined) => void) | undefined;\n taskComplete: Promise<VideoSample | undefined> = Promise.resolve(undefined);\n\n begin(): void {\n this.taskComplete = new Promise<VideoSample | undefined>((resolve) => {\n this.#resolve = resolve;\n });\n }\n\n complete(sample: VideoSample | undefined): void {\n this.value = sample;\n this.#resolve?.(sample);\n }\n\n abort(): void {\n this.#resolve?.(undefined);\n }\n}\n\n@customElement(\"ef-video\")\nexport class EFVideo extends TWMixin(EFMedia) implements FrameRenderable {\n static styles = [\n css`\n :host {\n display: block;\n position: relative;\n object-fit: contain;\n object-position: center;\n }\n canvas {\n overflow: hidden;\n position: static;\n width: 100%;\n height: 100%;\n object-fit: inherit;\n object-position: inherit;\n margin: 0;\n padding: 0;\n border: none;\n outline: none;\n box-shadow: none;\n }\n .loading-overlay {\n position: absolute;\n top: 0;\n left: 0;\n right: 0;\n height: 2px;\n overflow: hidden;\n z-index: 10;\n pointer-events: none;\n background: var(--ef-color-loading-spinner-track, rgba(255, 255, 255, 0.1));\n }\n .loading-bar {\n position: absolute;\n top: 0;\n height: 100%;\n width: 40%;\n background: var(--ef-color-loading-spinner-fill, rgba(255, 255, 255, 0.8));\n animation: loading-sweep 1.4s ease-in-out infinite;\n }\n @keyframes loading-sweep {\n 0% { left: -40%; }\n 100% { left: 140%; }\n }\n `,\n ];\n canvasRef = createRef<HTMLCanvasElement>();\n unifiedVideoSeekTask = new VideoSeekTask();\n\n // ── current-time (freeze frame) ─────────────────────────────────────────────\n\n /**\n * Pin the video to a specific source time, making it a freeze frame.\n *\n * When set, `currentSourceTimeMs` ignores the composition timeline and\n * always returns this value — the same frame is shown for the entire\n * duration of the element. Pair with `duration` to control how long the\n * freeze frame occupies in the timeline:\n *\n * `<ef-video src=\"clip.webm\" current-time=\"4.367s\" duration=\"2s\" alpha>`\n *\n * Accepts any duration string: `\"4.367s\"`, `\"4367ms\"`, etc.\n *\n * @domAttribute \"current-time\"\n */\n @property({ type: Number, attribute: \"current-time\", converter: durationConverter })\n pinnedSourceTimeMs: number | undefined = undefined;\n\n /**\n * Read the pinned source time, checking both the Lit-managed property\n * (set asynchronously via attributeChangedCallback) and the raw DOM attribute\n * (set synchronously at element creation). The raw attribute read is a fallback\n * for the window between element construction and Lit's first reactive update,\n * which otherwise causes the first prepareFrame call to see ownCurrentTimeMs=0.\n */\n #readPinnedSourceTimeMs(): number | undefined {\n if (this.pinnedSourceTimeMs !== undefined) return this.pinnedSourceTimeMs;\n const raw = this.getAttribute(\"current-time\");\n if (raw !== null) {\n try {\n return parseTimeToMs(raw);\n } catch {\n // malformed attribute — fall through\n }\n }\n return undefined;\n }\n\n /**\n * When `current-time` is set, return the pinned source time regardless of\n * where the composition playhead is.\n */\n override get currentSourceTimeMs(): number {\n const pinned = this.#readPinnedSourceTimeMs();\n if (pinned !== undefined) return pinned;\n return super.currentSourceTimeMs;\n }\n\n /**\n * When `current-time` is set, allow the `duration` attribute to override\n * the video's intrinsic duration so the timeline footprint is controlled\n * explicitly instead of being derived from the full video length.\n */\n override get intrinsicDurationMs(): number | undefined {\n const pinned = this.#readPinnedSourceTimeMs();\n if (pinned !== undefined && this.explicitDurationMs !== undefined) {\n return this.explicitDurationMs;\n }\n return super.intrinsicDurationMs;\n }\n\n // ============================================================================\n // FrameRenderable Implementation\n // Centralized frame control - no more Lit Tasks\n // ============================================================================\n\n /**\n * Cached video sample for the current frame.\n * Set by prepareFrame(), consumed by renderFrame().\n */\n #cachedVideoSample: VideoSample | undefined = undefined;\n #cachedVideoSampleTimeMs: number | undefined = undefined;\n\n /**\n * Quality upgrade intent tracking.\n * Tracks what upgrade tasks were last submitted to avoid redundant scheduler calls.\n */\n #upgradeState: {\n sourceTimeMs: number;\n segmentId: number;\n startTimeMs: number;\n submittedKeys: Set<string>;\n } | null = null;\n\n /**\n * Standalone upgrade controller for elements without a timegroup.\n */\n #standaloneUpgradeController: AbortController | null = null;\n\n // Per-instance caches. NOT module-level singletons: sharing BufferedSeekingInput\n // instances between the live player and render clones causes seek-lock deadlocks —\n // the thumbnail generator holds a lock while the live player waits on it indefinitely.\n #mainVideoInputCache = new MainVideoInputCache();\n #scrubInputCache = new ScrubInputCache();\n\n /**\n * Set to true while renderToVideo is executing to suppress background\n * quality upgrade tasks that would race with the render pipeline.\n */\n #renderingToVideo = false;\n\n /**\n * Stable per-instance identifier for the quality upgrade scheduler.\n * Uses this.id when available; falls back to a generated unique string so\n * elements without an id attribute never collide with each other.\n */\n #upgradeOwnerId: string = crypto.randomUUID();\n\n /**\n * Current rendition being displayed (for observability).\n */\n #currentRenditionId: \"main\" | \"scrub\" | undefined = undefined;\n\n /**\n * Get the current rendition being displayed.\n * @public\n */\n get currentRenditionId(): \"main\" | \"scrub\" | undefined {\n return this.#currentRenditionId;\n }\n\n /**\n * Query readiness state for a given time.\n * @implements FrameRenderable\n *\n * Note: The timeMs parameter is the root timegroup's time. We check against\n * this.currentSourceTimeMs since that's what we cache in prepareFrame.\n */\n getFrameState(_timeMs: number): FrameState {\n // Use element's source time to match what prepareFrame caches\n const sourceTimeMs = this.currentSourceTimeMs;\n\n // Check if we have a cached sample for this exact source time\n const hasCache =\n this.#cachedVideoSample !== undefined && this.#cachedVideoSampleTimeMs === sourceTimeMs;\n\n // If we're on scrub quality and main segment is cached, signal upgrade needed.\n // Skip in render clones — they must stay on scrub for thumbnail generation speed.\n if (this.#currentRenditionId === \"scrub\" && !this.rootTimegroup?.isRenderClone) {\n const mediaEngine = this.mediaEngineTask.value;\n if (mediaEngine) {\n const mainTrack = mediaEngine.tracks.video;\n if (mainTrack) {\n const mainSegmentId = mediaEngine.index.segmentAt(sourceTimeMs, mainTrack);\n if (\n mainSegmentId !== undefined &&\n mediaEngine.transport.isCached(mainSegmentId, mainTrack)\n ) {\n return { needsPreparation: true, isReady: false, priority: PRIORITY_VIDEO };\n }\n }\n }\n }\n\n return { needsPreparation: !hasCache, isReady: hasCache, priority: PRIORITY_VIDEO };\n }\n\n /**\n * Async preparation - seeks video and caches the sample.\n * @implements FrameRenderable\n *\n * Note: The timeMs parameter is the root timegroup's time. We ignore it and\n * use this.currentSourceTimeMs instead, which accounts for:\n * - Our position within the parent timegroup (ownCurrentTimeMs)\n * - Source trimming (sourceIn/sourceOut or trimStart/trimEnd)\n */\n async prepareFrame(_timeMs: number, signal: AbortSignal): Promise<void> {\n signal.throwIfAborted();\n this.unifiedVideoSeekTask.begin();\n this.#delayedLoadingState.startLoading(\"prepare-frame\", \"\");\n\n try {\n // Use element's source time, not the passed root timegroup time.\n // currentSourceTimeMs = ownCurrentTimeMs + (sourceIn || trimStart || 0)\n // This correctly maps timeline position to actual media time.\n const sourceTimeMs = this.currentSourceTimeMs;\n\n const mediaEngine = await this.getMediaEngine(signal);\n if (!mediaEngine) {\n this.#cachedVideoSample = undefined;\n this.#cachedVideoSampleTimeMs = sourceTimeMs;\n this.unifiedVideoSeekTask.complete(undefined);\n return;\n }\n\n signal.throwIfAborted();\n\n // Fetch video sample at the correct source time\n // Handle errors gracefully so one failed seek doesn't break subsequent frames\n try {\n const videoSample = await this.#fetchVideoSampleForFrame(mediaEngine, sourceTimeMs, signal);\n\n // Cache before checking abort: a successful seek result is valuable even\n // if the render is being superseded. Writing the cache here ensures the\n // next render (triggered by the same abort) finds a warm cache and avoids\n // re-seeking. Without this ordering the abort fires, cache is never written,\n // the next render re-seeks, gets aborted again — infinite loop, black canvas.\n this.#cachedVideoSample = videoSample;\n this.#cachedVideoSampleTimeMs = sourceTimeMs;\n this.unifiedVideoSeekTask.complete(videoSample);\n\n signal.throwIfAborted();\n } catch (error) {\n // Re-throw abort errors to properly handle cancellation.\n // If complete() was already called above, abort() is a no-op on the\n // already-resolved promise and does not clear .value.\n if (error instanceof DOMException && error.name === \"AbortError\") {\n this.unifiedVideoSeekTask.abort();\n throw error;\n }\n\n // For seek errors (NoSample, out of bounds, etc.), just clear cache\n // This allows subsequent frames to retry instead of being stuck\n console.warn(`Video seek error at ${sourceTimeMs}ms:`, error);\n this.#cachedVideoSample = undefined;\n this.#cachedVideoSampleTimeMs = sourceTimeMs;\n this.unifiedVideoSeekTask.complete(undefined);\n }\n } finally {\n this.#delayedLoadingState.clearLoading(\"prepare-frame\");\n }\n }\n\n /**\n * Synchronous render - paints cached video sample to canvas.\n * @implements FrameRenderable\n *\n * Note: The timeMs parameter is the root timegroup's time. We use\n * this.currentSourceTimeMs to match what prepareFrame cached.\n */\n renderFrame(_timeMs: number): void {\n // Use element's source time to match what was cached in prepareFrame\n const sourceTimeMs = this.currentSourceTimeMs;\n\n // Use cached sample if available for this source time\n if (this.#cachedVideoSampleTimeMs === sourceTimeMs && this.#cachedVideoSample) {\n try {\n const videoFrame = this.#cachedVideoSample.toVideoFrame();\n try {\n this.displayFrame(videoFrame, sourceTimeMs);\n } finally {\n videoFrame.close();\n }\n } catch {\n // VideoSample was evicted from SampleBuffer between prepareFrame and renderFrame.\n // Clear the cache so the next render cycle re-seeks.\n this.#cachedVideoSample = undefined;\n }\n }\n\n // Update animations if not in parent timegroup\n if (!this.parentTimegroup) {\n updateAnimations(this);\n }\n }\n\n /**\n * Fetch video sample for a given time.\n *\n * Uses a quality routing strategy:\n * - In production rendering: always use main (full quality) track\n * - In preview mode: try scrub track first for faster scrubbing, fall back to main\n * - If main track segment is already cached: use it (avoid redundant lower-quality fetch)\n */\n async #fetchVideoSampleForFrame(\n mediaEngine: MediaEngine,\n desiredSeekTimeMs: number,\n signal: AbortSignal,\n ): Promise<VideoSample | undefined> {\n const mainTrack = mediaEngine.tracks.video;\n\n // FIRST: Check if main quality content is already cached - use it if so\n if (mainTrack) {\n const mainSegmentId = mediaEngine.index.segmentAt(desiredSeekTimeMs, mainTrack);\n const mainCached =\n mainSegmentId !== undefined && mediaEngine.transport.isCached(mainSegmentId, mainTrack);\n if (mainCached) {\n this.#currentRenditionId = \"main\";\n return this.#getMainVideoSampleForFrame(mediaEngine, desiredSeekTimeMs, signal);\n }\n }\n\n // SECOND: In production rendering mode, always use main (full quality) track\n if (this.isInProductionRenderingMode()) {\n this.#currentRenditionId = \"main\";\n return this.#getMainVideoSampleForFrame(mediaEngine, desiredSeekTimeMs, signal);\n }\n\n // THIRD: In preview mode, try scrub track first for faster scrubbing\n const scrubTrack = mediaEngine.tracks.scrub;\n if (scrubTrack) {\n const scrubSample = await this.#getScrubVideoSampleForFrame(\n mediaEngine,\n desiredSeekTimeMs,\n signal,\n );\n if (scrubSample) {\n this.#currentRenditionId = \"scrub\";\n // Got scrub - schedule background quality upgrade\n this.#maybeScheduleQualityUpgrade(mediaEngine, desiredSeekTimeMs);\n return scrubSample;\n }\n }\n\n // FOURTH: Fall back to main video path\n this.#currentRenditionId = \"main\";\n return this.#getMainVideoSampleForFrame(mediaEngine, desiredSeekTimeMs, signal);\n }\n\n /**\n * Get scrub (low-resolution) video sample for fast preview scrubbing.\n * Used in preview mode for faster response during timeline scrubbing.\n */\n async #getScrubVideoSampleForFrame(\n mediaEngine: MediaEngine,\n desiredSeekTimeMs: number,\n signal: AbortSignal,\n ): Promise<VideoSample | undefined> {\n const scrubTrack = mediaEngine.tracks.scrub;\n if (!scrubTrack) {\n return undefined;\n }\n\n const segmentId = mediaEngine.index.segmentAt(desiredSeekTimeMs, scrubTrack);\n if (segmentId === undefined) {\n return undefined;\n }\n\n const scrubInput = await this.#scrubInputCache.getOrCreateInput(\n mediaEngine.src,\n segmentId,\n async () => {\n // Do NOT use the caller's signal — same reasoning as #getMainVideoSampleForFrame:\n // aborting this caller must not cancel the shared BufferedSeekingInput construction\n // for all other callers waiting on the same segment.\n let initSegment: ArrayBuffer | undefined;\n let mediaSegment: ArrayBuffer | undefined;\n\n try {\n [initSegment, mediaSegment] = await Promise.all([\n mediaEngine.transport.fetchInitSegment(scrubTrack),\n mediaEngine.transport.fetchMediaSegment(segmentId, scrubTrack),\n ]);\n } catch (error) {\n return undefined;\n }\n\n if (!initSegment || !mediaSegment) {\n return undefined;\n }\n\n const combinedBlob = new Blob([initSegment, mediaSegment]);\n const arrayBuffer = await combinedBlob.arrayBuffer();\n\n const { BufferedSeekingInput } = await import(\"./EFMedia/BufferedSeekingInput.js\");\n\n return new BufferedSeekingInput(arrayBuffer, {\n videoBufferSize: EFMedia.VIDEO_SAMPLE_BUFFER_SIZE,\n audioBufferSize: EFMedia.AUDIO_SAMPLE_BUFFER_SIZE,\n startTimeOffsetMs: scrubTrack.startTimeOffsetMs,\n });\n },\n );\n\n if (!scrubInput) {\n return undefined;\n }\n\n signal.throwIfAborted();\n\n const videoTrack = await scrubInput.getFirstVideoTrack();\n if (!videoTrack) {\n return undefined;\n }\n\n signal.throwIfAborted();\n\n return scrubInput.seek(videoTrack.id, desiredSeekTimeMs) as Promise<VideoSample | undefined>;\n }\n\n /**\n * Get main video sample for a given time.\n */\n async #getMainVideoSampleForFrame(\n mediaEngine: MediaEngine,\n desiredSeekTimeMs: number,\n signal: AbortSignal,\n ): Promise<VideoSample | undefined> {\n const videoTrack = mediaEngine.tracks.video;\n if (!videoTrack) {\n return undefined;\n }\n\n const segmentId = mediaEngine.index.segmentAt(desiredSeekTimeMs, videoTrack);\n if (segmentId === undefined) {\n return undefined;\n }\n\n const mainInput = await this.#mainVideoInputCache.getOrCreateInput(\n mediaEngine.src,\n segmentId,\n String(videoTrack.id),\n async () => {\n // Do NOT use the caller's signal here. This factory creates a shared\n // BufferedSeekingInput that will be reused by all callers for this\n // segment. If the caller aborts (e.g. seek away mid-upgrade), we must\n // still complete construction and cache the result — otherwise every\n // upgrade attempt that gets aborted restarts from scratch, and the\n // element is stuck on scrub quality indefinitely.\n let initSegment: ArrayBuffer | undefined;\n let mediaSegment: ArrayBuffer | undefined;\n\n try {\n const initP = mediaEngine.transport.fetchInitSegment(videoTrack);\n const mediaP = mediaEngine.transport.fetchMediaSegment(segmentId, videoTrack);\n [initSegment, mediaSegment] = await Promise.all([initP, mediaP]);\n } catch (error) {\n if (\n error instanceof Error &&\n (error.message.includes(\"401\") ||\n error.message.includes(\"UNAUTHORIZED\") ||\n error.message.includes(\"Failed to fetch\") ||\n error.message.includes(\"File not found\") ||\n error.message.includes(\"Media segment not found\") ||\n error.message.includes(\"Init segment not found\") ||\n error.message.includes(\"Track not found\"))\n ) {\n return undefined;\n }\n throw error;\n }\n\n if (!initSegment || !mediaSegment) {\n return undefined;\n }\n\n const combinedBlob = new Blob([initSegment, mediaSegment]);\n const arrayBuffer = await combinedBlob.arrayBuffer();\n\n const { BufferedSeekingInput } = await import(\"./EFMedia/BufferedSeekingInput.js\");\n\n return new BufferedSeekingInput(arrayBuffer, {\n videoBufferSize: EFMedia.VIDEO_SAMPLE_BUFFER_SIZE,\n audioBufferSize: EFMedia.AUDIO_SAMPLE_BUFFER_SIZE,\n startTimeOffsetMs: videoTrack.startTimeOffsetMs,\n });\n },\n );\n\n if (!mainInput) {\n return undefined;\n }\n\n signal.throwIfAborted();\n\n const videoTrackInfo = await mainInput.getFirstVideoTrack();\n if (!videoTrackInfo) {\n return undefined;\n }\n\n signal.throwIfAborted();\n\n const sample = (await mainInput.seek(videoTrackInfo.id, desiredSeekTimeMs)) as\n | VideoSample\n | undefined;\n return sample;\n }\n\n /**\n * Fetch the paired alpha matte sample at the given time.\n * Returns undefined if no alpha track is configured for this source.\n */\n // ============================================================================\n // End FrameRenderable Implementation\n // ============================================================================\n\n /**\n * Delayed loading state manager for user feedback\n */\n #delayedLoadingState: DelayedLoadingState;\n\n /**\n * Loading state for user feedback\n */\n @state()\n loadingState = {\n isLoading: false,\n operation: null as LoadingState[\"operation\"],\n message: \"\",\n };\n\n constructor() {\n super();\n\n // Initialize delayed loading state with callback to update UI\n // 400 ms delay before showing the loading bar. Decoder-warm seeks (backward\n // within the 90-frame buffer, or forward sequential decode) complete well under\n // this threshold and never trigger the bar. Only genuine cold-start situations\n // — first segment load or a segment boundary crossing — are slow enough to show.\n this.#delayedLoadingState = new DelayedLoadingState(400, (isLoading, message) => {\n this.setLoadingState(isLoading, null, message);\n });\n }\n\n protected updated(changedProperties: PropertyValueMap<any> | Map<PropertyKey, unknown>): void {\n super.updated(changedProperties);\n\n // Invalidate upgrade state on src/fileId change\n if (changedProperties.has(\"src\") || changedProperties.has(\"fileId\")) {\n this.#cachedVideoSample = undefined;\n this.#cachedVideoSampleTimeMs = undefined;\n this.#mainVideoInputCache.clear();\n this.#scrubInputCache.clear();\n this.#invalidateUpgradeState(\"src-change\");\n this.#prewarmQualityUpgrade();\n }\n\n // Invalidate upgrade state on trim/source changes\n const durationAffectingProps = [\"_trimStartMs\", \"_trimEndMs\", \"_sourceInMs\", \"_sourceOutMs\"];\n const hasDurationChange = durationAffectingProps.some((prop) => changedProperties.has(prop));\n if (hasDurationChange) {\n this.#invalidateUpgradeState(\"bounds-change\");\n this.#prewarmQualityUpgrade();\n }\n\n // No need to clear canvas - displayFrame() overwrites it completely\n // and clearing creates blank frame gaps during transitions\n }\n\n /**\n * Eagerly load the media engine and pre-warm main-quality segments for the\n * start of this clip. Called when src/fileId or source bounds change so that\n * segments are already in cache by the time the element first becomes visible.\n *\n * Without pre-warming, quality upgrade only begins after the first scrub frame\n * is displayed, causing ~12 frames of blur at the cold-start of every clip.\n */\n #prewarmQualityUpgrade(): void {\n if (this.isInProductionRenderingMode()) return;\n if (!this.src && !this.fileId) return;\n\n this.getMediaEngine()\n .then((engine) => {\n if (!engine) return;\n // Use currentSourceTimeMs so the prewarm targets wherever the playhead\n // actually is, not just the clip start. Falls back to sourceInMs if the\n // element has no temporal context yet (e.g. not yet parented to a timegroup).\n const targetTimeMs = this.currentSourceTimeMs ?? this.sourceInMs ?? 0;\n this.#maybeScheduleQualityUpgrade(engine, targetTimeMs);\n })\n .catch(() => {});\n }\n\n render() {\n return html`\n <canvas ${ref(this.canvasRef)}></canvas>\n ${\n this.loadingState.isLoading\n ? html`<div class=\"loading-overlay\"><div class=\"loading-bar\"></div></div>`\n : \"\"\n }\n `;\n }\n\n get canvasElement() {\n const referencedCanvas = this.canvasRef.value;\n if (referencedCanvas) {\n return referencedCanvas;\n }\n const shadowCanvas = this.shadowRoot?.querySelector(\"canvas\");\n if (shadowCanvas) {\n return shadowCanvas;\n }\n return undefined;\n }\n\n /**\n * Start a delayed loading operation for testing\n */\n startDelayedLoading(\n operationId: string,\n message: string,\n options: { background?: boolean } = {},\n ): void {\n this.#delayedLoadingState.startLoading(operationId, message, options);\n }\n\n /**\n * Clear a delayed loading operation for testing\n */\n clearDelayedLoading(operationId: string): void {\n this.#delayedLoadingState.clearLoading(operationId);\n }\n\n /**\n * Set loading state for user feedback\n */\n private setLoadingState(\n isLoading: boolean,\n operation: LoadingState[\"operation\"] = null,\n message = \"\",\n ): void {\n this.loadingState = {\n isLoading,\n operation,\n message,\n };\n }\n\n /**\n * Paint the current video frame to canvas\n */\n paint(seekToMs: number, parentSpan?: any): void {\n const parentContext = parentSpan ? trace.setSpan(context.active(), parentSpan) : undefined;\n\n withSpanSync(\n \"video.paint\",\n {\n elementId: this.id || \"unknown\",\n seekToMs,\n src: this.src || \"none\",\n },\n parentContext,\n (span) => {\n const t0 = performance.now();\n\n // Check if we're in production rendering mode vs preview mode\n const isProductionRendering = this.isInProductionRenderingMode();\n const t1 = performance.now();\n span.setAttribute(\"isProductionRendering\", isProductionRendering);\n span.setAttribute(\"modeCheckMs\", t1 - t0);\n\n // Use cached video sample from prepareFrame\n try {\n const t2 = performance.now();\n const videoSample = this.#cachedVideoSample;\n span.setAttribute(\"hasVideoSample\", !!videoSample);\n span.setAttribute(\"valueAccessMs\", t2 - t1);\n\n if (videoSample) {\n const t3 = performance.now();\n const videoFrame = videoSample.toVideoFrame();\n const t4 = performance.now();\n span.setAttribute(\"toVideoFrameMs\", t4 - t3);\n\n try {\n const t5 = performance.now();\n this.displayFrame(videoFrame, seekToMs, span);\n const t6 = performance.now();\n span.setAttribute(\"displayFrameMs\", t6 - t5);\n } finally {\n videoFrame.close();\n }\n }\n } catch (error) {\n console.warn(\"Video pipeline error:\", error);\n }\n\n // EF_FRAMEGEN-aware rendering mode detection\n if (!isProductionRendering) {\n // Preview mode: always render\n // Visibility is handled by the phase/visibility system (CSS display:none)\n // No need to skip initialization frames - if element shouldn't be visible,\n // it will be hidden by CSS\n } else {\n // Production rendering mode: only render when EF_FRAMEGEN has explicitly started frame rendering\n // This prevents initialization frames before the actual render sequence begins\n if (!this.rootTimegroup) {\n span.setAttribute(\"skipped\", \"no-root-timegroup\");\n return;\n }\n\n if (!this.isFrameRenderingActive()) {\n span.setAttribute(\"skipped\", \"frame-rendering-not-active\");\n return; // Wait for EF_FRAMEGEN to start frame sequence\n }\n\n // Production mode: EF_FRAMEGEN has started frame sequence, proceed with rendering\n }\n\n const tEnd = performance.now();\n span.setAttribute(\"totalPaintMs\", tEnd - t0);\n },\n );\n }\n\n /**\n * Clear the canvas when element becomes inactive\n */\n clearCanvas(): void {\n if (!this.canvasElement) return;\n\n const ctx = this.canvasElement.getContext(\"2d\", {\n willReadFrequently: true,\n });\n if (ctx) {\n ctx.clearRect(0, 0, this.canvasElement.width, this.canvasElement.height);\n }\n }\n\n /**\n * Display a video frame on the canvas.\n * VP9-alpha VideoFrames carry alpha natively (I420A / RGBA) — ctx.drawImage\n * composites the alpha automatically when the canvas has alpha: true (default).\n */\n displayFrame(frame: VideoFrame, seekToMs: number, parentSpan?: any): void {\n const parentContext = parentSpan ? trace.setSpan(context.active(), parentSpan) : undefined;\n\n withSpanSync(\n \"video.displayFrame\",\n {\n elementId: this.id || \"unknown\",\n seekToMs,\n format: frame.format || \"unknown\",\n width: frame.codedWidth,\n height: frame.codedHeight,\n },\n parentContext,\n (span) => {\n const t0 = performance.now();\n\n log(\"trace: displayFrame start\", {\n seekToMs,\n frameFormat: frame.format,\n });\n\n if (!this.canvasElement) {\n log(\"trace: displayFrame aborted - no canvas element\");\n throw new Error(\n `Frame display failed: Canvas element is not available at time ${seekToMs}ms. The video component may not be properly initialized.`,\n );\n }\n const t1 = performance.now();\n span.setAttribute(\"getCanvasMs\", Math.round((t1 - t0) * 100) / 100);\n\n const ctx = this.canvasElement.getContext(\"2d\", {\n willReadFrequently: true,\n });\n const t2 = performance.now();\n span.setAttribute(\"getCtxMs\", Math.round((t2 - t1) * 100) / 100);\n\n if (!ctx) {\n log(\"trace: displayFrame aborted - no canvas context\");\n throw new Error(\n `Frame display failed: Unable to get 2D canvas context at time ${seekToMs}ms. This may indicate a browser compatibility issue or canvas corruption.`,\n );\n }\n\n const frameWidth = frame.displayWidth;\n const frameHeight = frame.displayHeight;\n\n let resized = false;\n if (frameWidth && frameHeight) {\n const needsResize =\n frameWidth > this.canvasElement.width || frameHeight > this.canvasElement.height;\n if (needsResize) {\n const newWidth = Math.max(this.canvasElement.width, frameWidth);\n const newHeight = Math.max(this.canvasElement.height, frameHeight);\n log(\"trace: updating canvas dimensions\", {\n width: newWidth,\n height: newHeight,\n });\n this.canvasElement.width = newWidth;\n this.canvasElement.height = newHeight;\n resized = true;\n const t3 = performance.now();\n span.setAttribute(\"resizeMs\", Math.round((t3 - t2) * 100) / 100);\n }\n }\n span.setAttribute(\"canvasResized\", resized);\n\n if (frame.format === null) {\n log(\"trace: displayFrame aborted - null frame format\");\n throw new Error(\n `Frame display failed: Video frame has null format at time ${seekToMs}ms. This indicates corrupted or incompatible video data.`,\n );\n }\n\n const tDrawStart = performance.now();\n ctx.clearRect(0, 0, this.canvasElement.width, this.canvasElement.height);\n ctx.drawImage(frame, 0, 0, this.canvasElement.width, this.canvasElement.height);\n const tDrawEnd = performance.now();\n span.setAttribute(\"drawImageMs\", Math.round((tDrawEnd - tDrawStart) * 100) / 100);\n span.setAttribute(\"totalDisplayMs\", Math.round((tDrawEnd - t0) * 100) / 100);\n span.setAttribute(\"canvasWidth\", this.canvasElement.width);\n span.setAttribute(\"canvasHeight\", this.canvasElement.height);\n\n log(\"trace: frame drawn to canvas\", { seekToMs });\n },\n );\n }\n\n /**\n * Check if we're in production rendering mode (EF_FRAMEGEN active) vs preview mode\n */\n private isInProductionRenderingMode(): boolean {\n // Check if EF_RENDERING function exists and returns true (production rendering)\n if (typeof window.EF_RENDERING === \"function\") {\n return window.EF_RENDERING();\n }\n\n // Check if workbench is in rendering mode\n const workbench = document.querySelector(\"ef-workbench\") as any;\n if (workbench?.rendering) {\n return true;\n }\n\n // Check if EF_FRAMEGEN exists and has render options (indicates active rendering)\n if (window.EF_FRAMEGEN?.renderOptions) {\n return true;\n }\n\n // Default to preview mode\n return false;\n }\n\n /**\n * Check if EF_FRAMEGEN has explicitly started frame rendering (not just initialization)\n */\n private isFrameRenderingActive(): boolean {\n if (!window.EF_FRAMEGEN?.renderOptions) {\n return false;\n }\n\n // In production mode, only render when EF_FRAMEGEN has actually begun frame sequence\n // Check if we're past the initialization phase by looking for explicit frame control\n const renderOptions = window.EF_FRAMEGEN.renderOptions;\n const renderStartTime = renderOptions.encoderOptions.fromMs;\n const currentTime = this.rootTimegroup?.currentTimeMs || 0;\n\n // We're in active frame rendering if:\n // 1. currentTime >= renderStartTime (includes the starting frame)\n return currentTime >= renderStartTime;\n }\n\n /**\n * Get a decoded VideoFrame at a specific source media timestamp.\n * Returns a standard WebCodecs VideoFrame — caller MUST call .close() when done.\n *\n * Uses the same routing logic as the unified video system:\n * - \"auto\": main track for production rendering, follows normal routing otherwise\n * - \"scrub\": force low-res scrub track (for thumbnails)\n * - \"main\": force full-quality main track\n *\n * @param sourceTimeMs - Timestamp in source media coordinates (not timeline)\n * @param options - Quality and abort signal\n * @returns VideoFrame that the caller must close()\n * @public\n */\n async getVideoFrameAtSourceTime(\n sourceTimeMs: number,\n options: {\n quality?: \"auto\" | \"scrub\" | \"main\";\n signal?: AbortSignal;\n } = {},\n ): Promise<VideoFrame> {\n const { quality = \"auto\", signal: providedSignal } = options;\n\n const signal = providedSignal ?? new AbortController().signal;\n signal.throwIfAborted();\n\n this.playbackController?.suspendSelfRender();\n try {\n const mediaEngine = await this.getMediaEngine(signal);\n signal.throwIfAborted();\n\n if (!mediaEngine) {\n throw new Error(\"No media engine available for frame capture\");\n }\n\n const useMainTrack =\n quality === \"main\" || (quality === \"auto\" && this.isInProductionRenderingMode());\n\n let videoSample: any;\n\n const { BufferedSeekingInput } = await import(\"./EFMedia/BufferedSeekingInput.js\");\n signal.throwIfAborted();\n\n if (useMainTrack) {\n const videoTrack = mediaEngine.tracks.video;\n if (!videoTrack) {\n throw new Error(\"No video rendition available\");\n }\n\n const segmentId = mediaEngine.index.segmentAt(sourceTimeMs, videoTrack);\n if (segmentId === undefined) {\n throw new Error(`Cannot compute segment ID for time ${sourceTimeMs}ms`);\n }\n\n const seekingInput = await this.#mainVideoInputCache.getOrCreateInput(\n mediaEngine.src,\n segmentId,\n String(videoTrack.id),\n async () => {\n // Do NOT use the caller's signal — same reasoning as #getMainVideoSampleForFrame:\n // aborting this caller must not cancel the shared BufferedSeekingInput construction\n // for all other callers waiting on the same segment.\n const initP = mediaEngine.transport.fetchInitSegment(videoTrack);\n const mediaP = mediaEngine.transport.fetchMediaSegment(segmentId, videoTrack);\n initP.catch(() => {});\n mediaP.catch(() => {});\n const [initSegment, mediaSegment] = await Promise.all([initP, mediaP]);\n\n if (!initSegment || !mediaSegment) {\n return undefined;\n }\n\n const combinedBlob = new Blob([initSegment, mediaSegment]);\n const arrayBuffer = await combinedBlob.arrayBuffer();\n\n return new BufferedSeekingInput(arrayBuffer, {\n videoBufferSize: EFMedia.VIDEO_SAMPLE_BUFFER_SIZE,\n audioBufferSize: EFMedia.AUDIO_SAMPLE_BUFFER_SIZE,\n startTimeOffsetMs: videoTrack.startTimeOffsetMs,\n });\n },\n );\n signal.throwIfAborted();\n\n if (!seekingInput) {\n throw new Error(`Failed to fetch video segments for time ${sourceTimeMs}ms`);\n }\n\n const seekingVideoTrack = await seekingInput.getFirstVideoTrack();\n signal.throwIfAborted();\n\n if (!seekingVideoTrack) {\n throw new Error(\"No video track found in segment\");\n }\n\n videoSample = await seekingInput.seek(seekingVideoTrack.id, sourceTimeMs);\n signal.throwIfAborted();\n } else {\n const scrubTrack = mediaEngine.tracks.scrub;\n if (!scrubTrack) {\n return this.getVideoFrameAtSourceTime(sourceTimeMs, {\n quality: \"main\",\n signal,\n });\n }\n\n const segmentId = mediaEngine.index.segmentAt(sourceTimeMs, scrubTrack);\n\n if (segmentId === undefined) {\n throw new Error(`Cannot compute scrub segment ID for time ${sourceTimeMs}ms`);\n }\n\n const seekingInput = await this.#scrubInputCache.getOrCreateInput(\n mediaEngine.src,\n segmentId,\n async () => {\n // Do NOT use the caller's signal — same reasoning as #getScrubVideoSampleForFrame:\n // aborting this caller must not cancel the shared BufferedSeekingInput construction\n // for all other callers waiting on the same segment.\n const initP = mediaEngine.transport.fetchInitSegment(scrubTrack);\n const mediaP = mediaEngine.transport.fetchMediaSegment(segmentId, scrubTrack);\n initP.catch(() => {});\n mediaP.catch(() => {});\n const [initSegment, mediaSegment] = await Promise.all([initP, mediaP]);\n\n if (!initSegment || !mediaSegment) {\n return undefined;\n }\n\n const combinedBlob = new Blob([initSegment, mediaSegment]);\n const arrayBuffer = await combinedBlob.arrayBuffer();\n\n return new BufferedSeekingInput(arrayBuffer, {\n videoBufferSize: EFMedia.VIDEO_SAMPLE_BUFFER_SIZE,\n audioBufferSize: EFMedia.AUDIO_SAMPLE_BUFFER_SIZE,\n startTimeOffsetMs: scrubTrack.startTimeOffsetMs,\n });\n },\n );\n signal.throwIfAborted();\n\n if (!seekingInput) {\n return this.getVideoFrameAtSourceTime(sourceTimeMs, {\n quality: \"main\",\n signal,\n });\n }\n\n const seekingVideoTrack = await seekingInput.getFirstVideoTrack();\n signal.throwIfAborted();\n\n if (!seekingVideoTrack) {\n return this.getVideoFrameAtSourceTime(sourceTimeMs, {\n quality: \"main\",\n signal,\n });\n }\n\n videoSample = await seekingInput.seek(seekingVideoTrack.id, sourceTimeMs);\n signal.throwIfAborted();\n }\n\n if (!videoSample) {\n throw new Error(`No video sample found at ${sourceTimeMs}ms`);\n }\n\n return videoSample.toVideoFrame();\n } finally {\n this.playbackController?.resumeSelfRender();\n }\n }\n\n /**\n * Capture a video frame directly at a source media timestamp.\n * Designed for export/rendering.\n * Does NOT paint to the element's internal canvas.\n *\n * Uses the same routing logic as unified video system:\n * - \"auto\": main track for production rendering, follows normal routing otherwise\n * - \"scrub\": force low-res scrub track (for thumbnails)\n * - \"main\": force full-quality main track\n *\n * @param sourceTimeMs - Timestamp in source media coordinates (not timeline)\n * @param options - Capture options including quality and abort signal\n * @returns Frame data for serialization\n * @public\n */\n async captureFrameAtSourceTime(\n sourceTimeMs: number,\n options: {\n quality?: \"auto\" | \"scrub\" | \"main\";\n signal?: AbortSignal;\n } = {},\n ): Promise<{\n dataUrl: string;\n width: number;\n height: number;\n }> {\n const videoFrame = await this.getVideoFrameAtSourceTime(sourceTimeMs, options);\n\n try {\n options.signal?.throwIfAborted();\n\n const canvas = new OffscreenCanvas(videoFrame.codedWidth, videoFrame.codedHeight);\n const ctx = canvas.getContext(\"2d\");\n if (!ctx) {\n throw new Error(\"Failed to get 2d context from OffscreenCanvas\");\n }\n ctx.drawImage(videoFrame, 0, 0);\n\n options.signal?.throwIfAborted();\n\n const blob = await canvas.convertToBlob({\n type: \"image/jpeg\",\n quality: 0.92,\n });\n options.signal?.throwIfAborted();\n\n const dataUrl = await new Promise<string>((resolve, reject) => {\n const reader = new FileReader();\n reader.onload = () => resolve(reader.result as string);\n reader.onerror = reject;\n reader.readAsDataURL(blob);\n });\n options.signal?.throwIfAborted();\n\n return {\n dataUrl,\n width: videoFrame.codedWidth,\n height: videoFrame.codedHeight,\n };\n } finally {\n videoFrame.close();\n }\n }\n\n /**\n * Pre-fetch scrub segments for given timestamps.\n * Loads 30-second segments sequentially, emitting progress events.\n * This ensures scrub track is cached for fast thumbnail generation.\n *\n * @param timestamps - Array of timestamps (in ms) that will be captured\n * @param onProgress - Optional callback for loading progress\n * @param signal - Optional AbortSignal for cancellation\n * @returns Promise that resolves when all segments are cached\n * @public\n */\n async prefetchScrubSegments(\n timestamps: number[],\n onProgress?: (loaded: number, total: number, segmentTimeRange: [number, number]) => void,\n signal?: AbortSignal,\n ): Promise<void> {\n // Wait for media engine to be ready\n const mediaEngine = await this.getMediaEngine(signal);\n if (!mediaEngine) {\n log(\"prefetchScrubSegments: no media engine available\");\n return;\n }\n\n const scrubTrack = mediaEngine.tracks.scrub;\n if (!scrubTrack) {\n log(\"prefetchScrubSegments: no scrub rendition available\");\n return;\n }\n\n // Compute unique segment IDs needed for all timestamps\n const segmentIds = new Set<number>();\n for (const ts of timestamps) {\n const segmentId = mediaEngine.index.segmentAt(ts, scrubTrack);\n if (segmentId !== undefined) {\n segmentIds.add(segmentId);\n }\n }\n\n if (segmentIds.size === 0) {\n log(\"prefetchScrubSegments: no segments to prefetch\");\n return;\n }\n\n // Check if ANY segment is already cached (meaning the file is loaded).\n const firstSegmentId = Array.from(segmentIds)[0]!;\n if (mediaEngine.transport.isCached(firstSegmentId, scrubTrack)) {\n log(\"prefetchScrubSegments: scrub track already cached\");\n return;\n }\n\n log(`prefetchScrubSegments: fetching scrub track for ${segmentIds.size} segments...`);\n\n const durationMs = mediaEngine.durationMs || 0;\n this.dispatchEvent(\n new CustomEvent(\"scrub-segment-loading\", {\n detail: {\n segmentId: 0,\n timeRangeMs: [0, durationMs] as [number, number],\n loaded: 0,\n total: 1,\n status: \"loading\",\n },\n bubbles: true,\n composed: true,\n }),\n );\n\n const fetchSignal = signal ?? new AbortController().signal;\n try {\n await mediaEngine.transport.fetchMediaSegment(firstSegmentId, scrubTrack, fetchSignal);\n log(`prefetchScrubSegments: scrub track loaded`);\n } catch (error) {\n log(`prefetchScrubSegments: failed to load scrub track`, error);\n }\n\n this.dispatchEvent(\n new CustomEvent(\"scrub-segment-loading\", {\n detail: {\n segmentId: 0,\n timeRangeMs: [0, durationMs] as [number, number],\n loaded: 1,\n total: 1,\n status: \"loaded\",\n },\n bubbles: true,\n composed: true,\n }),\n );\n\n onProgress?.(1, 1, [0, durationMs]);\n log(`prefetchScrubSegments: complete`);\n }\n\n /**\n * Warm the scrub BufferedSeekingInput cache for all segments covering [fromMs, toMs].\n *\n * Unlike prefetchScrubSegments (which only warms the network layer), this method\n * constructs BufferedSeekingInput instances for each segment so that subsequent\n * scrub seeks within the range complete without a network round-trip or BSI\n * construction overhead.\n *\n * Returns a Promise that resolves after the range has been computed and segment\n * fetches have been kicked off (but before individual fetches complete).\n * Callers may await this or discard the promise — both are valid.\n */\n async warmScrubCacheForRange(fromMs: number, toMs: number, signal?: AbortSignal): Promise<void> {\n const mediaEngine = await this.getMediaEngine(signal);\n if (!mediaEngine) return;\n if (signal?.aborted) return;\n\n const scrubTrack = mediaEngine.tracks.scrub;\n if (!scrubTrack) return;\n\n const segments = mediaEngine.index.segmentsInRange(fromMs, toMs, scrubTrack);\n if (segments.length === 0) return;\n\n const { src } = mediaEngine;\n\n // Kick off all segment fetches concurrently. ScrubInputCache deduplicates\n // concurrent requests for the same segment, so this is safe.\n for (const { segmentId } of segments) {\n if (signal?.aborted) return;\n\n // getOrCreateInput is idempotent — returns immediately if already cached.\n const capturedSegmentId = segmentId;\n this.#scrubInputCache\n .getOrCreateInput(src, capturedSegmentId, async () => {\n if (signal?.aborted) return undefined;\n let initSegment: ArrayBuffer | undefined;\n let mediaSegment: ArrayBuffer | undefined;\n try {\n [initSegment, mediaSegment] = await Promise.all([\n mediaEngine.transport.fetchInitSegment(scrubTrack),\n mediaEngine.transport.fetchMediaSegment(capturedSegmentId, scrubTrack),\n ]);\n } catch {\n return undefined;\n }\n if (!initSegment || !mediaSegment) return undefined;\n\n const combinedBlob = new Blob([initSegment, mediaSegment]);\n const arrayBuffer = await combinedBlob.arrayBuffer();\n const { BufferedSeekingInput } = await import(\"./EFMedia/BufferedSeekingInput.js\");\n return new BufferedSeekingInput(arrayBuffer, {\n videoBufferSize: EFMedia.VIDEO_SAMPLE_BUFFER_SIZE,\n audioBufferSize: EFMedia.AUDIO_SAMPLE_BUFFER_SIZE,\n startTimeOffsetMs: scrubTrack.startTimeOffsetMs,\n });\n })\n .catch(() => {\n // Ignore prefetch errors — they're best-effort\n });\n }\n }\n\n /**\n * Maybe schedule quality upgrade tasks for this element.\n * Called when returning a scrub sample - checks if state has changed and submits tasks.\n */\n #maybeScheduleQualityUpgrade(mediaEngine: MediaEngine, sourceTimeMs: number): void {\n if (this.#renderingToVideo) return;\n if (this.rootTimegroup?.isRenderClone) return;\n const mainTrack = mediaEngine.tracks.video;\n if (!mainTrack) return;\n\n const segmentId = mediaEngine.index.segmentAt(sourceTimeMs, mainTrack);\n if (segmentId === undefined) return;\n\n const startTimeMs = this.startTimeMs;\n\n const stateChanged =\n this.#upgradeState === null ||\n this.#upgradeState.segmentId !== segmentId ||\n this.#upgradeState.startTimeMs !== startTimeMs;\n\n if (!stateChanged) {\n const currentTaskKey = `${this.#upgradeOwnerId}:${segmentId}:${mainTrack.id}`;\n const scheduler = this.rootTimegroup?.qualityUpgradeScheduler;\n if (scheduler?.isActive(currentTaskKey) || scheduler?.isPending(currentTaskKey)) {\n return;\n }\n this.rootTimegroup?.qualityUpgradeScheduler?.cancelForOwner(this.#upgradeOwnerId);\n this.#upgradeState = null;\n }\n\n const segments = this.#computeLookaheadSegments(mediaEngine, sourceTimeMs, mainTrack);\n if (segments.length === 0) return;\n\n // Capture src at task-creation time to guard the isCached closure against\n // stale mediaEngine references after a src change.\n const capturedSrc = mediaEngine.src;\n const tasks = segments.map((seg) => ({\n key: `${this.#upgradeOwnerId}:${seg.segmentId}:${mainTrack.id}`,\n fetch: async (signal: AbortSignal) => {\n await mediaEngine.transport.fetchInitSegment(mainTrack, signal);\n await mediaEngine.transport.fetchMediaSegment(seg.segmentId, mainTrack, signal);\n },\n isCached: () => {\n // Guard: if src changed since tasks were created, treat as not cached so\n // replaceForOwner does not skip re-queuing for the new video's segments.\n if (this.mediaEngineTask?.value?.src !== capturedSrc) return false;\n return mediaEngine.transport.isCached(seg.segmentId, mainTrack);\n },\n deadlineMs: seg.deadlineMs,\n owner: this.#upgradeOwnerId,\n }));\n\n const scheduler = this.rootTimegroup?.qualityUpgradeScheduler;\n if (scheduler) {\n scheduler.replaceForOwner(this.#upgradeOwnerId, tasks);\n } else {\n this.#fetchStandalone(tasks);\n }\n\n this.#upgradeState = {\n sourceTimeMs,\n segmentId,\n startTimeMs,\n submittedKeys: new Set(tasks.map((t) => t.key)),\n };\n }\n\n /**\n * Compute lookahead segments with deadlines in timeline space.\n */\n #computeLookaheadSegments(\n mediaEngine: MediaEngine,\n currentSourceTimeMs: number,\n track: import(\"./EFMedia/SegmentIndex.js\").TrackRef,\n maxLookahead: number = 5,\n ): { segmentId: number; deadlineMs: number }[] {\n const results: { segmentId: number; deadlineMs: number }[] = [];\n const playheadMs = this.rootTimegroup?.currentTimeMs ?? 0;\n const seen = new Set<number>();\n\n let probeTimeMs = currentSourceTimeMs;\n\n while (seen.size < maxLookahead) {\n const segmentId = mediaEngine.index.segmentAt(probeTimeMs, track);\n if (segmentId === undefined) break;\n if (seen.has(segmentId)) break;\n\n seen.add(segmentId);\n\n if (!mediaEngine.transport.isCached(segmentId, track)) {\n const offsetFromCurrentMs = probeTimeMs - currentSourceTimeMs;\n const deadlineMs = playheadMs + offsetFromCurrentMs;\n results.push({ segmentId, deadlineMs });\n }\n\n const thisDuration =\n track.segmentDurationsMs?.[segmentId - 1] ?? track.segmentDurationMs ?? 2000;\n probeTimeMs += thisDuration;\n }\n\n return results;\n }\n\n /**\n * Standalone mode: fetch tasks sequentially without scheduler.\n */\n #fetchStandalone(tasks: any[]): void {\n // Abort any previous standalone batch (e.g., after seek)\n this.#standaloneUpgradeController?.abort();\n this.#standaloneUpgradeController = new AbortController();\n const signal = this.#standaloneUpgradeController.signal;\n\n // Process sequentially\n (async () => {\n for (const task of tasks) {\n if (signal.aborted) break;\n try {\n await task.fetch(signal);\n } catch {\n // Continue on error\n }\n }\n // After all tasks complete, trigger re-render.\n // By the time the fetch completes the element is typically connected to a\n // timegroup, so prefer rootTimegroup.requestFrameRender(). Fall back to\n // playbackController for the standalone (no-timegroup) case.\n if (!signal.aborted) {\n if (this.rootTimegroup) {\n this.rootTimegroup.requestFrameRender();\n } else {\n this.playbackController?.runThrottledFrameTask().catch(() => {});\n }\n }\n })().catch(() => {});\n }\n\n /**\n * Invalidate upgrade state and optionally cancel queued tasks.\n */\n #invalidateUpgradeState(reason: \"src-change\" | \"bounds-change\" | \"disconnect\"): void {\n if (reason === \"src-change\" || reason === \"disconnect\") {\n // Full cancel - old tasks reference a stale media engine\n this.rootTimegroup?.qualityUpgradeScheduler?.cancelForOwner(this.#upgradeOwnerId);\n }\n // For bounds-change, don't cancel - old tasks may still be valid segments,\n // just with stale deadlines. replaceForOwner on next prepareFrame handles it.\n this.#upgradeState = null;\n }\n\n /**\n * Reset per-instance caches and rendition state. Allows tests to force\n * the scrub fallback path on the next render without clearing the shared\n * mediaCache (which races with in-flight fetches from other elements).\n * @public – test-only\n */\n clearInstanceCaches(): void {\n this.#cachedVideoSample = undefined;\n this.#cachedVideoSampleTimeMs = undefined;\n this.#currentRenditionId = undefined;\n this.#upgradeState = null;\n this.#mainVideoInputCache.clear();\n this.#scrubInputCache.clear();\n }\n\n /**\n * Returns scrub cache statistics for inspection in tests.\n * @public – test-only\n */\n getScrubCacheStats(): ReturnType<ScrubInputCache[\"getStats\"]> {\n return this.#scrubInputCache.getStats();\n }\n\n /**\n * Clean up resources when component is disconnected\n */\n disconnectedCallback(): void {\n super.disconnectedCallback();\n\n this.#cachedVideoSample = undefined;\n this.#cachedVideoSampleTimeMs = undefined;\n\n // Clean up delayed loading state\n this.#delayedLoadingState.clearAllLoading();\n\n // Cancel upgrade tasks (centralized or standalone)\n this.#invalidateUpgradeState(\"disconnect\");\n this.#standaloneUpgradeController?.abort();\n this.#standaloneUpgradeController = null;\n }\n\n didBecomeRoot() {\n super.didBecomeRoot();\n }\n didBecomeChild() {\n super.didBecomeChild();\n }\n\n /**\n * Get the natural dimensions of the video (coded width and height).\n * Returns null if the video hasn't loaded yet or canvas isn't available.\n *\n * @public\n */\n getNaturalDimensions(): { width: number; height: number } | null {\n const canvas = this.canvasElement;\n if (!canvas || canvas.width === 0 || canvas.height === 0) {\n return null;\n }\n return {\n width: canvas.width,\n height: canvas.height,\n };\n }\n\n /**\n * Render this video element to an MP4 using the direct video-to-video fast path.\n * Bypasses DOM serialization — decodes frames directly and re-encodes to MP4.\n * Respects trim, CSS filter, and opacity.\n *\n * @param options - Rendering options (fps, codec, bitrate, etc.)\n * @returns Promise resolving to video buffer (if returnBuffer), or undefined\n * @public\n */\n async renderToVideo(\n options?: import(\"../preview/renderTimegroupToVideo.types.js\").RenderToVideoOptions,\n ): Promise<Uint8Array | undefined> {\n this.#renderingToVideo = true;\n try {\n const { renderVideoToVideo } = await import(\"../preview/renderVideoToVideo.js\");\n return await renderVideoToVideo(this, options);\n } finally {\n this.#renderingToVideo = false;\n }\n }\n}\n\ndeclare global {\n interface HTMLElementTagNameMap {\n \"ef-video\": EFVideo;\n }\n}\n"],"mappings":"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"}
|
|
@@ -33,6 +33,8 @@ var PlaybackController = class {
|
|
|
33
33
|
#playbackAnimationFrameRequest = null;
|
|
34
34
|
#pendingAudioContext = null;
|
|
35
35
|
#AUDIO_PLAYBACK_SLICE_MS = 47 * 1024 / 48e3 * 1e3;
|
|
36
|
+
#rafStartWallMs = null;
|
|
37
|
+
#rafStartLogicalMs = 0;
|
|
36
38
|
#currentTime = void 0;
|
|
37
39
|
#seekInProgress = false;
|
|
38
40
|
#pendingSeekTime;
|
|
@@ -310,6 +312,22 @@ var PlaybackController = class {
|
|
|
310
312
|
this.#syncPlayheadToAudioContext(startMs);
|
|
311
313
|
});
|
|
312
314
|
}
|
|
315
|
+
#syncPlayheadToRaf() {
|
|
316
|
+
if (this.#rafStartWallMs === null) return;
|
|
317
|
+
const endMs = this.#host.endTimeMs;
|
|
318
|
+
const elapsed = performance.now() - this.#rafStartWallMs;
|
|
319
|
+
let rawTimeMs = this.#rafStartLogicalMs + elapsed;
|
|
320
|
+
if (this.#loopingPlayback) rawTimeMs = rawTimeMs % endMs;
|
|
321
|
+
const nextTimeMs = Math.round(rawTimeMs / this.#MS_PER_FRAME) * this.#MS_PER_FRAME;
|
|
322
|
+
this.#updatePlaybackTime(nextTimeMs);
|
|
323
|
+
if (!this.#loopingPlayback && nextTimeMs >= endMs) {
|
|
324
|
+
this.maybeLoopPlayback();
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
this.#playbackAnimationFrameRequest = requestAnimationFrame(() => {
|
|
328
|
+
this.#syncPlayheadToRaf();
|
|
329
|
+
});
|
|
330
|
+
}
|
|
313
331
|
async maybeLoopPlayback() {
|
|
314
332
|
if (this.#loop) {
|
|
315
333
|
this.setCurrentTimeMs(0);
|
|
@@ -326,6 +344,7 @@ var PlaybackController = class {
|
|
|
326
344
|
cancelAnimationFrame(this.#playbackAnimationFrameRequest);
|
|
327
345
|
this.#playbackAnimationFrameRequest = null;
|
|
328
346
|
}
|
|
347
|
+
this.#rafStartWallMs = null;
|
|
329
348
|
if (this.#playbackAudioContext) {
|
|
330
349
|
if (this.#playbackAudioContext.state !== "closed") await this.#playbackAudioContext.close();
|
|
331
350
|
}
|
|
@@ -346,28 +365,30 @@ var PlaybackController = class {
|
|
|
346
365
|
this.pause();
|
|
347
366
|
return;
|
|
348
367
|
}
|
|
368
|
+
this.#loopingPlayback = this.#loop;
|
|
369
|
+
this.#playbackWrapTimeSeconds = 0;
|
|
370
|
+
if (this.#playbackAnimationFrameRequest) cancelAnimationFrame(this.#playbackAnimationFrameRequest);
|
|
371
|
+
if (!host.renderAudio) {
|
|
372
|
+
this.#rafStartWallMs = performance.now();
|
|
373
|
+
this.#rafStartLogicalMs = currentMs;
|
|
374
|
+
this.#syncPlayheadToRaf();
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
349
377
|
let bufferCount = 0;
|
|
350
378
|
if (this.#pendingAudioContext) {
|
|
351
379
|
this.#playbackAudioContext = this.#pendingAudioContext;
|
|
352
380
|
this.#pendingAudioContext = null;
|
|
353
381
|
} else this.#playbackAudioContext = new AudioContext({ latencyHint: "playback" });
|
|
354
|
-
this.#loopingPlayback = this.#loop;
|
|
355
|
-
this.#playbackWrapTimeSeconds = 0;
|
|
356
|
-
if (this.#playbackAnimationFrameRequest) cancelAnimationFrame(this.#playbackAnimationFrameRequest);
|
|
357
|
-
this.#syncPlayheadToAudioContext(currentMs);
|
|
358
382
|
const playbackContext = this.#playbackAudioContext;
|
|
359
|
-
if (playbackContext.state === "suspended")
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
}
|
|
366
|
-
} catch (error) {
|
|
367
|
-
console.warn("Failed to resume AudioContext:", error, "On mobile devices, AudioContext.resume() must be called synchronously within a user interaction handler.");
|
|
368
|
-
this.setPlaying(false);
|
|
383
|
+
if (playbackContext.state === "suspended") {
|
|
384
|
+
playbackContext.close().catch(() => {});
|
|
385
|
+
this.#playbackAudioContext = null;
|
|
386
|
+
this.#rafStartWallMs = performance.now();
|
|
387
|
+
this.#rafStartLogicalMs = currentMs;
|
|
388
|
+
this.#syncPlayheadToRaf();
|
|
369
389
|
return;
|
|
370
390
|
}
|
|
391
|
+
this.#syncPlayheadToAudioContext(currentMs);
|
|
371
392
|
await playbackContext.suspend();
|
|
372
393
|
let logicalTimeMs = currentMs;
|
|
373
394
|
let audioContextTimeMs = 0;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"PlaybackController.js","names":["#FPS","#host","#playingProvider","#playing","#loopProvider","#loop","#currentTimeMsProvider","#durationMsProvider","#currentTime","#processingPendingSeek","#pendingSeekTime","#seekInProgress","#runSeek","#seekAbortController","#notifyListeners","#hasConnected","#removed","#initializeTime","#selfRenderSuspended","#selfRenderAbortController","#selfRenderPromise","#selfRenderDirty","#startSelfRender","#listeners","#pendingAudioContext","#playbackAudioContext","rawTimeMs: number","#playbackWrapTimeSeconds","#loopingPlayback","#MS_PER_FRAME","#updatePlaybackTime","#playbackAnimationFrameRequest","#syncPlayheadToAudioContext","#AUDIO_PLAYBACK_SLICE_MS"],"sources":["../../src/gui/PlaybackController.ts"],"sourcesContent":["import { ContextProvider } from \"@lit/context\";\nimport type { ReactiveController, ReactiveControllerHost } from \"lit\";\nimport { currentTimeContext } from \"./currentTimeContext.js\";\nimport { durationContext } from \"./durationContext.js\";\nimport { loopContext, playingContext } from \"./playingContext.js\";\nimport { updateAnimations, type AnimatableElement } from \"../elements/updateAnimations.js\";\nimport type { RenderFrameOptions, FrameRenderable } from \"../preview/FrameController.js\";\n\ninterface PlaybackHost extends HTMLElement, ReactiveControllerHost {\n currentTimeMs: number;\n durationMs: number;\n endTimeMs: number;\n /** Centralized frame controller (present on EFTimegroup) */\n frameController?: {\n renderFrame(timeMs: number, options?: RenderFrameOptions): Promise<void>;\n abort(): void;\n };\n renderAudio?(fromMs: number, toMs: number): Promise<AudioBuffer>;\n waitForMediaDurations?(signal?: AbortSignal): Promise<void>;\n saveTimeToLocalStorage?(time: number): void;\n loadTimeFromLocalStorage?(): number | undefined;\n requestUpdate(property?: string): void;\n updateComplete: Promise<boolean>;\n playing: boolean;\n loop: boolean;\n play(): void;\n pause(): void;\n playbackController?: PlaybackController;\n parentTimegroup?: any;\n rootTimegroup?: any;\n}\n\nexport type PlaybackControllerUpdateEvent = {\n property: \"playing\" | \"loop\" | \"currentTimeMs\";\n value: boolean | number;\n};\n\n/**\n * Manages playback state and audio-driven timing for root temporal elements\n *\n * Created automatically when a temporal element becomes a root (no parent timegroup)\n * Provides playback contexts (playing, loop, currentTimeMs, durationMs) to descendants\n * Handles:\n * - Audio-driven playback with Web Audio API\n * - Seek and frame rendering throttling\n * - Time state management with pending seek handling\n * - Playback loop behavior\n *\n * Works with any temporal element (timegroups or standalone media) via PlaybackHost interface\n */\nexport class PlaybackController implements ReactiveController {\n #host: PlaybackHost;\n #playing = false;\n #loop = false;\n #listeners = new Set<(event: PlaybackControllerUpdateEvent) => void>();\n #playingProvider: ContextProvider<typeof playingContext>;\n #loopProvider: ContextProvider<typeof loopContext>;\n #currentTimeMsProvider: ContextProvider<typeof currentTimeContext>;\n #durationMsProvider: ContextProvider<typeof durationContext>;\n\n #FPS = 30;\n #MS_PER_FRAME = 1000 / this.#FPS;\n #playbackAudioContext: AudioContext | null = null;\n #playbackAnimationFrameRequest: number | null = null;\n #pendingAudioContext: AudioContext | null = null;\n #AUDIO_PLAYBACK_SLICE_MS = ((47 * 1024) / 48000) * 1000;\n\n #currentTime: number | undefined = undefined;\n #seekInProgress = false;\n #pendingSeekTime: number | undefined;\n #processingPendingSeek = false;\n #loopingPlayback = false; // Track if we're in a looping playback session\n #playbackWrapTimeSeconds = 0; // The AudioContext time when we wrapped\n\n #seekAbortController: AbortController | null = null;\n #hasConnected = false;\n\n constructor(host: PlaybackHost) {\n this.#host = host;\n host.addController(this);\n\n this.#playingProvider = new ContextProvider(host, {\n context: playingContext,\n initialValue: this.#playing,\n });\n this.#loopProvider = new ContextProvider(host, {\n context: loopContext,\n initialValue: this.#loop,\n });\n this.#currentTimeMsProvider = new ContextProvider(host, {\n context: currentTimeContext,\n initialValue: host.currentTimeMs,\n });\n this.#durationMsProvider = new ContextProvider(host, {\n context: durationContext,\n initialValue: host.durationMs,\n });\n }\n\n get currentTime(): number {\n const rawTime = this.#currentTime ?? 0;\n // Quantize to frame boundaries based on host's fps\n const fps = (this.#host as any).fps ?? 30;\n if (!fps || fps <= 0) return rawTime;\n const frameDurationS = 1 / fps;\n const quantizedTime = Math.round(rawTime / frameDurationS) * frameDurationS;\n // Clamp to valid range after quantization to prevent exceeding duration\n const durationS = this.#host.durationMs / 1000;\n return Math.max(0, Math.min(quantizedTime, durationS));\n }\n\n set currentTime(time: number) {\n time = Math.max(0, Math.min(this.#host.durationMs / 1000, time));\n if (Number.isNaN(time)) {\n return;\n }\n if (time === this.#currentTime && !this.#processingPendingSeek) {\n return;\n }\n if (this.#pendingSeekTime === time) {\n return;\n }\n\n if (this.#seekInProgress) {\n this.#pendingSeekTime = time;\n this.#currentTime = time;\n return;\n }\n\n this.#currentTime = time;\n this.#seekInProgress = true;\n\n this.#runSeek(time).finally(async () => {\n // CRITICAL: Coordinate animations after seek completes\n // This ensures animations are positioned correctly, not playing naturally\n const { updateAnimations } = await import(\"../elements/updateAnimations.js\");\n updateAnimations(this.#host as any);\n\n if (this.#pendingSeekTime !== undefined && this.#pendingSeekTime !== time) {\n const pendingTime = this.#pendingSeekTime;\n this.#pendingSeekTime = undefined;\n this.#processingPendingSeek = true;\n try {\n this.currentTime = pendingTime;\n } finally {\n this.#processingPendingSeek = false;\n }\n } else {\n this.#pendingSeekTime = undefined;\n }\n });\n }\n\n async #runSeek(targetTime: number): Promise<number | undefined> {\n // Abort any in-flight seek\n this.#seekAbortController?.abort();\n this.#seekAbortController = new AbortController();\n const signal = this.#seekAbortController.signal;\n\n try {\n signal.throwIfAborted();\n\n await this.#host.waitForMediaDurations?.(signal);\n signal.throwIfAborted();\n\n const newTime = Math.max(0, Math.min(targetTime, this.#host.durationMs / 1000));\n this.#currentTime = newTime;\n this.#host.requestUpdate(\"currentTime\");\n this.#currentTimeMsProvider.setValue(this.currentTimeMs);\n this.#notifyListeners({\n property: \"currentTimeMs\",\n value: this.currentTimeMs,\n });\n\n signal.throwIfAborted();\n\n await this.runThrottledFrameTask();\n signal.throwIfAborted();\n\n // Save to localStorage for persistence (only if not restoring to avoid loops)\n const isRestoring = (this.#host as any).isRestoringFromLocalStorage?.() ?? false;\n if (!isRestoring) {\n this.#host.saveTimeToLocalStorage?.(newTime);\n } else {\n (this.#host as any).setRestoringFromLocalStorage?.(false);\n }\n this.#seekInProgress = false;\n return newTime;\n } catch (error) {\n if (error instanceof DOMException && error.name === \"AbortError\") {\n // Expected - don't log\n return undefined;\n }\n throw error;\n }\n }\n\n get playing(): boolean {\n return this.#playing;\n }\n\n setPlaying(value: boolean): void {\n if (this.#playing === value) return;\n this.#playing = value;\n this.#playingProvider.setValue(value);\n this.#host.requestUpdate(\"playing\");\n this.#notifyListeners({ property: \"playing\", value });\n\n if (value) {\n this.startPlayback();\n } else {\n this.stopPlayback();\n }\n }\n\n get loop(): boolean {\n return this.#loop;\n }\n\n setLoop(value: boolean): void {\n if (this.#loop === value) return;\n this.#loop = value;\n this.#loopProvider.setValue(value);\n this.#host.requestUpdate(\"loop\");\n this.#notifyListeners({ property: \"loop\", value });\n }\n\n get currentTimeMs(): number {\n return this.currentTime * 1000;\n }\n\n setCurrentTimeMs(value: number): void {\n this.currentTime = value / 1000;\n }\n\n // Update time during playback without triggering a seek\n // Used by #syncPlayheadToAudioContext to avoid frame drops\n #updatePlaybackTime(timeMs: number): void {\n // Clamp to valid range to prevent time exceeding duration\n const durationMs = this.#host.durationMs;\n const clampedTimeMs = Math.max(0, Math.min(timeMs, durationMs));\n const timeSec = clampedTimeMs / 1000;\n if (this.#currentTime === timeSec) {\n return;\n }\n this.#currentTime = timeSec;\n this.#host.requestUpdate(\"currentTime\");\n this.#currentTimeMsProvider.setValue(clampedTimeMs);\n this.#notifyListeners({\n property: \"currentTimeMs\",\n value: clampedTimeMs,\n });\n // Trigger frame rendering without the async seek mechanism\n this.runThrottledFrameTask();\n }\n\n play(): void {\n this.setPlaying(true);\n }\n\n pause(): void {\n this.setPlaying(false);\n }\n\n #removed = false;\n\n hostConnected(): void {\n const isReconnect = this.#hasConnected;\n this.#hasConnected = true;\n // Defer all operations to avoid blocking during initialization\n // This prevents deadlocks when many timegroups are initializing simultaneously\n requestAnimationFrame(() => {\n requestAnimationFrame(() => {\n // Check if this controller was removed before the RAF callback executed.\n // This happens when wrapWithWorkbench moves the element, causing disconnect/reconnect.\n if (this.#removed || this.#host.playbackController !== this) {\n return;\n }\n\n if (this.#playing && isReconnect) {\n this.startPlayback();\n } else if (!this.#playing) {\n this.#initializeTime();\n }\n });\n });\n }\n\n async #initializeTime(): Promise<void> {\n try {\n const waitPromise = this.#host.waitForMediaDurations?.();\n if (waitPromise) {\n await waitPromise;\n }\n } catch (err) {\n const isAbortError =\n (err instanceof DOMException && err.name === \"AbortError\") ||\n (err instanceof Error &&\n (err.name === \"AbortError\" ||\n err.message.includes(\"signal is aborted\") ||\n err.message.includes(\"The user aborted a request\")));\n if (!isAbortError) {\n console.error(\"Error in PlaybackController hostConnected:\", err);\n }\n return;\n }\n\n if (this.#removed || this.#host.playbackController !== this) {\n return;\n }\n\n const maybeLoadedTime = this.#host.loadTimeFromLocalStorage?.();\n if (maybeLoadedTime !== undefined) {\n (this.#host as any).setRestoringFromLocalStorage?.(true);\n this.currentTime = maybeLoadedTime;\n } else if (this.#currentTime === undefined) {\n this.currentTime = 0;\n }\n }\n\n hostDisconnected(): void {\n this.pause();\n }\n\n hostUpdated(): void {\n this.#durationMsProvider.setValue(this.#host.durationMs);\n this.#currentTimeMsProvider.setValue(this.currentTimeMs);\n }\n\n #selfRenderAbortController?: AbortController;\n #selfRenderPromise?: Promise<void>;\n #selfRenderDirty = false;\n #selfRenderSuspended = false;\n\n suspendSelfRender(): void {\n this.#selfRenderSuspended = true;\n this.#selfRenderAbortController?.abort();\n this.#selfRenderAbortController = undefined;\n }\n\n resumeSelfRender(): void {\n this.#selfRenderSuspended = false;\n }\n\n /**\n * Run frame rendering via FrameController, or directly on the host if it\n * implements FrameRenderable (standalone media element without a Timegroup).\n */\n async runThrottledFrameTask(): Promise<void> {\n const timeMs = this.currentTimeMs;\n\n if (this.#host.frameController) {\n try {\n await this.#host.frameController.renderFrame(timeMs, {\n onAnimationsUpdate: (root: Element) => {\n updateAnimations(root as unknown as AnimatableElement);\n },\n });\n } catch (error) {\n if (error instanceof DOMException && error.name === \"AbortError\") return;\n console.error(\"FrameController error:\", error);\n }\n return;\n }\n\n // Standalone FrameRenderable host (e.g. bare ef-video without a Timegroup)\n const host = this.#host as unknown as Partial<FrameRenderable>;\n if (!host.prepareFrame || !host.renderFrame) return;\n\n if (this.#selfRenderSuspended) return;\n\n // If a render is in-flight, mark dirty so we re-render after it\n // completes (source mapping may have changed due to trim drag).\n if (this.#selfRenderPromise) {\n this.#selfRenderDirty = true;\n return this.#selfRenderPromise;\n }\n\n return this.#startSelfRender(host, timeMs);\n }\n\n #startSelfRender(host: Partial<FrameRenderable>, timeMs: number): Promise<void> {\n this.#selfRenderAbortController?.abort();\n this.#selfRenderAbortController = new AbortController();\n const signal = this.#selfRenderAbortController.signal;\n this.#selfRenderDirty = false;\n\n this.#selfRenderPromise = (async () => {\n try {\n await host.prepareFrame!(timeMs, signal);\n signal.throwIfAborted();\n host.renderFrame!(timeMs);\n updateAnimations(this.#host as unknown as AnimatableElement);\n } catch (error) {\n if (error instanceof DOMException && error.name === \"AbortError\") return;\n if ((error as any)?.name === \"AbortError\") return;\n console.error(\"Standalone frame render error:\", error);\n } finally {\n this.#selfRenderPromise = undefined;\n // Re-render if source mapping changed while we were rendering\n if (this.#selfRenderDirty && !this.#selfRenderSuspended) {\n this.#startSelfRender(host, this.currentTimeMs);\n }\n }\n })();\n\n return this.#selfRenderPromise;\n }\n\n addListener(listener: (event: PlaybackControllerUpdateEvent) => void): void {\n this.#listeners.add(listener);\n }\n\n removeListener(listener: (event: PlaybackControllerUpdateEvent) => void): void {\n this.#listeners.delete(listener);\n }\n\n #notifyListeners(event: PlaybackControllerUpdateEvent): void {\n for (const listener of this.#listeners) {\n listener(event);\n }\n }\n\n remove(): void {\n this.#removed = true; // Mark as removed to abort any pending RAF callbacks\n this.stopPlayback();\n this.#listeners.clear();\n this.#host.removeController(this);\n }\n\n setPendingAudioContext(context: AudioContext): void {\n this.#pendingAudioContext = context;\n }\n\n #syncPlayheadToAudioContext(startMs: number) {\n const audioContextTime = this.#playbackAudioContext?.currentTime ?? 0;\n const endMs = this.#host.endTimeMs;\n\n // Calculate raw time based on audio context\n let rawTimeMs: number;\n if (this.#playbackWrapTimeSeconds > 0 && audioContextTime >= this.#playbackWrapTimeSeconds) {\n // After wrap: time since wrap, wrapped to duration\n const timeSinceWrap = (audioContextTime - this.#playbackWrapTimeSeconds) * 1000;\n rawTimeMs = timeSinceWrap % endMs;\n } else {\n // Before wrap or no wrap: normal calculation\n rawTimeMs = startMs + audioContextTime * 1000;\n\n // If looping and we've reached the end, wrap around\n if (this.#loopingPlayback && rawTimeMs >= endMs) {\n rawTimeMs = rawTimeMs % endMs;\n }\n }\n\n const nextTimeMs = Math.round(rawTimeMs / this.#MS_PER_FRAME) * this.#MS_PER_FRAME;\n\n // During playback, update time directly without triggering seek\n // This avoids frame drops at the loop boundary\n this.#updatePlaybackTime(nextTimeMs);\n\n // Only check for end if we haven't already handled looping\n if (!this.#loopingPlayback && nextTimeMs >= endMs) {\n this.maybeLoopPlayback();\n return;\n }\n\n this.#playbackAnimationFrameRequest = requestAnimationFrame(() => {\n this.#syncPlayheadToAudioContext(startMs);\n });\n }\n\n private async maybeLoopPlayback() {\n if (this.#loop) {\n // Loop enabled: reset to beginning and restart playback\n // We restart the audio system directly without changing #playing state\n // to keep the play button in sync\n this.setCurrentTimeMs(0);\n // Restart in next frame without awaiting to minimize gap\n requestAnimationFrame(() => {\n this.startPlayback();\n });\n } else {\n // No loop: reset to beginning and stop\n // This ensures play button works when clicked again\n this.setCurrentTimeMs(0);\n this.pause();\n }\n }\n\n private async stopPlayback() {\n if (this.#playbackAnimationFrameRequest) {\n cancelAnimationFrame(this.#playbackAnimationFrameRequest);\n this.#playbackAnimationFrameRequest = null;\n }\n if (this.#playbackAudioContext) {\n if (this.#playbackAudioContext.state !== \"closed\") {\n await this.#playbackAudioContext.close();\n }\n }\n this.#playbackAudioContext = null;\n this.#pendingAudioContext = null;\n }\n\n private async startPlayback() {\n // Guard against starting playback on a removed controller\n if (this.#removed) {\n return;\n }\n\n await this.stopPlayback();\n const host = this.#host;\n if (!host) {\n return;\n }\n\n if (host.waitForMediaDurations) {\n await host.waitForMediaDurations();\n }\n\n // Check again after async - controller could have been removed\n if (this.#removed) {\n return;\n }\n\n const currentMs = this.currentTimeMs;\n const fromMs = currentMs;\n const toMs = host.endTimeMs;\n\n if (fromMs >= toMs) {\n this.pause();\n return;\n }\n\n let bufferCount = 0;\n // Check for pre-resumed AudioContext from synchronous user interaction\n if (this.#pendingAudioContext) {\n this.#playbackAudioContext = this.#pendingAudioContext;\n this.#pendingAudioContext = null;\n } else {\n this.#playbackAudioContext = new AudioContext({\n latencyHint: \"playback\",\n });\n }\n this.#loopingPlayback = this.#loop; // Remember if we're in a looping session\n this.#playbackWrapTimeSeconds = 0; // Reset wrap time\n\n if (this.#playbackAnimationFrameRequest) {\n cancelAnimationFrame(this.#playbackAnimationFrameRequest);\n }\n this.#syncPlayheadToAudioContext(currentMs);\n const playbackContext = this.#playbackAudioContext;\n\n // Check if context is suspended (fallback for newly-created contexts)\n if (playbackContext.state === \"suspended\") {\n // Attempt to resume (may not work on mobile if user interaction context is lost)\n try {\n await playbackContext.resume();\n // Check state again after resume attempt\n if (playbackContext.state === \"suspended\") {\n console.warn(\n \"AudioContext is suspended and resume() failed. \" +\n \"On mobile devices, AudioContext.resume() must be called synchronously within a user interaction handler. \" +\n \"Media playback will not work until user has interacted with page.\",\n );\n this.setPlaying(false);\n return;\n }\n } catch (error) {\n console.warn(\n \"Failed to resume AudioContext:\",\n error,\n \"On mobile devices, AudioContext.resume() must be called synchronously within a user interaction handler.\",\n );\n this.setPlaying(false);\n return;\n }\n }\n await playbackContext.suspend();\n\n // Track the logical media time (what position in the media we're rendering)\n // vs the AudioContext schedule time (when to play it)\n let logicalTimeMs = currentMs;\n let audioContextTimeMs = 0; // Tracks the schedule position in the AudioContext timeline\n let hasWrapped = false;\n\n const fillBuffer = async () => {\n if (bufferCount > 2) {\n return;\n }\n const canFillBuffer = await queueBufferSource();\n if (canFillBuffer) {\n fillBuffer().catch(() => {});\n }\n };\n\n const queueBufferSource = async () => {\n // Check if we've already wrapped and aren't looping anymore\n if (hasWrapped && !this.#loopingPlayback) {\n return false;\n }\n\n const startMs = logicalTimeMs;\n const endMs = Math.min(logicalTimeMs + this.#AUDIO_PLAYBACK_SLICE_MS, toMs);\n\n // Will this slice reach the end?\n const willReachEnd = endMs >= toMs;\n\n if (!host.renderAudio) {\n return false;\n }\n\n const audioBuffer = await host.renderAudio(startMs, endMs);\n bufferCount++;\n const source = playbackContext.createBufferSource();\n source.buffer = audioBuffer;\n source.connect(playbackContext.destination);\n // Schedule this buffer to play at the current audioContextTime position\n source.start(audioContextTimeMs / 1000);\n\n const sliceDurationMs = endMs - startMs;\n\n source.onended = () => {\n bufferCount--;\n\n if (willReachEnd) {\n if (!this.#loopingPlayback) {\n // Not looping, end playback\n this.maybeLoopPlayback();\n } else {\n // Looping: continue filling buffer after wrap\n fillBuffer().catch(() => {});\n }\n } else {\n // Continue filling buffer\n fillBuffer().catch(() => {});\n }\n };\n\n // Advance the AudioContext schedule time\n audioContextTimeMs += sliceDurationMs;\n\n // If this buffer reaches the end and we're looping, immediately queue the wraparound\n if (willReachEnd && this.#loopingPlayback) {\n // Mark that we've wrapped\n hasWrapped = true;\n // Store when we wrapped (relative to when playback started, which is time 0 in AudioContext)\n // This is the duration from start to end\n this.#playbackWrapTimeSeconds = (toMs - fromMs) / 1000;\n // Reset logical time to beginning\n logicalTimeMs = 0;\n // Continue buffering will happen in fillBuffer() call below\n } else {\n // Normal advance\n logicalTimeMs = endMs;\n }\n\n return true;\n };\n\n try {\n await fillBuffer();\n await playbackContext.resume();\n } catch (error) {\n // Ignore errors if AudioContext is closed or during test cleanup\n if (\n error instanceof Error &&\n (error.name === \"InvalidStateError\" || error.message.includes(\"closed\"))\n ) {\n return;\n }\n throw error;\n }\n }\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"PlaybackController.js","names":["#FPS","#host","#playingProvider","#playing","#loopProvider","#loop","#currentTimeMsProvider","#durationMsProvider","#currentTime","#processingPendingSeek","#pendingSeekTime","#seekInProgress","#runSeek","#seekAbortController","#notifyListeners","#hasConnected","#removed","#initializeTime","#selfRenderSuspended","#selfRenderAbortController","#selfRenderPromise","#selfRenderDirty","#startSelfRender","#listeners","#pendingAudioContext","#playbackAudioContext","rawTimeMs: number","#playbackWrapTimeSeconds","#loopingPlayback","#MS_PER_FRAME","#updatePlaybackTime","#playbackAnimationFrameRequest","#syncPlayheadToAudioContext","#rafStartWallMs","#rafStartLogicalMs","#syncPlayheadToRaf","#AUDIO_PLAYBACK_SLICE_MS"],"sources":["../../src/gui/PlaybackController.ts"],"sourcesContent":["import { ContextProvider } from \"@lit/context\";\nimport type { ReactiveController, ReactiveControllerHost } from \"lit\";\nimport { currentTimeContext } from \"./currentTimeContext.js\";\nimport { durationContext } from \"./durationContext.js\";\nimport { loopContext, playingContext } from \"./playingContext.js\";\nimport { updateAnimations, type AnimatableElement } from \"../elements/updateAnimations.js\";\nimport type { RenderFrameOptions, FrameRenderable } from \"../preview/FrameController.js\";\n\ninterface PlaybackHost extends HTMLElement, ReactiveControllerHost {\n currentTimeMs: number;\n durationMs: number;\n endTimeMs: number;\n /** Centralized frame controller (present on EFTimegroup) */\n frameController?: {\n renderFrame(timeMs: number, options?: RenderFrameOptions): Promise<void>;\n abort(): void;\n };\n renderAudio?(fromMs: number, toMs: number): Promise<AudioBuffer>;\n waitForMediaDurations?(signal?: AbortSignal): Promise<void>;\n saveTimeToLocalStorage?(time: number): void;\n loadTimeFromLocalStorage?(): number | undefined;\n requestUpdate(property?: string): void;\n updateComplete: Promise<boolean>;\n playing: boolean;\n loop: boolean;\n play(): void;\n pause(): void;\n playbackController?: PlaybackController;\n parentTimegroup?: any;\n rootTimegroup?: any;\n}\n\nexport type PlaybackControllerUpdateEvent = {\n property: \"playing\" | \"loop\" | \"currentTimeMs\";\n value: boolean | number;\n};\n\n/**\n * Manages playback state and audio-driven timing for root temporal elements\n *\n * Created automatically when a temporal element becomes a root (no parent timegroup)\n * Provides playback contexts (playing, loop, currentTimeMs, durationMs) to descendants\n * Handles:\n * - Audio-driven playback with Web Audio API\n * - Seek and frame rendering throttling\n * - Time state management with pending seek handling\n * - Playback loop behavior\n *\n * Works with any temporal element (timegroups or standalone media) via PlaybackHost interface\n */\nexport class PlaybackController implements ReactiveController {\n #host: PlaybackHost;\n #playing = false;\n #loop = false;\n #listeners = new Set<(event: PlaybackControllerUpdateEvent) => void>();\n #playingProvider: ContextProvider<typeof playingContext>;\n #loopProvider: ContextProvider<typeof loopContext>;\n #currentTimeMsProvider: ContextProvider<typeof currentTimeContext>;\n #durationMsProvider: ContextProvider<typeof durationContext>;\n\n #FPS = 30;\n #MS_PER_FRAME = 1000 / this.#FPS;\n #playbackAudioContext: AudioContext | null = null;\n #playbackAnimationFrameRequest: number | null = null;\n #pendingAudioContext: AudioContext | null = null;\n #AUDIO_PLAYBACK_SLICE_MS = ((47 * 1024) / 48000) * 1000;\n\n // rAF-based clock used when there is no audio (avoids AudioContext autoplay restrictions)\n #rafStartWallMs: number | null = null;\n #rafStartLogicalMs = 0;\n\n #currentTime: number | undefined = undefined;\n #seekInProgress = false;\n #pendingSeekTime: number | undefined;\n #processingPendingSeek = false;\n #loopingPlayback = false; // Track if we're in a looping playback session\n #playbackWrapTimeSeconds = 0; // The AudioContext time when we wrapped\n\n #seekAbortController: AbortController | null = null;\n #hasConnected = false;\n\n constructor(host: PlaybackHost) {\n this.#host = host;\n host.addController(this);\n\n this.#playingProvider = new ContextProvider(host, {\n context: playingContext,\n initialValue: this.#playing,\n });\n this.#loopProvider = new ContextProvider(host, {\n context: loopContext,\n initialValue: this.#loop,\n });\n this.#currentTimeMsProvider = new ContextProvider(host, {\n context: currentTimeContext,\n initialValue: host.currentTimeMs,\n });\n this.#durationMsProvider = new ContextProvider(host, {\n context: durationContext,\n initialValue: host.durationMs,\n });\n }\n\n get currentTime(): number {\n const rawTime = this.#currentTime ?? 0;\n // Quantize to frame boundaries based on host's fps\n const fps = (this.#host as any).fps ?? 30;\n if (!fps || fps <= 0) return rawTime;\n const frameDurationS = 1 / fps;\n const quantizedTime = Math.round(rawTime / frameDurationS) * frameDurationS;\n // Clamp to valid range after quantization to prevent exceeding duration\n const durationS = this.#host.durationMs / 1000;\n return Math.max(0, Math.min(quantizedTime, durationS));\n }\n\n set currentTime(time: number) {\n time = Math.max(0, Math.min(this.#host.durationMs / 1000, time));\n if (Number.isNaN(time)) {\n return;\n }\n if (time === this.#currentTime && !this.#processingPendingSeek) {\n return;\n }\n if (this.#pendingSeekTime === time) {\n return;\n }\n\n if (this.#seekInProgress) {\n this.#pendingSeekTime = time;\n this.#currentTime = time;\n return;\n }\n\n this.#currentTime = time;\n this.#seekInProgress = true;\n\n this.#runSeek(time).finally(async () => {\n // CRITICAL: Coordinate animations after seek completes\n // This ensures animations are positioned correctly, not playing naturally\n const { updateAnimations } = await import(\"../elements/updateAnimations.js\");\n updateAnimations(this.#host as any);\n\n if (this.#pendingSeekTime !== undefined && this.#pendingSeekTime !== time) {\n const pendingTime = this.#pendingSeekTime;\n this.#pendingSeekTime = undefined;\n this.#processingPendingSeek = true;\n try {\n this.currentTime = pendingTime;\n } finally {\n this.#processingPendingSeek = false;\n }\n } else {\n this.#pendingSeekTime = undefined;\n }\n });\n }\n\n async #runSeek(targetTime: number): Promise<number | undefined> {\n // Abort any in-flight seek\n this.#seekAbortController?.abort();\n this.#seekAbortController = new AbortController();\n const signal = this.#seekAbortController.signal;\n\n try {\n signal.throwIfAborted();\n\n await this.#host.waitForMediaDurations?.(signal);\n signal.throwIfAborted();\n\n const newTime = Math.max(0, Math.min(targetTime, this.#host.durationMs / 1000));\n this.#currentTime = newTime;\n this.#host.requestUpdate(\"currentTime\");\n this.#currentTimeMsProvider.setValue(this.currentTimeMs);\n this.#notifyListeners({\n property: \"currentTimeMs\",\n value: this.currentTimeMs,\n });\n\n signal.throwIfAborted();\n\n await this.runThrottledFrameTask();\n signal.throwIfAborted();\n\n // Save to localStorage for persistence (only if not restoring to avoid loops)\n const isRestoring = (this.#host as any).isRestoringFromLocalStorage?.() ?? false;\n if (!isRestoring) {\n this.#host.saveTimeToLocalStorage?.(newTime);\n } else {\n (this.#host as any).setRestoringFromLocalStorage?.(false);\n }\n this.#seekInProgress = false;\n return newTime;\n } catch (error) {\n if (error instanceof DOMException && error.name === \"AbortError\") {\n // Expected - don't log\n return undefined;\n }\n throw error;\n }\n }\n\n get playing(): boolean {\n return this.#playing;\n }\n\n setPlaying(value: boolean): void {\n if (this.#playing === value) return;\n this.#playing = value;\n this.#playingProvider.setValue(value);\n this.#host.requestUpdate(\"playing\");\n this.#notifyListeners({ property: \"playing\", value });\n\n if (value) {\n this.startPlayback();\n } else {\n this.stopPlayback();\n }\n }\n\n get loop(): boolean {\n return this.#loop;\n }\n\n setLoop(value: boolean): void {\n if (this.#loop === value) return;\n this.#loop = value;\n this.#loopProvider.setValue(value);\n this.#host.requestUpdate(\"loop\");\n this.#notifyListeners({ property: \"loop\", value });\n }\n\n get currentTimeMs(): number {\n return this.currentTime * 1000;\n }\n\n setCurrentTimeMs(value: number): void {\n this.currentTime = value / 1000;\n }\n\n // Update time during playback without triggering a seek\n // Used by #syncPlayheadToAudioContext to avoid frame drops\n #updatePlaybackTime(timeMs: number): void {\n // Clamp to valid range to prevent time exceeding duration\n const durationMs = this.#host.durationMs;\n const clampedTimeMs = Math.max(0, Math.min(timeMs, durationMs));\n const timeSec = clampedTimeMs / 1000;\n if (this.#currentTime === timeSec) {\n return;\n }\n this.#currentTime = timeSec;\n this.#host.requestUpdate(\"currentTime\");\n this.#currentTimeMsProvider.setValue(clampedTimeMs);\n this.#notifyListeners({\n property: \"currentTimeMs\",\n value: clampedTimeMs,\n });\n // Trigger frame rendering without the async seek mechanism\n this.runThrottledFrameTask();\n }\n\n play(): void {\n this.setPlaying(true);\n }\n\n pause(): void {\n this.setPlaying(false);\n }\n\n #removed = false;\n\n hostConnected(): void {\n const isReconnect = this.#hasConnected;\n this.#hasConnected = true;\n // Defer all operations to avoid blocking during initialization\n // This prevents deadlocks when many timegroups are initializing simultaneously\n requestAnimationFrame(() => {\n requestAnimationFrame(() => {\n // Check if this controller was removed before the RAF callback executed.\n // This happens when wrapWithWorkbench moves the element, causing disconnect/reconnect.\n if (this.#removed || this.#host.playbackController !== this) {\n return;\n }\n\n if (this.#playing && isReconnect) {\n this.startPlayback();\n } else if (!this.#playing) {\n this.#initializeTime();\n }\n });\n });\n }\n\n async #initializeTime(): Promise<void> {\n try {\n const waitPromise = this.#host.waitForMediaDurations?.();\n if (waitPromise) {\n await waitPromise;\n }\n } catch (err) {\n const isAbortError =\n (err instanceof DOMException && err.name === \"AbortError\") ||\n (err instanceof Error &&\n (err.name === \"AbortError\" ||\n err.message.includes(\"signal is aborted\") ||\n err.message.includes(\"The user aborted a request\")));\n if (!isAbortError) {\n console.error(\"Error in PlaybackController hostConnected:\", err);\n }\n return;\n }\n\n if (this.#removed || this.#host.playbackController !== this) {\n return;\n }\n\n const maybeLoadedTime = this.#host.loadTimeFromLocalStorage?.();\n if (maybeLoadedTime !== undefined) {\n (this.#host as any).setRestoringFromLocalStorage?.(true);\n this.currentTime = maybeLoadedTime;\n } else if (this.#currentTime === undefined) {\n this.currentTime = 0;\n }\n }\n\n hostDisconnected(): void {\n this.pause();\n }\n\n hostUpdated(): void {\n this.#durationMsProvider.setValue(this.#host.durationMs);\n this.#currentTimeMsProvider.setValue(this.currentTimeMs);\n }\n\n #selfRenderAbortController?: AbortController;\n #selfRenderPromise?: Promise<void>;\n #selfRenderDirty = false;\n #selfRenderSuspended = false;\n\n suspendSelfRender(): void {\n this.#selfRenderSuspended = true;\n this.#selfRenderAbortController?.abort();\n this.#selfRenderAbortController = undefined;\n }\n\n resumeSelfRender(): void {\n this.#selfRenderSuspended = false;\n }\n\n /**\n * Run frame rendering via FrameController, or directly on the host if it\n * implements FrameRenderable (standalone media element without a Timegroup).\n */\n async runThrottledFrameTask(): Promise<void> {\n const timeMs = this.currentTimeMs;\n\n if (this.#host.frameController) {\n try {\n await this.#host.frameController.renderFrame(timeMs, {\n onAnimationsUpdate: (root: Element) => {\n updateAnimations(root as unknown as AnimatableElement);\n },\n });\n } catch (error) {\n if (error instanceof DOMException && error.name === \"AbortError\") return;\n console.error(\"FrameController error:\", error);\n }\n return;\n }\n\n // Standalone FrameRenderable host (e.g. bare ef-video without a Timegroup)\n const host = this.#host as unknown as Partial<FrameRenderable>;\n if (!host.prepareFrame || !host.renderFrame) return;\n\n if (this.#selfRenderSuspended) return;\n\n // If a render is in-flight, mark dirty so we re-render after it\n // completes (source mapping may have changed due to trim drag).\n if (this.#selfRenderPromise) {\n this.#selfRenderDirty = true;\n return this.#selfRenderPromise;\n }\n\n return this.#startSelfRender(host, timeMs);\n }\n\n #startSelfRender(host: Partial<FrameRenderable>, timeMs: number): Promise<void> {\n this.#selfRenderAbortController?.abort();\n this.#selfRenderAbortController = new AbortController();\n const signal = this.#selfRenderAbortController.signal;\n this.#selfRenderDirty = false;\n\n this.#selfRenderPromise = (async () => {\n try {\n await host.prepareFrame!(timeMs, signal);\n signal.throwIfAborted();\n host.renderFrame!(timeMs);\n updateAnimations(this.#host as unknown as AnimatableElement);\n } catch (error) {\n if (error instanceof DOMException && error.name === \"AbortError\") return;\n if ((error as any)?.name === \"AbortError\") return;\n console.error(\"Standalone frame render error:\", error);\n } finally {\n this.#selfRenderPromise = undefined;\n // Re-render if source mapping changed while we were rendering\n if (this.#selfRenderDirty && !this.#selfRenderSuspended) {\n this.#startSelfRender(host, this.currentTimeMs);\n }\n }\n })();\n\n return this.#selfRenderPromise;\n }\n\n addListener(listener: (event: PlaybackControllerUpdateEvent) => void): void {\n this.#listeners.add(listener);\n }\n\n removeListener(listener: (event: PlaybackControllerUpdateEvent) => void): void {\n this.#listeners.delete(listener);\n }\n\n #notifyListeners(event: PlaybackControllerUpdateEvent): void {\n for (const listener of this.#listeners) {\n listener(event);\n }\n }\n\n remove(): void {\n this.#removed = true; // Mark as removed to abort any pending RAF callbacks\n this.stopPlayback();\n this.#listeners.clear();\n this.#host.removeController(this);\n }\n\n setPendingAudioContext(context: AudioContext): void {\n this.#pendingAudioContext = context;\n }\n\n #syncPlayheadToAudioContext(startMs: number) {\n const audioContextTime = this.#playbackAudioContext?.currentTime ?? 0;\n const endMs = this.#host.endTimeMs;\n\n // Calculate raw time based on audio context\n let rawTimeMs: number;\n if (this.#playbackWrapTimeSeconds > 0 && audioContextTime >= this.#playbackWrapTimeSeconds) {\n // After wrap: time since wrap, wrapped to duration\n const timeSinceWrap = (audioContextTime - this.#playbackWrapTimeSeconds) * 1000;\n rawTimeMs = timeSinceWrap % endMs;\n } else {\n // Before wrap or no wrap: normal calculation\n rawTimeMs = startMs + audioContextTime * 1000;\n\n // If looping and we've reached the end, wrap around\n if (this.#loopingPlayback && rawTimeMs >= endMs) {\n rawTimeMs = rawTimeMs % endMs;\n }\n }\n\n const nextTimeMs = Math.round(rawTimeMs / this.#MS_PER_FRAME) * this.#MS_PER_FRAME;\n\n // During playback, update time directly without triggering seek\n // This avoids frame drops at the loop boundary\n this.#updatePlaybackTime(nextTimeMs);\n\n // Only check for end if we haven't already handled looping\n if (!this.#loopingPlayback && nextTimeMs >= endMs) {\n this.maybeLoopPlayback();\n return;\n }\n\n this.#playbackAnimationFrameRequest = requestAnimationFrame(() => {\n this.#syncPlayheadToAudioContext(startMs);\n });\n }\n\n #syncPlayheadToRaf() {\n if (this.#rafStartWallMs === null) return;\n const endMs = this.#host.endTimeMs;\n const elapsed = performance.now() - this.#rafStartWallMs;\n let rawTimeMs = this.#rafStartLogicalMs + elapsed;\n\n if (this.#loopingPlayback) {\n rawTimeMs = rawTimeMs % endMs;\n }\n\n const nextTimeMs = Math.round(rawTimeMs / this.#MS_PER_FRAME) * this.#MS_PER_FRAME;\n this.#updatePlaybackTime(nextTimeMs);\n\n if (!this.#loopingPlayback && nextTimeMs >= endMs) {\n this.maybeLoopPlayback();\n return;\n }\n\n this.#playbackAnimationFrameRequest = requestAnimationFrame(() => {\n this.#syncPlayheadToRaf();\n });\n }\n\n private async maybeLoopPlayback() {\n if (this.#loop) {\n // Loop enabled: reset to beginning and restart playback\n // We restart the audio system directly without changing #playing state\n // to keep the play button in sync\n this.setCurrentTimeMs(0);\n // Restart in next frame without awaiting to minimize gap\n requestAnimationFrame(() => {\n this.startPlayback();\n });\n } else {\n // No loop: reset to beginning and stop\n // This ensures play button works when clicked again\n this.setCurrentTimeMs(0);\n this.pause();\n }\n }\n\n private async stopPlayback() {\n if (this.#playbackAnimationFrameRequest) {\n cancelAnimationFrame(this.#playbackAnimationFrameRequest);\n this.#playbackAnimationFrameRequest = null;\n }\n this.#rafStartWallMs = null;\n if (this.#playbackAudioContext) {\n if (this.#playbackAudioContext.state !== \"closed\") {\n await this.#playbackAudioContext.close();\n }\n }\n this.#playbackAudioContext = null;\n this.#pendingAudioContext = null;\n }\n\n private async startPlayback() {\n // Guard against starting playback on a removed controller\n if (this.#removed) {\n return;\n }\n\n await this.stopPlayback();\n const host = this.#host;\n if (!host) {\n return;\n }\n\n if (host.waitForMediaDurations) {\n await host.waitForMediaDurations();\n }\n\n // Check again after async - controller could have been removed\n if (this.#removed) {\n return;\n }\n\n const currentMs = this.currentTimeMs;\n const fromMs = currentMs;\n const toMs = host.endTimeMs;\n\n if (fromMs >= toMs) {\n this.pause();\n return;\n }\n\n this.#loopingPlayback = this.#loop; // Remember if we're in a looping session\n this.#playbackWrapTimeSeconds = 0; // Reset wrap time\n\n if (this.#playbackAnimationFrameRequest) {\n cancelAnimationFrame(this.#playbackAnimationFrameRequest);\n }\n\n // When there is no audio, drive the playhead with performance.now() to avoid\n // AudioContext autoplay restrictions entirely.\n if (!host.renderAudio) {\n this.#rafStartWallMs = performance.now();\n this.#rafStartLogicalMs = currentMs;\n this.#syncPlayheadToRaf();\n return;\n }\n\n let bufferCount = 0;\n // Check for pre-resumed AudioContext from synchronous user interaction\n if (this.#pendingAudioContext) {\n this.#playbackAudioContext = this.#pendingAudioContext;\n this.#pendingAudioContext = null;\n } else {\n this.#playbackAudioContext = new AudioContext({\n latencyHint: \"playback\",\n });\n }\n\n const playbackContext = this.#playbackAudioContext;\n\n if (playbackContext.state === \"suspended\") {\n // AudioContext blocked by autoplay policy — fall back to rAF clock immediately.\n playbackContext.close().catch(() => {});\n this.#playbackAudioContext = null;\n this.#rafStartWallMs = performance.now();\n this.#rafStartLogicalMs = currentMs;\n this.#syncPlayheadToRaf();\n return;\n }\n this.#syncPlayheadToAudioContext(currentMs);\n await playbackContext.suspend();\n\n // Track the logical media time (what position in the media we're rendering)\n // vs the AudioContext schedule time (when to play it)\n let logicalTimeMs = currentMs;\n let audioContextTimeMs = 0; // Tracks the schedule position in the AudioContext timeline\n let hasWrapped = false;\n\n const fillBuffer = async () => {\n if (bufferCount > 2) {\n return;\n }\n const canFillBuffer = await queueBufferSource();\n if (canFillBuffer) {\n fillBuffer().catch(() => {});\n }\n };\n\n const queueBufferSource = async () => {\n // Check if we've already wrapped and aren't looping anymore\n if (hasWrapped && !this.#loopingPlayback) {\n return false;\n }\n\n const startMs = logicalTimeMs;\n const endMs = Math.min(logicalTimeMs + this.#AUDIO_PLAYBACK_SLICE_MS, toMs);\n\n // Will this slice reach the end?\n const willReachEnd = endMs >= toMs;\n\n if (!host.renderAudio) {\n return false;\n }\n\n const audioBuffer = await host.renderAudio(startMs, endMs);\n bufferCount++;\n const source = playbackContext.createBufferSource();\n source.buffer = audioBuffer;\n source.connect(playbackContext.destination);\n // Schedule this buffer to play at the current audioContextTime position\n source.start(audioContextTimeMs / 1000);\n\n const sliceDurationMs = endMs - startMs;\n\n source.onended = () => {\n bufferCount--;\n\n if (willReachEnd) {\n if (!this.#loopingPlayback) {\n // Not looping, end playback\n this.maybeLoopPlayback();\n } else {\n // Looping: continue filling buffer after wrap\n fillBuffer().catch(() => {});\n }\n } else {\n // Continue filling buffer\n fillBuffer().catch(() => {});\n }\n };\n\n // Advance the AudioContext schedule time\n audioContextTimeMs += sliceDurationMs;\n\n // If this buffer reaches the end and we're looping, immediately queue the wraparound\n if (willReachEnd && this.#loopingPlayback) {\n // Mark that we've wrapped\n hasWrapped = true;\n // Store when we wrapped (relative to when playback started, which is time 0 in AudioContext)\n // This is the duration from start to end\n this.#playbackWrapTimeSeconds = (toMs - fromMs) / 1000;\n // Reset logical time to beginning\n logicalTimeMs = 0;\n // Continue buffering will happen in fillBuffer() call below\n } else {\n // Normal advance\n logicalTimeMs = endMs;\n }\n\n return true;\n };\n\n try {\n await fillBuffer();\n await playbackContext.resume();\n } catch (error) {\n // Ignore errors if AudioContext is closed or during test cleanup\n if (\n error instanceof Error &&\n (error.name === \"InvalidStateError\" || error.message.includes(\"closed\"))\n ) {\n return;\n }\n throw error;\n }\n }\n}\n"],"mappings":"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"}
|