@editframe/elements 0.42.0 → 0.42.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.
@@ -1 +1 @@
1
- {"version":3,"file":"PlaybackController.js","names":["#FPS","#host","#playingProvider","#playing","#loopProvider","#loop","#currentTimeMsProvider","#durationMsProvider","#currentTime","#processingPendingSeek","#pendingSeekTime","#seekInProgress","#runSeek","#seekAbortController","#notifyListeners","#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 {\n updateAnimations,\n type AnimatableElement,\n} from \"../elements/updateAnimations.js\";\nimport type {\n RenderFrameOptions,\n FrameRenderable,\n} 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\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 } =\n await import(\"../elements/updateAnimations.js\");\n updateAnimations(this.#host as any);\n\n if (\n this.#pendingSeekTime !== undefined &&\n this.#pendingSeekTime !== time\n ) {\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(\n 0,\n Math.min(targetTime, this.#host.durationMs / 1000),\n );\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 =\n (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 // 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) {\n this.startPlayback();\n } else {\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\")\n 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(\n host: Partial<FrameRenderable>,\n timeMs: number,\n ): 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\")\n 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(\n listener: (event: PlaybackControllerUpdateEvent) => void,\n ): 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 (\n this.#playbackWrapTimeSeconds > 0 &&\n audioContextTime >= this.#playbackWrapTimeSeconds\n ) {\n // After wrap: time since wrap, wrapped to duration\n const timeSinceWrap =\n (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 =\n 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.#playbackAudioContext) {\n if (this.#playbackAudioContext.state !== \"closed\") {\n await this.#playbackAudioContext.close();\n }\n }\n if (this.#playbackAnimationFrameRequest) {\n cancelAnimationFrame(this.#playbackAnimationFrameRequest);\n }\n this.#playbackAudioContext = null;\n this.#playbackAnimationFrameRequest = 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(\n logicalTimeMs + this.#AUDIO_PLAYBACK_SLICE_MS,\n toMs,\n );\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","#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 {\n updateAnimations,\n type AnimatableElement,\n} from \"../elements/updateAnimations.js\";\nimport type {\n RenderFrameOptions,\n FrameRenderable,\n} 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 } =\n await import(\"../elements/updateAnimations.js\");\n updateAnimations(this.#host as any);\n\n if (\n this.#pendingSeekTime !== undefined &&\n this.#pendingSeekTime !== time\n ) {\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(\n 0,\n Math.min(targetTime, this.#host.durationMs / 1000),\n );\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 =\n (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\")\n 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(\n host: Partial<FrameRenderable>,\n timeMs: number,\n ): 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\")\n 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(\n listener: (event: PlaybackControllerUpdateEvent) => void,\n ): 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 (\n this.#playbackWrapTimeSeconds > 0 &&\n audioContextTime >= this.#playbackWrapTimeSeconds\n ) {\n // After wrap: time since wrap, wrapped to duration\n const timeSinceWrap =\n (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 =\n 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.#playbackAudioContext) {\n if (this.#playbackAudioContext.state !== \"closed\") {\n await this.#playbackAudioContext.close();\n }\n }\n if (this.#playbackAnimationFrameRequest) {\n cancelAnimationFrame(this.#playbackAnimationFrameRequest);\n }\n this.#playbackAudioContext = null;\n this.#playbackAnimationFrameRequest = 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(\n logicalTimeMs + this.#AUDIO_PLAYBACK_SLICE_MS,\n toMs,\n );\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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"}
@@ -345,7 +345,7 @@ async function renderTimegroupToVideo(timegroup, options = {}) {
345
345
  } catch (e) {}
346
346
  if (config.benchmarkMode) return;
347
347
  await output.finalize();
348
- {
348
+ if (typeof process !== "undefined" && process.env.EF_TELEMETRY_ENABLED === "true") {
349
349
  const elapsedMs = Math.round(performance.now() - renderStartTime);
350
350
  const endpoint = options.telemetryEndpoint ?? "https://editframe.com";
351
351
  const efMediaCount = timegroup.querySelectorAll("ef-video,ef-audio").length;
@@ -1 +1 @@
1
- {"version":3,"file":"renderTimegroupToVideo.js","names":["timestamps: number[]","output: Output | null","videoSource: CanvasSource | null","audioSource: AudioBufferSource | null","target: BufferTarget | StreamTarget | null","fileStream: {\n writable: WritableStream<Uint8Array>;\n close: () => Promise<void>;\n } | null","encodingCanvas: OffscreenCanvas | null","encodingCtx: OffscreenCanvasRenderingContext2D | null","videoConfig: VideoEncodingConfig","thumbCanvas: HTMLCanvasElement | null","thumbCtx: CanvasRenderingContext2D | null","pendingFrames: PendingFrame[]","entry: PendingFrame","image","image: HTMLImageElement","headers: Record<string, string>"],"sources":["../../src/preview/renderTimegroupToVideo.ts"],"sourcesContent":["/**\n * Video rendering for timegroups using direct serialization.\n *\n * Architecture:\n * - Creates a render clone of the timeline\n * - For each frame:\n * 1. Seeks the clone to the target time\n * 2. Executes frame tasks (SVG updates, canvas draws, etc.)\n * 3. Serializes the live DOM directly to SVG+foreignObject data URI\n * 4. Renders to image and encodes to video\n *\n * RenderContext provides pixel caching across frames for performance.\n */\n\nimport { logger } from \"./logger.js\";\nimport {\n Output,\n Mp4OutputFormat,\n BufferTarget,\n StreamTarget,\n CanvasSource,\n AudioBufferSource,\n QUALITY_HIGH,\n canEncodeAudio,\n getEncodableAudioCodecs,\n type VideoEncodingConfig,\n type AudioEncodingConfig,\n type AudioCodec,\n} from \"mediabunny\";\nimport type { EFTimegroup } from \"../elements/EFTimegroup.js\";\nimport type { RenderToVideoOptions } from \"./renderTimegroupToVideo.types.js\";\nimport type { ContentReadyMode } from \"./renderTimegroupToCanvas.types.js\";\nimport {\n resetRenderState,\n waitForVideoContent,\n} from \"./renderTimegroupToCanvas.js\";\nimport { captureTimelineToDataUri } from \"./rendering/serializeTimelineDirect.js\";\nimport { renderToImageNative } from \"./rendering/renderToImageNative.js\";\nimport { isNativeCanvasApiAvailable } from \"./previewSettings.js\";\nimport { createPreviewContainer } from \"./previewTypes.js\";\nimport { RenderContext } from \"./RenderContext.js\";\n\n// ============================================================================\n// Types\n// ============================================================================\n\n// Re-export types from type-only module (zero side effects)\nexport type {\n RenderProgress,\n RenderToVideoOptions,\n} from \"./renderTimegroupToVideo.types.js\";\n\n// ============================================================================\n// Errors\n// ============================================================================\n\nexport class NoSupportedAudioCodecError extends Error {\n constructor(requestedCodecs: AudioCodec[], availableCodecs: AudioCodec[]) {\n super(\n `No supported audio codec found. Requested: [${requestedCodecs.join(\", \")}], ` +\n `Available: [${availableCodecs.length > 0 ? availableCodecs.join(\", \") : \"none\"}]`,\n );\n this.name = \"NoSupportedAudioCodecError\";\n }\n}\n\nexport class RenderCancelledError extends Error {\n constructor() {\n super(\"Render cancelled\");\n this.name = \"RenderCancelledError\";\n }\n}\n\n// ============================================================================\n// Configuration\n// ============================================================================\n\ninterface ResolvedConfig {\n fps: number;\n codec: \"avc\" | \"hevc\" | \"vp9\" | \"av1\" | \"vp8\";\n bitrate: number;\n filename: string;\n scale: number;\n keyFrameInterval: number;\n startMs: number;\n endMs: number;\n renderDurationMs: number;\n width: number;\n height: number;\n videoWidth: number;\n videoHeight: number;\n totalFrames: number;\n frameDurationMs: number;\n frameDurationS: number;\n streaming: boolean;\n includeAudio: boolean;\n audioBitrate: number;\n contentReadyMode: ContentReadyMode;\n blockingTimeoutMs: number;\n returnBuffer: boolean;\n preferredAudioCodecs: AudioCodec[];\n benchmarkMode: boolean;\n progressPreviewInterval: number;\n canvasMode: \"native\" | \"foreignObject\";\n}\n\nfunction resolveConfig(\n timegroup: EFTimegroup,\n options: RenderToVideoOptions = {},\n): ResolvedConfig {\n const fps = options.fps ?? timegroup.effectiveFps ?? 30;\n const codec = options.codec ?? \"avc\";\n const bitrate = options.bitrate ?? 8_000_000;\n const filename = options.filename ?? \"timegroup-video.mp4\";\n const scale = options.scale ?? 1;\n const keyFrameInterval = options.keyFrameInterval ?? 2;\n const streaming = options.streaming ?? true;\n const includeAudio = options.includeAudio ?? true;\n const audioBitrate = options.audioBitrate ?? 128_000;\n const contentReadyMode = options.contentReadyMode ?? \"blocking\";\n const blockingTimeoutMs = options.blockingTimeoutMs ?? 5000;\n const returnBuffer = options.returnBuffer ?? false;\n const preferredAudioCodecs = options.preferredAudioCodecs ?? [\"aac\", \"opus\"];\n const benchmarkMode = options.benchmarkMode ?? false;\n // Preview generation now uses canvas reference (no encoding) - cheap to enable!\n // Defaults to 60 frames (every 2 seconds at 30fps). Set to 0 to disable.\n const progressPreviewInterval = options.progressPreviewInterval ?? 60;\n\n const totalDurationMs = timegroup.durationMs;\n if (!totalDurationMs || totalDurationMs <= 0) {\n throw new Error(\"Timegroup has no duration\");\n }\n\n const startMs = Math.max(0, options.fromMs ?? 0);\n const endMs =\n options.toMs !== undefined\n ? Math.min(options.toMs, totalDurationMs)\n : totalDurationMs;\n const renderDurationMs = endMs - startMs;\n\n if (renderDurationMs <= 0) {\n throw new Error(`Invalid render range: from ${startMs}ms to ${endMs}ms`);\n }\n\n // Force layout reflow before reading dimensions\n void timegroup.offsetHeight;\n\n // Try multiple sources for dimensions (offsetWidth can be 0 in headless browsers)\n let timegroupWidth = timegroup.offsetWidth;\n let timegroupHeight = timegroup.offsetHeight;\n\n if (!timegroupWidth || !timegroupHeight) {\n const rect = timegroup.getBoundingClientRect();\n if (rect.width > 0 && rect.height > 0) {\n timegroupWidth = rect.width;\n timegroupHeight = rect.height;\n }\n }\n\n if (!timegroupWidth || !timegroupHeight) {\n const computed = getComputedStyle(timegroup);\n const cw = parseFloat(computed.width);\n const ch = parseFloat(computed.height);\n if (cw > 0 && ch > 0) {\n timegroupWidth = cw;\n timegroupHeight = ch;\n }\n }\n\n if (!timegroupWidth || !timegroupHeight) {\n throw new Error(\n `Timegroup has no dimensions (${timegroupWidth}x${timegroupHeight}). ` +\n `Ensure the timegroup element is in the document and has explicit width/height styles ` +\n `(e.g., class=\"w-[1920px] h-[1080px]\")`,\n );\n }\n const width = Math.floor(timegroupWidth * scale);\n const height = Math.floor(timegroupHeight * scale);\n\n const videoWidth = width % 2 === 0 ? width : width - 1;\n const videoHeight = height % 2 === 0 ? height : height - 1;\n\n const frameDurationMs = 1000 / fps;\n const totalFrames = Math.ceil(renderDurationMs / frameDurationMs);\n const frameDurationS = frameDurationMs / 1000;\n\n // Determine effective canvas mode:\n // 1. If explicitly specified, use that (with fallback if native not available)\n // 2. If not specified, default to foreignObject for compatibility\n const canvasMode = (() => {\n const requested = options.canvasMode;\n if (!requested) return \"foreignObject\";\n if (requested === \"native\" && !isNativeCanvasApiAvailable()) {\n logger.debug(\n \"[renderTimegroupToVideo] Native canvas mode requested but not available, falling back to foreignObject\",\n );\n return \"foreignObject\";\n }\n return requested;\n })();\n\n return {\n fps,\n codec,\n bitrate,\n filename,\n scale,\n keyFrameInterval,\n startMs,\n endMs,\n renderDurationMs,\n width,\n height,\n videoWidth,\n videoHeight,\n totalFrames,\n frameDurationMs,\n frameDurationS,\n streaming,\n includeAudio,\n audioBitrate,\n contentReadyMode,\n blockingTimeoutMs,\n returnBuffer,\n preferredAudioCodecs,\n benchmarkMode,\n progressPreviewInterval,\n canvasMode,\n };\n}\n\n// ============================================================================\n// Helpers\n// ============================================================================\n\nfunction isFileSystemAccessSupported(): boolean {\n return typeof window !== \"undefined\" && \"showSaveFilePicker\" in window;\n}\n\nasync function getFileWritableStream(filename: string): Promise<{\n writable: WritableStream<Uint8Array>;\n close: () => Promise<void>;\n} | null> {\n if (!isFileSystemAccessSupported()) {\n return null;\n }\n\n try {\n const fileHandle = await (window as any).showSaveFilePicker({\n suggestedName: filename,\n types: [{ description: \"MP4 Video\", accept: { \"video/mp4\": [\".mp4\"] } }],\n });\n const writable = await fileHandle.createWritable();\n return {\n writable,\n close: async () => {\n await writable.close();\n },\n };\n } catch (e) {\n if ((e as Error).name !== \"AbortError\") {\n logger.warn(\"[renderToVideo] File System Access failed:\", e);\n }\n return null;\n }\n}\n\nasync function selectAudioCodec(\n preferredCodecs: AudioCodec[],\n encodingOptions: {\n numberOfChannels: number;\n sampleRate: number;\n bitrate: number;\n },\n): Promise<AudioCodec> {\n for (const codec of preferredCodecs) {\n try {\n const isSupported = await canEncodeAudio(codec, encodingOptions);\n if (isSupported) return codec;\n } catch (e) {\n logger.warn(`[selectAudioCodec] Check failed for ${codec}:`, e);\n }\n }\n const availableCodecs = await getEncodableAudioCodecs(\n undefined,\n encodingOptions,\n );\n throw new NoSupportedAudioCodecError(preferredCodecs, availableCodecs);\n}\n\nfunction downloadBlob(blob: Blob, filename: string): void {\n const url = URL.createObjectURL(blob);\n const a = document.createElement(\"a\");\n a.href = url;\n a.download = filename;\n document.body.appendChild(a);\n a.click();\n document.body.removeChild(a);\n URL.revokeObjectURL(url);\n}\n\n// ============================================================================\n// Public API\n// ============================================================================\n\nexport async function getSupportedAudioCodecs(options?: {\n numberOfChannels?: number;\n sampleRate?: number;\n bitrate?: number;\n}): Promise<AudioCodec[]> {\n const {\n numberOfChannels = 2,\n sampleRate = 48000,\n bitrate = 128000,\n } = options ?? {};\n return getEncodableAudioCodecs(undefined, {\n numberOfChannels,\n sampleRate,\n bitrate,\n });\n}\n\n/**\n * Renders a timegroup to an MP4 video file.\n *\n * Uses the EXACT same code path as thumbnail generation (captureFromClone).\n * This ensures consistency - if thumbnails work, video export works.\n */\nexport async function renderTimegroupToVideo(\n timegroup: EFTimegroup,\n options: RenderToVideoOptions = {},\n): Promise<Uint8Array | undefined> {\n const config = resolveConfig(timegroup, options);\n const { signal, onProgress } = options;\n\n const checkCancelled = () => {\n if (signal?.aborted) throw new RenderCancelledError();\n };\n\n resetRenderState();\n\n // =========================================================================\n // Create render clone - EXACT same as captureBatch in EFTimegroup\n // =========================================================================\n const {\n clone: renderClone,\n container: cloneContainer,\n cleanup: cleanupRenderClone,\n } = await timegroup.createRenderClone();\n\n // Build timestamps array for frame loop\n const timestamps: number[] = [];\n for (let i = 0; i < config.totalFrames; i++) {\n timestamps.push(config.startMs + i * config.frameDurationMs);\n }\n\n // =========================================================================\n // Set up video encoding\n // =========================================================================\n let output: Output | null = null;\n let videoSource: CanvasSource | null = null;\n let audioSource: AudioBufferSource | null = null;\n let target: BufferTarget | StreamTarget | null = null;\n let fileStream: {\n writable: WritableStream<Uint8Array>;\n close: () => Promise<void>;\n } | null = null;\n let useStreaming = false;\n let encodingCanvas: OffscreenCanvas | null = null;\n let encodingCtx: OffscreenCanvasRenderingContext2D | null = null;\n\n if (!config.benchmarkMode) {\n // Check for custom writable stream first (for programmatic streaming)\n if (options.customWritableStream) {\n target = new StreamTarget(options.customWritableStream as any);\n output = new Output({\n format: new Mp4OutputFormat({ fastStart: \"fragmented\" }),\n target,\n });\n useStreaming = true;\n } else if (config.streaming) {\n fileStream = await getFileWritableStream(config.filename);\n useStreaming = fileStream !== null;\n\n if (useStreaming && fileStream) {\n target = new StreamTarget(fileStream.writable as any);\n output = new Output({\n format: new Mp4OutputFormat({ fastStart: \"fragmented\" }),\n target,\n });\n }\n }\n\n if (!target) {\n target = new BufferTarget();\n output = new Output({ format: new Mp4OutputFormat(), target });\n }\n\n encodingCanvas = new OffscreenCanvas(config.videoWidth, config.videoHeight);\n encodingCtx = encodingCanvas.getContext(\"2d\");\n if (!encodingCtx) {\n cleanupRenderClone();\n throw new Error(\"Failed to get encoding canvas context\");\n }\n\n if (!output) {\n throw new Error(\"Output not initialized\");\n }\n\n const videoConfig: VideoEncodingConfig = {\n codec: config.codec,\n bitrate: config.bitrate,\n keyFrameInterval: config.keyFrameInterval,\n };\n videoSource = new CanvasSource(encodingCanvas, videoConfig);\n output.addVideoTrack(videoSource);\n\n if (config.includeAudio) {\n const selectedCodec = await selectAudioCodec(\n config.preferredAudioCodecs,\n {\n numberOfChannels: 2,\n sampleRate: 48000,\n bitrate: config.audioBitrate,\n },\n );\n const audioConfig: AudioEncodingConfig = {\n codec: selectedCodec,\n bitrate: config.audioBitrate,\n };\n audioSource = new AudioBufferSource(audioConfig);\n output.addAudioTrack(audioSource);\n }\n\n await output.start();\n }\n\n // =========================================================================\n // Setup for per-frame passive structure rebuilding (like live preview)\n // =========================================================================\n // Create RenderContext for caching across all frames\n const renderContext = new RenderContext();\n\n // Create preview container with proper styling (reusable, content rebuilt each frame)\n // Use unscaled dimensions for the preview container (which holds the full-size clone)\n const containerWidth = timegroup.offsetWidth || 1920;\n const containerHeight = timegroup.offsetHeight || 1080;\n const previewContainer = createPreviewContainer({\n width: containerWidth,\n height: containerHeight,\n background: getComputedStyle(timegroup).background || \"#000\",\n });\n\n // Setup for direct serialization\n logger.debug(`[renderTimegroupToVideo] Using direct timeline serialization`);\n\n // Attach clone container (keeps renderClone in its React-managed DOM position)\n previewContainer.appendChild(cloneContainer);\n\n // Add ef-render-clone-container class for CSS selectors and debugging\n previewContainer.classList.add(\"ef-render-clone-container\");\n\n // CRITICAL: Attach container to document so getComputedStyle returns actual values\n // Without this, all computed styles are empty strings!\n // Hide the container OFF-SCREEN but do NOT use visibility:hidden because:\n // 1. visibility:hidden is inherited by all children\n // 2. seekForRender checks getComputedStyle().visibility and skips \"hidden\" subtrees\n // 3. This would cause FrameController to skip rendering all nested content\n previewContainer.style.cssText +=\n \";position:fixed;left:-99999px;top:-99999px;pointer-events:none;\";\n document.body.appendChild(previewContainer);\n\n // Force layout/reflow so getComputedStyle returns correct values\n void renderClone.offsetHeight;\n logger.debug(\n `[renderTimegroupToVideo] Attached previewContainer to document.body (off-screen) for style computation`,\n );\n\n // =========================================================================\n // Frame loop - DEEP PIPELINE: overlap encode + render + prepare\n // =========================================================================\n const renderStartTime = performance.now();\n let lastRenderedAudioEndMs = config.startMs;\n const audioChunkDurationMs = 2000;\n\n // Reusable thumbnail canvas for preview (no encoding, just draw to canvas)\n let thumbCanvas: HTMLCanvasElement | null = null;\n let thumbCtx: CanvasRenderingContext2D | null = null;\n if (onProgress && config.progressPreviewInterval > 0) {\n const previewWidth = 160;\n const previewHeight = Math.round(\n previewWidth * (config.videoHeight / config.videoWidth),\n );\n thumbCanvas = document.createElement(\"canvas\");\n thumbCanvas.width = previewWidth;\n thumbCanvas.height = previewHeight;\n thumbCtx = thumbCanvas.getContext(\"2d\");\n }\n\n try {\n // ========================================================================\n // OVERLAPPED PIPELINE: image loading runs parallel with seek+serialize\n // ========================================================================\n // The clone can only seek one frame at a time, and serialization must\n // capture the DOM before the next seek. But image loading (data URI →\n // Image) is independent of the clone and runs in the background.\n //\n // Per-frame timeline:\n // [seek(N)] → [serialize(N)] → [image.load(N) in background...]\n // └─ [seek(N+1)] → [serialize(N+1)] → ...\n // └─ encode(N) when image resolves\n\n type PendingFrame = {\n frameIndex: number;\n timeMs: number;\n timestampS: number;\n resolved: HTMLImageElement | null;\n promise: Promise<HTMLImageElement>;\n };\n\n const MAX_AHEAD = 2;\n const pendingFrames: PendingFrame[] = [];\n let nextSeekFrame = 0;\n let encodedFrames = 0;\n\n while (encodedFrames < config.totalFrames) {\n checkCancelled();\n\n // ==================================================================\n // PHASE 1: Fill pipeline — seek+serialize ahead while images load\n // ==================================================================\n while (\n nextSeekFrame < config.totalFrames &&\n pendingFrames.length < MAX_AHEAD\n ) {\n const fi = nextSeekFrame;\n const timeMs = timestamps[fi]!;\n const timestampS = (fi * config.frameDurationMs) / 1000;\n\n await renderClone.seekForRender(timeMs);\n\n const entry: PendingFrame = {\n frameIndex: fi,\n timeMs,\n timestampS,\n resolved: null,\n promise: null!,\n };\n\n // Wait for video content if using blocking mode\n if (config.contentReadyMode === \"blocking\") {\n await waitForVideoContent(\n renderClone,\n timeMs,\n config.blockingTimeoutMs,\n );\n }\n\n if (config.canvasMode === \"native\") {\n const canvas = await renderToImageNative(\n renderClone,\n config.width,\n config.height,\n {\n skipDprScaling: true,\n },\n );\n entry.resolved = canvas as any as HTMLImageElement;\n entry.promise = Promise.resolve(entry.resolved);\n } else {\n // Synchronous capture: walks DOM + snapshots canvas pixels.\n // Returns immediately — clone is free for next seek.\n // Encoding (canvas→base64, SVG assembly) and image loading\n // all resolve in the background.\n const dataUriPromise = captureTimelineToDataUri(\n renderClone,\n config.width,\n config.height,\n {\n renderContext,\n canvasScale: config.scale,\n timeMs,\n },\n );\n\n entry.promise = dataUriPromise.then((dataUri) => {\n return new Promise<HTMLImageElement>((resolve, reject) => {\n const image = new Image();\n image.onload = () => {\n entry.resolved = image;\n resolve(image);\n };\n image.onerror = (e) => {\n console.error(`[Render] frame ${fi} image load error:`, e);\n reject(new Error(`Failed to load image from data URI`));\n };\n image.src = dataUri;\n });\n });\n }\n\n pendingFrames.push(entry);\n nextSeekFrame++;\n }\n\n // ==================================================================\n // PHASE 2: Encode next frame in order (await if not yet loaded)\n // ==================================================================\n const head = pendingFrames.shift()!;\n const preloaded = head.resolved !== null;\n let image: HTMLImageElement;\n if (preloaded) {\n image = head.resolved!;\n } else {\n image = await head.promise;\n }\n\n if (\n audioSource &&\n head.timeMs >= lastRenderedAudioEndMs + audioChunkDurationMs\n ) {\n const chunkEndMs = Math.min(\n head.timeMs + audioChunkDurationMs,\n config.endMs,\n );\n try {\n const audioBuffer = await timegroup.renderAudio(\n lastRenderedAudioEndMs,\n chunkEndMs,\n signal,\n );\n if (audioBuffer && audioBuffer.length > 0) {\n await audioSource.add(audioBuffer);\n }\n } catch (e) {\n /* Audio render failures are non-fatal */\n }\n lastRenderedAudioEndMs = chunkEndMs;\n }\n\n if (videoSource && output && encodingCtx) {\n encodingCtx.drawImage(\n image,\n 0,\n 0,\n image.width,\n image.height,\n 0,\n 0,\n config.videoWidth,\n config.videoHeight,\n );\n await videoSource.add(head.timestampS, config.frameDurationS);\n }\n\n // ==================================================================\n // Progress reporting\n // ==================================================================\n encodedFrames++;\n const currentFrame = encodedFrames;\n const progress = currentFrame / config.totalFrames;\n const renderedMs = currentFrame * config.frameDurationMs;\n const elapsedMs = performance.now() - renderStartTime;\n const msPerFrame = elapsedMs / currentFrame;\n const remainingFrames = config.totalFrames - currentFrame;\n const estimatedRemainingMs = remainingFrames * msPerFrame;\n const speedMultiplier = renderedMs / elapsedMs;\n\n if (\n thumbCanvas &&\n thumbCtx &&\n head.frameIndex % config.progressPreviewInterval === 0\n ) {\n thumbCtx.drawImage(image, 0, 0, thumbCanvas.width, thumbCanvas.height);\n }\n\n onProgress?.({\n progress,\n currentFrame,\n totalFrames: config.totalFrames,\n renderedMs,\n totalDurationMs: config.renderDurationMs,\n elapsedMs,\n estimatedRemainingMs,\n speedMultiplier,\n framePreviewCanvas: thumbCanvas || undefined,\n });\n }\n\n // Render remaining audio\n if (audioSource && lastRenderedAudioEndMs < config.endMs) {\n try {\n const audioBuffer = await timegroup.renderAudio(\n lastRenderedAudioEndMs,\n config.endMs,\n signal,\n );\n if (audioBuffer && audioBuffer.length > 0) {\n await audioSource.add(audioBuffer);\n }\n } catch (e) {\n /* Audio render failures are non-fatal */\n }\n }\n\n if (config.benchmarkMode) {\n return undefined;\n }\n\n await output!.finalize();\n\n // Report telemetry (fire-and-forget, anonymous)\n {\n const elapsedMs = Math.round(performance.now() - renderStartTime);\n const endpoint = options.telemetryEndpoint ?? \"https://editframe.com\";\n const efMediaCount =\n timegroup.querySelectorAll(\"ef-video,ef-audio\").length;\n const efImageCount = timegroup.querySelectorAll(\"ef-image\").length;\n const efCaptionsCount = timegroup.querySelectorAll(\"ef-captions\").length;\n const efTextCount = timegroup.querySelectorAll(\"ef-text\").length;\n const headers: Record<string, string> = {\n \"Content-Type\": \"application/json\",\n };\n if (options.telemetryToken) {\n headers[\"Authorization\"] = `Bearer ${options.telemetryToken}`;\n }\n fetch(`${endpoint}/api/v1/telemetry`, {\n method: \"POST\",\n headers,\n body: JSON.stringify({\n event_type: \"render\",\n render_path: \"client\",\n duration_ms: elapsedMs,\n width: config.videoWidth,\n height: config.videoHeight,\n fps: config.fps,\n feature_usage: {\n efMediaCount,\n efImageCount,\n efCaptionsCount,\n efTextCount,\n },\n }),\n keepalive: true,\n }).catch(() => {\n // Telemetry errors must never surface to users.\n });\n }\n\n if (useStreaming) {\n // Streaming mode: chunks already sent via customWritableStream or file stream\n return undefined;\n } else {\n const bufferTarget = target as BufferTarget;\n const videoBuffer = bufferTarget.buffer;\n if (!videoBuffer) {\n throw new Error(\"Video encoding failed: no buffer produced\");\n }\n\n if (config.returnBuffer) {\n return new Uint8Array(videoBuffer);\n }\n\n const videoBlob = new Blob([videoBuffer], { type: \"video/mp4\" });\n downloadBlob(videoBlob, config.filename);\n return undefined;\n }\n } finally {\n renderContext.dispose();\n // Remove previewContainer first — renderClone was moved into it, so it must be\n // detached before cleanupRenderClone() unmounts the React root that owns renderClone.\n if (previewContainer.parentNode) {\n previewContainer.parentNode.removeChild(previewContainer);\n }\n cleanupRenderClone();\n }\n}\n\nexport { QUALITY_HIGH };\nexport type { AudioCodec };\n"],"mappings":"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"}
1
+ {"version":3,"file":"renderTimegroupToVideo.js","names":["timestamps: number[]","output: Output | null","videoSource: CanvasSource | null","audioSource: AudioBufferSource | null","target: BufferTarget | StreamTarget | null","fileStream: {\n writable: WritableStream<Uint8Array>;\n close: () => Promise<void>;\n } | null","encodingCanvas: OffscreenCanvas | null","encodingCtx: OffscreenCanvasRenderingContext2D | null","videoConfig: VideoEncodingConfig","thumbCanvas: HTMLCanvasElement | null","thumbCtx: CanvasRenderingContext2D | null","pendingFrames: PendingFrame[]","entry: PendingFrame","image","image: HTMLImageElement","headers: Record<string, string>"],"sources":["../../src/preview/renderTimegroupToVideo.ts"],"sourcesContent":["/**\n * Video rendering for timegroups using direct serialization.\n *\n * Architecture:\n * - Creates a render clone of the timeline\n * - For each frame:\n * 1. Seeks the clone to the target time\n * 2. Executes frame tasks (SVG updates, canvas draws, etc.)\n * 3. Serializes the live DOM directly to SVG+foreignObject data URI\n * 4. Renders to image and encodes to video\n *\n * RenderContext provides pixel caching across frames for performance.\n */\n\nimport { logger } from \"./logger.js\";\nimport {\n Output,\n Mp4OutputFormat,\n BufferTarget,\n StreamTarget,\n CanvasSource,\n AudioBufferSource,\n QUALITY_HIGH,\n canEncodeAudio,\n getEncodableAudioCodecs,\n type VideoEncodingConfig,\n type AudioEncodingConfig,\n type AudioCodec,\n} from \"mediabunny\";\nimport type { EFTimegroup } from \"../elements/EFTimegroup.js\";\nimport type { RenderToVideoOptions } from \"./renderTimegroupToVideo.types.js\";\nimport type { ContentReadyMode } from \"./renderTimegroupToCanvas.types.js\";\nimport {\n resetRenderState,\n waitForVideoContent,\n} from \"./renderTimegroupToCanvas.js\";\nimport { captureTimelineToDataUri } from \"./rendering/serializeTimelineDirect.js\";\nimport { renderToImageNative } from \"./rendering/renderToImageNative.js\";\nimport { isNativeCanvasApiAvailable } from \"./previewSettings.js\";\nimport { createPreviewContainer } from \"./previewTypes.js\";\nimport { RenderContext } from \"./RenderContext.js\";\n\n// ============================================================================\n// Types\n// ============================================================================\n\n// Re-export types from type-only module (zero side effects)\nexport type {\n RenderProgress,\n RenderToVideoOptions,\n} from \"./renderTimegroupToVideo.types.js\";\n\n// ============================================================================\n// Errors\n// ============================================================================\n\nexport class NoSupportedAudioCodecError extends Error {\n constructor(requestedCodecs: AudioCodec[], availableCodecs: AudioCodec[]) {\n super(\n `No supported audio codec found. Requested: [${requestedCodecs.join(\", \")}], ` +\n `Available: [${availableCodecs.length > 0 ? availableCodecs.join(\", \") : \"none\"}]`,\n );\n this.name = \"NoSupportedAudioCodecError\";\n }\n}\n\nexport class RenderCancelledError extends Error {\n constructor() {\n super(\"Render cancelled\");\n this.name = \"RenderCancelledError\";\n }\n}\n\n// ============================================================================\n// Configuration\n// ============================================================================\n\ninterface ResolvedConfig {\n fps: number;\n codec: \"avc\" | \"hevc\" | \"vp9\" | \"av1\" | \"vp8\";\n bitrate: number;\n filename: string;\n scale: number;\n keyFrameInterval: number;\n startMs: number;\n endMs: number;\n renderDurationMs: number;\n width: number;\n height: number;\n videoWidth: number;\n videoHeight: number;\n totalFrames: number;\n frameDurationMs: number;\n frameDurationS: number;\n streaming: boolean;\n includeAudio: boolean;\n audioBitrate: number;\n contentReadyMode: ContentReadyMode;\n blockingTimeoutMs: number;\n returnBuffer: boolean;\n preferredAudioCodecs: AudioCodec[];\n benchmarkMode: boolean;\n progressPreviewInterval: number;\n canvasMode: \"native\" | \"foreignObject\";\n}\n\nfunction resolveConfig(\n timegroup: EFTimegroup,\n options: RenderToVideoOptions = {},\n): ResolvedConfig {\n const fps = options.fps ?? timegroup.effectiveFps ?? 30;\n const codec = options.codec ?? \"avc\";\n const bitrate = options.bitrate ?? 8_000_000;\n const filename = options.filename ?? \"timegroup-video.mp4\";\n const scale = options.scale ?? 1;\n const keyFrameInterval = options.keyFrameInterval ?? 2;\n const streaming = options.streaming ?? true;\n const includeAudio = options.includeAudio ?? true;\n const audioBitrate = options.audioBitrate ?? 128_000;\n const contentReadyMode = options.contentReadyMode ?? \"blocking\";\n const blockingTimeoutMs = options.blockingTimeoutMs ?? 5000;\n const returnBuffer = options.returnBuffer ?? false;\n const preferredAudioCodecs = options.preferredAudioCodecs ?? [\"aac\", \"opus\"];\n const benchmarkMode = options.benchmarkMode ?? false;\n // Preview generation now uses canvas reference (no encoding) - cheap to enable!\n // Defaults to 60 frames (every 2 seconds at 30fps). Set to 0 to disable.\n const progressPreviewInterval = options.progressPreviewInterval ?? 60;\n\n const totalDurationMs = timegroup.durationMs;\n if (!totalDurationMs || totalDurationMs <= 0) {\n throw new Error(\"Timegroup has no duration\");\n }\n\n const startMs = Math.max(0, options.fromMs ?? 0);\n const endMs =\n options.toMs !== undefined\n ? Math.min(options.toMs, totalDurationMs)\n : totalDurationMs;\n const renderDurationMs = endMs - startMs;\n\n if (renderDurationMs <= 0) {\n throw new Error(`Invalid render range: from ${startMs}ms to ${endMs}ms`);\n }\n\n // Force layout reflow before reading dimensions\n void timegroup.offsetHeight;\n\n // Try multiple sources for dimensions (offsetWidth can be 0 in headless browsers)\n let timegroupWidth = timegroup.offsetWidth;\n let timegroupHeight = timegroup.offsetHeight;\n\n if (!timegroupWidth || !timegroupHeight) {\n const rect = timegroup.getBoundingClientRect();\n if (rect.width > 0 && rect.height > 0) {\n timegroupWidth = rect.width;\n timegroupHeight = rect.height;\n }\n }\n\n if (!timegroupWidth || !timegroupHeight) {\n const computed = getComputedStyle(timegroup);\n const cw = parseFloat(computed.width);\n const ch = parseFloat(computed.height);\n if (cw > 0 && ch > 0) {\n timegroupWidth = cw;\n timegroupHeight = ch;\n }\n }\n\n if (!timegroupWidth || !timegroupHeight) {\n throw new Error(\n `Timegroup has no dimensions (${timegroupWidth}x${timegroupHeight}). ` +\n `Ensure the timegroup element is in the document and has explicit width/height styles ` +\n `(e.g., class=\"w-[1920px] h-[1080px]\")`,\n );\n }\n const width = Math.floor(timegroupWidth * scale);\n const height = Math.floor(timegroupHeight * scale);\n\n const videoWidth = width % 2 === 0 ? width : width - 1;\n const videoHeight = height % 2 === 0 ? height : height - 1;\n\n const frameDurationMs = 1000 / fps;\n const totalFrames = Math.ceil(renderDurationMs / frameDurationMs);\n const frameDurationS = frameDurationMs / 1000;\n\n // Determine effective canvas mode:\n // 1. If explicitly specified, use that (with fallback if native not available)\n // 2. If not specified, default to foreignObject for compatibility\n const canvasMode = (() => {\n const requested = options.canvasMode;\n if (!requested) return \"foreignObject\";\n if (requested === \"native\" && !isNativeCanvasApiAvailable()) {\n logger.debug(\n \"[renderTimegroupToVideo] Native canvas mode requested but not available, falling back to foreignObject\",\n );\n return \"foreignObject\";\n }\n return requested;\n })();\n\n return {\n fps,\n codec,\n bitrate,\n filename,\n scale,\n keyFrameInterval,\n startMs,\n endMs,\n renderDurationMs,\n width,\n height,\n videoWidth,\n videoHeight,\n totalFrames,\n frameDurationMs,\n frameDurationS,\n streaming,\n includeAudio,\n audioBitrate,\n contentReadyMode,\n blockingTimeoutMs,\n returnBuffer,\n preferredAudioCodecs,\n benchmarkMode,\n progressPreviewInterval,\n canvasMode,\n };\n}\n\n// ============================================================================\n// Helpers\n// ============================================================================\n\nfunction isFileSystemAccessSupported(): boolean {\n return typeof window !== \"undefined\" && \"showSaveFilePicker\" in window;\n}\n\nasync function getFileWritableStream(filename: string): Promise<{\n writable: WritableStream<Uint8Array>;\n close: () => Promise<void>;\n} | null> {\n if (!isFileSystemAccessSupported()) {\n return null;\n }\n\n try {\n const fileHandle = await (window as any).showSaveFilePicker({\n suggestedName: filename,\n types: [{ description: \"MP4 Video\", accept: { \"video/mp4\": [\".mp4\"] } }],\n });\n const writable = await fileHandle.createWritable();\n return {\n writable,\n close: async () => {\n await writable.close();\n },\n };\n } catch (e) {\n if ((e as Error).name !== \"AbortError\") {\n logger.warn(\"[renderToVideo] File System Access failed:\", e);\n }\n return null;\n }\n}\n\nasync function selectAudioCodec(\n preferredCodecs: AudioCodec[],\n encodingOptions: {\n numberOfChannels: number;\n sampleRate: number;\n bitrate: number;\n },\n): Promise<AudioCodec> {\n for (const codec of preferredCodecs) {\n try {\n const isSupported = await canEncodeAudio(codec, encodingOptions);\n if (isSupported) return codec;\n } catch (e) {\n logger.warn(`[selectAudioCodec] Check failed for ${codec}:`, e);\n }\n }\n const availableCodecs = await getEncodableAudioCodecs(\n undefined,\n encodingOptions,\n );\n throw new NoSupportedAudioCodecError(preferredCodecs, availableCodecs);\n}\n\nfunction downloadBlob(blob: Blob, filename: string): void {\n const url = URL.createObjectURL(blob);\n const a = document.createElement(\"a\");\n a.href = url;\n a.download = filename;\n document.body.appendChild(a);\n a.click();\n document.body.removeChild(a);\n URL.revokeObjectURL(url);\n}\n\n// ============================================================================\n// Public API\n// ============================================================================\n\nexport async function getSupportedAudioCodecs(options?: {\n numberOfChannels?: number;\n sampleRate?: number;\n bitrate?: number;\n}): Promise<AudioCodec[]> {\n const {\n numberOfChannels = 2,\n sampleRate = 48000,\n bitrate = 128000,\n } = options ?? {};\n return getEncodableAudioCodecs(undefined, {\n numberOfChannels,\n sampleRate,\n bitrate,\n });\n}\n\n/**\n * Renders a timegroup to an MP4 video file.\n *\n * Uses the EXACT same code path as thumbnail generation (captureFromClone).\n * This ensures consistency - if thumbnails work, video export works.\n */\nexport async function renderTimegroupToVideo(\n timegroup: EFTimegroup,\n options: RenderToVideoOptions = {},\n): Promise<Uint8Array | undefined> {\n const config = resolveConfig(timegroup, options);\n const { signal, onProgress } = options;\n\n const checkCancelled = () => {\n if (signal?.aborted) throw new RenderCancelledError();\n };\n\n resetRenderState();\n\n // =========================================================================\n // Create render clone - EXACT same as captureBatch in EFTimegroup\n // =========================================================================\n const {\n clone: renderClone,\n container: cloneContainer,\n cleanup: cleanupRenderClone,\n } = await timegroup.createRenderClone();\n\n // Build timestamps array for frame loop\n const timestamps: number[] = [];\n for (let i = 0; i < config.totalFrames; i++) {\n timestamps.push(config.startMs + i * config.frameDurationMs);\n }\n\n // =========================================================================\n // Set up video encoding\n // =========================================================================\n let output: Output | null = null;\n let videoSource: CanvasSource | null = null;\n let audioSource: AudioBufferSource | null = null;\n let target: BufferTarget | StreamTarget | null = null;\n let fileStream: {\n writable: WritableStream<Uint8Array>;\n close: () => Promise<void>;\n } | null = null;\n let useStreaming = false;\n let encodingCanvas: OffscreenCanvas | null = null;\n let encodingCtx: OffscreenCanvasRenderingContext2D | null = null;\n\n if (!config.benchmarkMode) {\n // Check for custom writable stream first (for programmatic streaming)\n if (options.customWritableStream) {\n target = new StreamTarget(options.customWritableStream as any);\n output = new Output({\n format: new Mp4OutputFormat({ fastStart: \"fragmented\" }),\n target,\n });\n useStreaming = true;\n } else if (config.streaming) {\n fileStream = await getFileWritableStream(config.filename);\n useStreaming = fileStream !== null;\n\n if (useStreaming && fileStream) {\n target = new StreamTarget(fileStream.writable as any);\n output = new Output({\n format: new Mp4OutputFormat({ fastStart: \"fragmented\" }),\n target,\n });\n }\n }\n\n if (!target) {\n target = new BufferTarget();\n output = new Output({ format: new Mp4OutputFormat(), target });\n }\n\n encodingCanvas = new OffscreenCanvas(config.videoWidth, config.videoHeight);\n encodingCtx = encodingCanvas.getContext(\"2d\");\n if (!encodingCtx) {\n cleanupRenderClone();\n throw new Error(\"Failed to get encoding canvas context\");\n }\n\n if (!output) {\n throw new Error(\"Output not initialized\");\n }\n\n const videoConfig: VideoEncodingConfig = {\n codec: config.codec,\n bitrate: config.bitrate,\n keyFrameInterval: config.keyFrameInterval,\n };\n videoSource = new CanvasSource(encodingCanvas, videoConfig);\n output.addVideoTrack(videoSource);\n\n if (config.includeAudio) {\n const selectedCodec = await selectAudioCodec(\n config.preferredAudioCodecs,\n {\n numberOfChannels: 2,\n sampleRate: 48000,\n bitrate: config.audioBitrate,\n },\n );\n const audioConfig: AudioEncodingConfig = {\n codec: selectedCodec,\n bitrate: config.audioBitrate,\n };\n audioSource = new AudioBufferSource(audioConfig);\n output.addAudioTrack(audioSource);\n }\n\n await output.start();\n }\n\n // =========================================================================\n // Setup for per-frame passive structure rebuilding (like live preview)\n // =========================================================================\n // Create RenderContext for caching across all frames\n const renderContext = new RenderContext();\n\n // Create preview container with proper styling (reusable, content rebuilt each frame)\n // Use unscaled dimensions for the preview container (which holds the full-size clone)\n const containerWidth = timegroup.offsetWidth || 1920;\n const containerHeight = timegroup.offsetHeight || 1080;\n const previewContainer = createPreviewContainer({\n width: containerWidth,\n height: containerHeight,\n background: getComputedStyle(timegroup).background || \"#000\",\n });\n\n // Setup for direct serialization\n logger.debug(`[renderTimegroupToVideo] Using direct timeline serialization`);\n\n // Attach clone container (keeps renderClone in its React-managed DOM position)\n previewContainer.appendChild(cloneContainer);\n\n // Add ef-render-clone-container class for CSS selectors and debugging\n previewContainer.classList.add(\"ef-render-clone-container\");\n\n // CRITICAL: Attach container to document so getComputedStyle returns actual values\n // Without this, all computed styles are empty strings!\n // Hide the container OFF-SCREEN but do NOT use visibility:hidden because:\n // 1. visibility:hidden is inherited by all children\n // 2. seekForRender checks getComputedStyle().visibility and skips \"hidden\" subtrees\n // 3. This would cause FrameController to skip rendering all nested content\n previewContainer.style.cssText +=\n \";position:fixed;left:-99999px;top:-99999px;pointer-events:none;\";\n document.body.appendChild(previewContainer);\n\n // Force layout/reflow so getComputedStyle returns correct values\n void renderClone.offsetHeight;\n logger.debug(\n `[renderTimegroupToVideo] Attached previewContainer to document.body (off-screen) for style computation`,\n );\n\n // =========================================================================\n // Frame loop - DEEP PIPELINE: overlap encode + render + prepare\n // =========================================================================\n const renderStartTime = performance.now();\n let lastRenderedAudioEndMs = config.startMs;\n const audioChunkDurationMs = 2000;\n\n // Reusable thumbnail canvas for preview (no encoding, just draw to canvas)\n let thumbCanvas: HTMLCanvasElement | null = null;\n let thumbCtx: CanvasRenderingContext2D | null = null;\n if (onProgress && config.progressPreviewInterval > 0) {\n const previewWidth = 160;\n const previewHeight = Math.round(\n previewWidth * (config.videoHeight / config.videoWidth),\n );\n thumbCanvas = document.createElement(\"canvas\");\n thumbCanvas.width = previewWidth;\n thumbCanvas.height = previewHeight;\n thumbCtx = thumbCanvas.getContext(\"2d\");\n }\n\n try {\n // ========================================================================\n // OVERLAPPED PIPELINE: image loading runs parallel with seek+serialize\n // ========================================================================\n // The clone can only seek one frame at a time, and serialization must\n // capture the DOM before the next seek. But image loading (data URI →\n // Image) is independent of the clone and runs in the background.\n //\n // Per-frame timeline:\n // [seek(N)] → [serialize(N)] → [image.load(N) in background...]\n // └─ [seek(N+1)] → [serialize(N+1)] → ...\n // └─ encode(N) when image resolves\n\n type PendingFrame = {\n frameIndex: number;\n timeMs: number;\n timestampS: number;\n resolved: HTMLImageElement | null;\n promise: Promise<HTMLImageElement>;\n };\n\n const MAX_AHEAD = 2;\n const pendingFrames: PendingFrame[] = [];\n let nextSeekFrame = 0;\n let encodedFrames = 0;\n\n while (encodedFrames < config.totalFrames) {\n checkCancelled();\n\n // ==================================================================\n // PHASE 1: Fill pipeline — seek+serialize ahead while images load\n // ==================================================================\n while (\n nextSeekFrame < config.totalFrames &&\n pendingFrames.length < MAX_AHEAD\n ) {\n const fi = nextSeekFrame;\n const timeMs = timestamps[fi]!;\n const timestampS = (fi * config.frameDurationMs) / 1000;\n\n await renderClone.seekForRender(timeMs);\n\n const entry: PendingFrame = {\n frameIndex: fi,\n timeMs,\n timestampS,\n resolved: null,\n promise: null!,\n };\n\n // Wait for video content if using blocking mode\n if (config.contentReadyMode === \"blocking\") {\n await waitForVideoContent(\n renderClone,\n timeMs,\n config.blockingTimeoutMs,\n );\n }\n\n if (config.canvasMode === \"native\") {\n const canvas = await renderToImageNative(\n renderClone,\n config.width,\n config.height,\n {\n skipDprScaling: true,\n },\n );\n entry.resolved = canvas as any as HTMLImageElement;\n entry.promise = Promise.resolve(entry.resolved);\n } else {\n // Synchronous capture: walks DOM + snapshots canvas pixels.\n // Returns immediately — clone is free for next seek.\n // Encoding (canvas→base64, SVG assembly) and image loading\n // all resolve in the background.\n const dataUriPromise = captureTimelineToDataUri(\n renderClone,\n config.width,\n config.height,\n {\n renderContext,\n canvasScale: config.scale,\n timeMs,\n },\n );\n\n entry.promise = dataUriPromise.then((dataUri) => {\n return new Promise<HTMLImageElement>((resolve, reject) => {\n const image = new Image();\n image.onload = () => {\n entry.resolved = image;\n resolve(image);\n };\n image.onerror = (e) => {\n console.error(`[Render] frame ${fi} image load error:`, e);\n reject(new Error(`Failed to load image from data URI`));\n };\n image.src = dataUri;\n });\n });\n }\n\n pendingFrames.push(entry);\n nextSeekFrame++;\n }\n\n // ==================================================================\n // PHASE 2: Encode next frame in order (await if not yet loaded)\n // ==================================================================\n const head = pendingFrames.shift()!;\n const preloaded = head.resolved !== null;\n let image: HTMLImageElement;\n if (preloaded) {\n image = head.resolved!;\n } else {\n image = await head.promise;\n }\n\n if (\n audioSource &&\n head.timeMs >= lastRenderedAudioEndMs + audioChunkDurationMs\n ) {\n const chunkEndMs = Math.min(\n head.timeMs + audioChunkDurationMs,\n config.endMs,\n );\n try {\n const audioBuffer = await timegroup.renderAudio(\n lastRenderedAudioEndMs,\n chunkEndMs,\n signal,\n );\n if (audioBuffer && audioBuffer.length > 0) {\n await audioSource.add(audioBuffer);\n }\n } catch (e) {\n /* Audio render failures are non-fatal */\n }\n lastRenderedAudioEndMs = chunkEndMs;\n }\n\n if (videoSource && output && encodingCtx) {\n encodingCtx.drawImage(\n image,\n 0,\n 0,\n image.width,\n image.height,\n 0,\n 0,\n config.videoWidth,\n config.videoHeight,\n );\n await videoSource.add(head.timestampS, config.frameDurationS);\n }\n\n // ==================================================================\n // Progress reporting\n // ==================================================================\n encodedFrames++;\n const currentFrame = encodedFrames;\n const progress = currentFrame / config.totalFrames;\n const renderedMs = currentFrame * config.frameDurationMs;\n const elapsedMs = performance.now() - renderStartTime;\n const msPerFrame = elapsedMs / currentFrame;\n const remainingFrames = config.totalFrames - currentFrame;\n const estimatedRemainingMs = remainingFrames * msPerFrame;\n const speedMultiplier = renderedMs / elapsedMs;\n\n if (\n thumbCanvas &&\n thumbCtx &&\n head.frameIndex % config.progressPreviewInterval === 0\n ) {\n thumbCtx.drawImage(image, 0, 0, thumbCanvas.width, thumbCanvas.height);\n }\n\n onProgress?.({\n progress,\n currentFrame,\n totalFrames: config.totalFrames,\n renderedMs,\n totalDurationMs: config.renderDurationMs,\n elapsedMs,\n estimatedRemainingMs,\n speedMultiplier,\n framePreviewCanvas: thumbCanvas || undefined,\n });\n }\n\n // Render remaining audio\n if (audioSource && lastRenderedAudioEndMs < config.endMs) {\n try {\n const audioBuffer = await timegroup.renderAudio(\n lastRenderedAudioEndMs,\n config.endMs,\n signal,\n );\n if (audioBuffer && audioBuffer.length > 0) {\n await audioSource.add(audioBuffer);\n }\n } catch (e) {\n /* Audio render failures are non-fatal */\n }\n }\n\n if (config.benchmarkMode) {\n return undefined;\n }\n\n await output!.finalize();\n\n if (\n typeof process !== \"undefined\" &&\n process.env.EF_TELEMETRY_ENABLED === \"true\"\n ) {\n const elapsedMs = Math.round(performance.now() - renderStartTime);\n const endpoint = options.telemetryEndpoint ?? \"https://editframe.com\";\n const efMediaCount =\n timegroup.querySelectorAll(\"ef-video,ef-audio\").length;\n const efImageCount = timegroup.querySelectorAll(\"ef-image\").length;\n const efCaptionsCount = timegroup.querySelectorAll(\"ef-captions\").length;\n const efTextCount = timegroup.querySelectorAll(\"ef-text\").length;\n const headers: Record<string, string> = {\n \"Content-Type\": \"application/json\",\n };\n if (options.telemetryToken) {\n headers[\"Authorization\"] = `Bearer ${options.telemetryToken}`;\n }\n fetch(`${endpoint}/api/v1/telemetry`, {\n method: \"POST\",\n headers,\n body: JSON.stringify({\n event_type: \"render\",\n render_path: \"client\",\n duration_ms: elapsedMs,\n width: config.videoWidth,\n height: config.videoHeight,\n fps: config.fps,\n feature_usage: {\n efMediaCount,\n efImageCount,\n efCaptionsCount,\n efTextCount,\n },\n }),\n keepalive: true,\n }).catch(() => {\n // Telemetry errors must never surface to users.\n });\n }\n\n if (useStreaming) {\n // Streaming mode: chunks already sent via customWritableStream or file stream\n return undefined;\n } else {\n const bufferTarget = target as BufferTarget;\n const videoBuffer = bufferTarget.buffer;\n if (!videoBuffer) {\n throw new Error(\"Video encoding failed: no buffer produced\");\n }\n\n if (config.returnBuffer) {\n return new Uint8Array(videoBuffer);\n }\n\n const videoBlob = new Blob([videoBuffer], { type: \"video/mp4\" });\n downloadBlob(videoBlob, config.filename);\n return undefined;\n }\n } finally {\n renderContext.dispose();\n // Remove previewContainer first — renderClone was moved into it, so it must be\n // detached before cleanupRenderClone() unmounts the React root that owns renderClone.\n if (previewContainer.parentNode) {\n previewContainer.parentNode.removeChild(previewContainer);\n }\n cleanupRenderClone();\n }\n}\n\nexport { QUALITY_HIGH };\nexport type { AudioCodec };\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@editframe/elements",
3
- "version": "0.42.0",
3
+ "version": "0.42.2",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -18,7 +18,7 @@
18
18
  "license": "UNLICENSED",
19
19
  "dependencies": {
20
20
  "@bramus/style-observer": "^1.3.0",
21
- "@editframe/assets": "0.42.0",
21
+ "@editframe/assets": "0.42.2",
22
22
  "@lit/context": "^1.1.6",
23
23
  "@opentelemetry/api": "^1.9.0",
24
24
  "@opentelemetry/context-zone": "^1.26.0",