@editframe/elements 0.38.1 → 0.40.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/EF_FRAMEGEN.js +1 -0
- package/dist/EF_FRAMEGEN.js.map +1 -1
- package/dist/elements/EFCaptions.d.ts +2 -2
- package/dist/elements/EFCaptions.js +1 -1
- package/dist/elements/EFCaptions.js.map +1 -1
- package/dist/elements/EFImage.js +3 -4
- package/dist/elements/EFImage.js.map +1 -1
- package/dist/elements/EFMedia/BufferedSeekingInput.js +1 -1
- package/dist/elements/EFMedia/CachedFetcher.js +99 -0
- package/dist/elements/EFMedia/CachedFetcher.js.map +1 -0
- package/dist/elements/EFMedia/MediaEngine.d.ts +19 -0
- package/dist/elements/EFMedia/MediaEngine.js +129 -0
- package/dist/elements/EFMedia/MediaEngine.js.map +1 -0
- package/dist/elements/EFMedia/SegmentIndex.d.ts +32 -0
- package/dist/elements/EFMedia/SegmentIndex.js +185 -0
- package/dist/elements/EFMedia/SegmentIndex.js.map +1 -0
- package/dist/elements/EFMedia/SegmentTransport.d.ts +12 -0
- package/dist/elements/EFMedia/SegmentTransport.js +69 -0
- package/dist/elements/EFMedia/SegmentTransport.js.map +1 -0
- package/dist/elements/EFMedia/TimingModel.d.ts +10 -0
- package/dist/elements/EFMedia/TimingModel.js +28 -0
- package/dist/elements/EFMedia/TimingModel.js.map +1 -0
- package/dist/elements/EFMedia/shared/AudioSpanUtils.js +7 -6
- package/dist/elements/EFMedia/shared/AudioSpanUtils.js.map +1 -1
- package/dist/elements/EFMedia/shared/ThumbnailExtractor.js +13 -34
- package/dist/elements/EFMedia/shared/ThumbnailExtractor.js.map +1 -1
- package/dist/elements/EFMedia.d.ts +4 -3
- package/dist/elements/EFMedia.js +14 -31
- package/dist/elements/EFMedia.js.map +1 -1
- package/dist/elements/EFSourceMixin.js +1 -1
- package/dist/elements/EFSourceMixin.js.map +1 -1
- package/dist/elements/EFTemporal.js +2 -1
- package/dist/elements/EFTemporal.js.map +1 -1
- package/dist/elements/EFTimegroup.js +2 -1
- package/dist/elements/EFTimegroup.js.map +1 -1
- package/dist/elements/EFVideo.js +204 -187
- package/dist/elements/EFVideo.js.map +1 -1
- package/dist/gui/EFConfiguration.d.ts +0 -7
- package/dist/gui/EFConfiguration.js +0 -5
- package/dist/gui/EFConfiguration.js.map +1 -1
- package/dist/gui/EFWorkbench.d.ts +2 -0
- package/dist/gui/EFWorkbench.js +68 -1
- package/dist/gui/EFWorkbench.js.map +1 -1
- package/dist/gui/PlaybackController.d.ts +2 -0
- package/dist/gui/PlaybackController.js +11 -1
- package/dist/gui/PlaybackController.js.map +1 -1
- package/dist/gui/ef-theme.css +11 -0
- package/dist/gui/timeline/tracks/AudioTrack.js +28 -30
- package/dist/gui/timeline/tracks/AudioTrack.js.map +1 -1
- package/dist/gui/timeline/tracks/EFThumbnailStrip.d.ts +1 -0
- package/dist/gui/timeline/tracks/EFThumbnailStrip.js +41 -8
- package/dist/gui/timeline/tracks/EFThumbnailStrip.js.map +1 -1
- package/dist/gui/timeline/tracks/VideoTrack.js +2 -2
- package/dist/gui/timeline/tracks/VideoTrack.js.map +1 -1
- package/dist/gui/timeline/tracks/waveformUtils.js +19 -19
- package/dist/gui/timeline/tracks/waveformUtils.js.map +1 -1
- package/dist/preview/QualityUpgradeScheduler.d.ts +8 -0
- package/dist/preview/QualityUpgradeScheduler.js +13 -1
- package/dist/preview/QualityUpgradeScheduler.js.map +1 -1
- package/dist/preview/renderTimegroupToVideo.js +3 -3
- package/dist/preview/renderTimegroupToVideo.js.map +1 -1
- package/dist/preview/renderVideoToVideo.js +5 -6
- package/dist/preview/renderVideoToVideo.js.map +1 -1
- package/dist/transcoding/types/index.d.ts +6 -94
- package/dist/transcoding/utils/UrlGenerator.d.ts +3 -12
- package/dist/transcoding/utils/UrlGenerator.js +3 -29
- package/dist/transcoding/utils/UrlGenerator.js.map +1 -1
- package/package.json +2 -2
- package/test/setup.ts +1 -1
- package/test/useAssetMSW.ts +0 -100
- package/dist/elements/EFMedia/AssetMediaEngine.js +0 -284
- package/dist/elements/EFMedia/AssetMediaEngine.js.map +0 -1
- package/dist/elements/EFMedia/BaseMediaEngine.js +0 -200
- package/dist/elements/EFMedia/BaseMediaEngine.js.map +0 -1
- package/dist/elements/EFMedia/FileMediaEngine.js +0 -122
- package/dist/elements/EFMedia/FileMediaEngine.js.map +0 -1
- package/dist/elements/EFMedia/JitMediaEngine.js +0 -157
- package/dist/elements/EFMedia/JitMediaEngine.js.map +0 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"EFThumbnailStrip.js","names":["#timestamps","EFThumbnailStrip","#timelineState","#thumbnailDimensions","#effectiveDurationMs","#effectivePixelsPerMs","#hostWidth","#targetController","#resizeObserver","#scheduleRender","#abortController","#cleanupTimegroupGenerator","#detachTargetListeners","#calculateThumbnailDimensions","#attachTargetListeners","#targetReadyStateHandler","#targetContentChangeHandler","thumbnails: ThumbnailDescriptor[]","#previousPixelsPerMs","#thumbnailPhase","#renderRequested","#renderThumbnails","#calculateVisibleThumbnails","#clearCanvas","#lastRequiredTimestamps","#updateVideoCapture","#updateTimegroupCapture","results: ThumbnailResult[]","#thumbnailCache","nearestTimeMs: number | null","#drawThumbnails","#getSourceTimeMs","#updateInProgress","#pendingTimestamps","#retryScheduled","#timegroupGenerator","#timegroupGeneratorAbort","#timegroupQueue","#timegroupClone","#previewContainer","#consumeTimegroupGenerator","#startTimegroupGenerator","updatePromises: Promise<any>[]","textUpdatePromises: Promise<any>[]","imagePromises: Promise<void>[]","#consumerRunning","#canvasContainer"],"sources":["../../../../src/gui/timeline/tracks/EFThumbnailStrip.ts"],"sourcesContent":["import { consume } from \"@lit/context\";\nimport { css, html, LitElement } from \"lit\";\nimport { customElement, property, state } from \"lit/decorators.js\";\nimport { createRef, ref, type Ref } from \"lit/directives/ref.js\";\n\nimport { EFTimegroup } from \"../../../elements/EFTimegroup.js\";\nimport { EFVideo } from \"../../../elements/EFVideo.js\";\nimport { TargetController } from \"../../../elements/TargetController.js\";\nimport { ThumbnailExtractor } from \"../../../elements/EFMedia/shared/ThumbnailExtractor.js\";\nimport type { BaseMediaEngine } from \"../../../elements/EFMedia/BaseMediaEngine.js\";\nimport {\n generateThumbnailsFromClone,\n type GeneratedThumbnail,\n type ThumbnailQueue,\n} from \"../../../preview/renderTimegroupToCanvas.js\";\n\nimport { quantizeToFrameTimeMs } from \"../../../utils/frameTime.js\";\nimport { TWMixin } from \"../../TWMixin.js\";\nimport {\n timelineStateContext,\n type TimelineState,\n} from \"../timelineStateContext.js\";\nimport {\n previewSettingsContext,\n type PreviewSettings,\n} from \"../../previewSettingsContext.js\";\n\n/** Padding for virtual rendering */\nconst VIRTUAL_RENDER_PADDING_PX = 100;\n\n/**\n * Mutable queue for timestamp generation.\n * Allows updating timestamps while generator is consuming them.\n */\nclass MutableTimestampQueue implements ThumbnailQueue {\n #timestamps: number[] = [];\n\n /** Replace entire queue with new timestamps (sorted) */\n reset(timestamps: number[]): void {\n this.#timestamps = [...timestamps].sort((a, b) => a - b);\n }\n\n /** Keep only these specific timestamps (maintains order) */\n retainOnly(timestamps: number[]): void {\n const keep = new Set(timestamps);\n this.#timestamps = this.#timestamps.filter((t) => keep.has(t));\n }\n\n /** Append timestamps to end (sorted) */\n append(timestamps: number[]): void {\n this.#timestamps.push(...[...timestamps].sort((a, b) => a - b));\n }\n\n /** Get next timestamp (removes from front) */\n shift(): number | undefined {\n return this.#timestamps.shift();\n }\n\n /** Get remaining timestamps without modifying queue */\n remaining(): number[] {\n return [...this.#timestamps];\n }\n\n /** Check if queue is empty */\n isEmpty(): boolean {\n return this.#timestamps.length === 0;\n }\n}\n\n/**\n * Descriptor for a thumbnail to render\n */\ninterface ThumbnailDescriptor {\n timeMs: number;\n x: number;\n width: number;\n height: number;\n}\n\n/**\n * Result of thumbnail rendering (canvas or error)\n */\ninterface ThumbnailResult {\n canvas: CanvasImageSource | null;\n error?: Error;\n}\n\n/**\n * Thumbnail strip component that renders thumbnails for video or timegroup elements.\n *\n * Features:\n * - Targets ef-video or root ef-timegroup via target attribute\n * - Batch video thumbnail extraction via ThumbnailExtractor\n * - Canvas rendering for timegroups at low resolution\n * - Viewport-based lazy loading with scroll calculation\n * - Fixed visual spacing (consistent at all zoom levels)\n * - Error indicators for failed thumbnails\n */\n@customElement(\"ef-thumbnail-strip\")\nexport class EFThumbnailStrip extends TWMixin(LitElement) {\n static styles = [\n css`\n :host {\n display: block;\n position: relative;\n width: 100%;\n height: 100%;\n overflow: hidden;\n }\n\n .thumbnail-container {\n position: relative;\n width: 100%;\n height: 100%;\n overflow: hidden;\n }\n\n .error-message {\n display: flex;\n align-items: center;\n justify-content: center;\n width: 100%;\n height: 100%;\n padding: 8px;\n color: var(--ef-color-error);\n font-size: 12px;\n background: color-mix(in srgb, var(--ef-color-error) 10%, transparent);\n }\n\n canvas {\n position: absolute;\n image-rendering: pixelated;\n image-rendering: crisp-edges;\n }\n `,\n ];\n\n @property({ type: String })\n target = \"\";\n\n @property({ attribute: false })\n targetElement: Element | null = null;\n\n @property({ type: Number, attribute: \"thumbnail-height\" })\n thumbnailHeight = 24;\n\n @property({ type: Number, attribute: \"thumbnail-spacing-px\" })\n thumbnailSpacingPx = 48;\n\n @property({ type: Number, attribute: \"pixels-per-ms\" })\n pixelsPerMs: number | null = null;\n\n @property({ type: Boolean, attribute: \"use-intrinsic-duration\" })\n useIntrinsicDuration = false;\n\n @consume({ context: timelineStateContext, subscribe: true })\n @state()\n timelineState?: TimelineState;\n\n @consume({ context: previewSettingsContext, subscribe: true })\n @state()\n previewSettings?: PreviewSettings;\n\n @state()\n thumbnailDimensions = { width: 0, height: 0 };\n\n #targetController?: TargetController;\n #abortController: AbortController | null = null;\n #renderRequested = false;\n #canvasContainer: Ref<HTMLDivElement> = createRef();\n #lastRequiredTimestamps = \"\";\n #thumbnailCache = new Map<number, CanvasImageSource>();\n\n // Timegroup thumbnail generation state\n #timegroupQueue = new MutableTimestampQueue();\n #timegroupClone: {\n clone: EFTimegroup;\n container: HTMLElement;\n cleanup: () => void;\n } | null = null;\n #timegroupGenerator: AsyncGenerator<GeneratedThumbnail> | null = null;\n #timegroupGeneratorAbort: AbortController | null = null;\n #previewContainer: HTMLDivElement | null = null;\n #updateInProgress = false; // Lock to prevent concurrent updates\n #consumerRunning = false; // Lock to prevent concurrent consumers\n #pendingTimestamps = new Set<number>(); // Timestamps requested while update in progress\n #retryScheduled = false; // Flag to prevent duplicate retry schedules\n #thumbnailPhase: number = 0; // Phase offset for thumbnail grid\n #previousPixelsPerMs: number | null = null; // Track zoom changes\n #targetReadyStateHandler: (() => void) | null = null;\n #targetContentChangeHandler: (() => void) | null = null;\n #resizeObserver: ResizeObserver | null = null;\n #hostWidth = 0;\n\n /**\n * Check if target is valid (EFVideo or root EFTimegroup)\n */\n get isValidTarget(): boolean {\n const el = this.targetElement;\n if (!el) return false;\n\n if (el instanceof EFVideo) return true;\n\n if (el instanceof EFTimegroup) {\n // Only root timegroups\n return (el as any).isRootTimegroup === true;\n }\n\n return false;\n }\n\n get #timelineState(): TimelineState | undefined {\n return this.timelineState;\n }\n\n get #thumbnailDimensions() {\n return this.thumbnailDimensions;\n }\n\n get #effectiveDurationMs(): number {\n const element = this.targetElement;\n if (!element) return 0;\n if (this.useIntrinsicDuration) {\n return (\n (element as any).intrinsicDurationMs ?? (element as any).durationMs ?? 0\n );\n }\n return (element as any).durationMs ?? 0;\n }\n\n get #effectivePixelsPerMs(): number {\n if (this.#timelineState?.pixelsPerMs != null) {\n return this.#timelineState.pixelsPerMs;\n }\n if (this.pixelsPerMs != null) {\n return this.pixelsPerMs;\n }\n const durationMs = this.#effectiveDurationMs;\n if (this.#hostWidth > 0 && durationMs > 0) {\n return this.#hostWidth / durationMs;\n }\n return 0.04;\n }\n\n connectedCallback(): void {\n super.connectedCallback();\n // Only use TargetController if target is set and targetElement is not directly set\n if (this.target && !this.targetElement) {\n this.#targetController = new TargetController(this);\n }\n this.#resizeObserver = new ResizeObserver((entries) => {\n const entry = entries[0];\n if (!entry) return;\n const width = entry.contentRect.width;\n if (width !== this.#hostWidth) {\n this.#hostWidth = width;\n this.requestUpdate();\n this.#scheduleRender();\n }\n });\n this.#resizeObserver.observe(this);\n }\n\n disconnectedCallback(): void {\n super.disconnectedCallback();\n this.#abortController?.abort();\n this.#cleanupTimegroupGenerator();\n this.#detachTargetListeners(this.targetElement);\n this.#resizeObserver?.disconnect();\n this.#resizeObserver = null;\n }\n\n protected willUpdate(\n changedProperties: Map<string | number | symbol, unknown>,\n ): void {\n super.willUpdate(changedProperties);\n\n // Create TargetController if target is set and targetElement is not directly set\n if (changedProperties.has(\"target\")) {\n if (this.target && !this.targetElement && !this.#targetController) {\n this.#targetController = new TargetController(this);\n }\n }\n\n // Recalculate thumbnail dimensions if target changed\n if (\n changedProperties.has(\"targetElement\") ||\n changedProperties.has(\"thumbnailHeight\")\n ) {\n this.thumbnailDimensions = this.#calculateThumbnailDimensions();\n }\n\n // Manage event listeners when target changes\n if (changedProperties.has(\"targetElement\")) {\n const oldTarget = changedProperties.get(\n \"targetElement\",\n ) as Element | null;\n this.#detachTargetListeners(oldTarget);\n this.#attachTargetListeners(this.targetElement);\n }\n }\n\n #attachTargetListeners(target: Element | null): void {\n if (!target) return;\n\n this.#targetReadyStateHandler = () => {\n this.requestUpdate();\n this.#scheduleRender();\n };\n this.#targetContentChangeHandler = () => {\n this.requestUpdate();\n this.#scheduleRender();\n };\n target.addEventListener(\"readystatechange\", this.#targetReadyStateHandler);\n target.addEventListener(\"contentchange\", this.#targetContentChangeHandler);\n\n // Late-subscriber: if the target already transitioned to \"ready\" before\n // we attached, the event was missed. The contentReadyState property\n // serves exactly this purpose — check it and render if needed.\n if ((target as any).contentReadyState === \"ready\") {\n this.requestUpdate();\n this.#scheduleRender();\n }\n }\n\n #detachTargetListeners(target: Element | null): void {\n if (!target) return;\n if (this.#targetReadyStateHandler) {\n target.removeEventListener(\n \"readystatechange\",\n this.#targetReadyStateHandler,\n );\n this.#targetReadyStateHandler = null;\n }\n if (this.#targetContentChangeHandler) {\n target.removeEventListener(\n \"contentchange\",\n this.#targetContentChangeHandler,\n );\n this.#targetContentChangeHandler = null;\n }\n }\n\n updated(changedProperties: Map<string | number | symbol, unknown>): void {\n super.updated(changedProperties);\n\n if (\n changedProperties.has(\"targetElement\") ||\n changedProperties.has(\"thumbnailSpacingPx\") ||\n changedProperties.has(\"pixelsPerMs\") ||\n changedProperties.has(\"thumbnailHeight\") ||\n changedProperties.has(\"timelineState\")\n ) {\n this.#scheduleRender();\n }\n }\n\n /**\n * Calculate thumbnail dimensions from target element's actual bounds\n */\n #calculateThumbnailDimensions(): { width: number; height: number } {\n const el = this.targetElement;\n if (!el) return { width: 0, height: 0 };\n\n // Get actual visible bounds from DOM\n const bounds = el.getBoundingClientRect();\n if (bounds.width === 0 || bounds.height === 0) {\n // Element not yet rendered or no size, use default aspect ratio\n return {\n width: this.thumbnailHeight * (16 / 9),\n height: this.thumbnailHeight,\n };\n }\n\n const aspectRatio = bounds.width / bounds.height;\n const width = Math.round(this.thumbnailHeight * aspectRatio);\n\n return { width, height: this.thumbnailHeight };\n }\n\n /**\n * Calculate visible thumbnails based on viewport\n */\n #calculateVisibleThumbnails(): ThumbnailDescriptor[] {\n if (!this.isValidTarget) return [];\n\n const element = this.targetElement;\n if (!element) return [];\n\n const scrollLeft = this.#timelineState?.viewportScrollLeft ?? 0;\n const viewportWidth =\n this.#timelineState?.viewportWidth ?? (this.#hostWidth || 800);\n const pixelsPerMs = this.#effectivePixelsPerMs;\n\n const durationMs = this.#effectiveDurationMs;\n if (durationMs === 0) return [];\n\n const trackWidthPx = durationMs * pixelsPerMs;\n\n // Get FPS for quantization\n const fps = (element as any).fps ?? 30;\n\n const visibleStartPx = scrollLeft - VIRTUAL_RENDER_PADDING_PX;\n const visibleEndPx = scrollLeft + viewportWidth + VIRTUAL_RENDER_PADDING_PX;\n\n const thumbnails: ThumbnailDescriptor[] = [];\n const { width, height } = this.#thumbnailDimensions;\n\n // Read minimum gap from CSS variable (--ef-thumbnail-gap, default 2px)\n const gapPx =\n parseFloat(\n getComputedStyle(this).getPropertyValue(\"--ef-thumbnail-gap\"),\n ) || 2;\n // Stride must be at least thumbnail width + gap to prevent overlap\n const thumbnailStride = Math.max(this.thumbnailSpacingPx, width + gapPx);\n\n // Detect zoom by checking if pixelsPerMs changed\n const isZoom =\n this.#previousPixelsPerMs !== null &&\n this.#previousPixelsPerMs !== pixelsPerMs;\n\n if (this.#previousPixelsPerMs === null) {\n // First render: align grid to track start (t=0)\n this.#thumbnailPhase = 0;\n } else if (isZoom) {\n // On zoom: snap a thumbnail to near the left edge of viewport\n // This prevents visual slip during zoom operations\n this.#thumbnailPhase = scrollLeft % thumbnailStride;\n } else if (scrollLeft < thumbnailStride) {\n // When scrolled near the start, realign to t=0 to avoid left gap\n this.#thumbnailPhase = 0;\n }\n // During normal scroll: phase unchanged, grid scrolls naturally with track\n\n this.#previousPixelsPerMs = pixelsPerMs;\n\n // Generate thumbnail grid anchored at phase offset\n // Each thumbnail is at absolute track position: phase + (i * stride)\n // This means grid is stable in track space (scrolls naturally)\n const startIndex = Math.max(\n 0,\n Math.floor((visibleStartPx - this.#thumbnailPhase) / thumbnailStride),\n );\n const endIndex = Math.ceil(\n (visibleEndPx - this.#thumbnailPhase) / thumbnailStride,\n );\n\n for (let i = startIndex; i <= endIndex; i++) {\n const thumbX = this.#thumbnailPhase + i * thumbnailStride;\n\n // Only include if within track bounds\n if (thumbX >= 0 && thumbX < trackWidthPx) {\n // Convert position to time (leading edge)\n const rawTimeMs = thumbX / pixelsPerMs;\n const timeMs = quantizeToFrameTimeMs(rawTimeMs, fps);\n\n if (timeMs >= 0 && timeMs < durationMs) {\n thumbnails.push({ timeMs, x: thumbX, width, height });\n }\n }\n }\n\n return thumbnails;\n }\n\n /**\n * Schedule thumbnail render on next frame\n */\n #scheduleRender(): void {\n if (this.#renderRequested) return;\n this.#renderRequested = true;\n\n requestAnimationFrame(() => {\n this.#renderRequested = false;\n this.#renderThumbnails();\n });\n }\n\n /**\n * Render thumbnails with cancellation support\n */\n async #renderThumbnails(): Promise<void> {\n // Cancel previous render\n this.#abortController?.abort();\n this.#abortController = new AbortController();\n const signal = this.#abortController.signal;\n\n const visibleThumbnails = this.#calculateVisibleThumbnails();\n if (visibleThumbnails.length === 0) {\n this.#clearCanvas();\n return;\n }\n\n // Check if required timestamps changed\n const requiredTimestamps = visibleThumbnails.map((t) => t.timeMs);\n const timestampsString = requiredTimestamps.join(\", \");\n if (timestampsString !== this.#lastRequiredTimestamps) {\n this.#lastRequiredTimestamps = timestampsString;\n\n // Update capture queue\n if (this.targetElement instanceof EFVideo) {\n this.#updateVideoCapture(requiredTimestamps, signal).catch((error) => {\n // Ignore AbortErrors - these are expected when renders are cancelled\n if (error instanceof DOMException && error.name === \"AbortError\") {\n return;\n }\n console.error(\"Thumbnail capture error:\", error);\n });\n } else if (this.targetElement instanceof EFTimegroup) {\n this.#updateTimegroupCapture(requiredTimestamps);\n }\n }\n\n if (signal.aborted) return;\n\n // Draw thumbnails - use nearest neighbor if exact timestamp not cached\n const maxNeighborDistanceMs = 3000; // Don't use thumbnails more than 3s away\n const results: ThumbnailResult[] = visibleThumbnails.map((t) => {\n let canvas = this.#thumbnailCache.get(t.timeMs);\n\n // If exact match not found, find nearest cached thumbnail\n if (!canvas) {\n let nearestTimeMs: number | null = null;\n let minDistance = Infinity;\n\n for (const cachedTimeMs of this.#thumbnailCache.keys()) {\n const distance = Math.abs(cachedTimeMs - t.timeMs);\n if (distance < minDistance && distance <= maxNeighborDistanceMs) {\n minDistance = distance;\n nearestTimeMs = cachedTimeMs;\n }\n }\n\n if (nearestTimeMs !== null) {\n canvas = this.#thumbnailCache.get(nearestTimeMs);\n }\n }\n\n return { canvas: canvas ?? null };\n });\n this.#drawThumbnails(visibleThumbnails, results);\n }\n\n /**\n * Update video thumbnail capture\n */\n async #updateVideoCapture(\n timestamps: number[],\n signal: AbortSignal,\n ): Promise<void> {\n const video = this.targetElement as EFVideo;\n if (!video) return;\n\n // Filter out cached timestamps\n const uncached = timestamps.filter((t) => !this.#thumbnailCache.has(t));\n if (uncached.length === 0) return;\n\n const mediaEngineTask = video.mediaEngineTask;\n if (!mediaEngineTask) return;\n\n const mediaEngine = await mediaEngineTask.taskComplete;\n if (!mediaEngine) return;\n\n const sourceTimestamps = uncached.map((t) => this.#getSourceTimeMs(t));\n\n const extractor = new ThumbnailExtractor(\n mediaEngine as unknown as BaseMediaEngine,\n );\n const scrubRendition = mediaEngine.videoRendition;\n if (!scrubRendition) return;\n\n const results = await extractor.extractThumbnails(\n sourceTimestamps,\n scrubRendition,\n video.durationMs ?? 0,\n signal,\n );\n\n // Store in cache and trigger redraw\n for (let i = 0; i < uncached.length; i++) {\n const thumbnail = results[i]?.thumbnail;\n const timestamp = uncached[i];\n if (thumbnail && timestamp !== undefined) {\n this.#thumbnailCache.set(timestamp, thumbnail);\n }\n }\n\n this.#scheduleRender();\n }\n\n /**\n * Update timegroup thumbnail capture using mutable queue\n */\n async #updateTimegroupCapture(timestamps: number[]): Promise<void> {\n const timegroup = this.targetElement as EFTimegroup;\n if (!timegroup) return;\n\n // Filter out cached timestamps\n const uncached = timestamps\n .filter((t) => !this.#thumbnailCache.has(t))\n .sort((a, b) => a - b);\n if (uncached.length === 0) {\n return;\n }\n\n // CRITICAL: If update already in progress, REPLACE pending (not add)\n // We only want the LATEST required timestamps, not a union of all previous ones\n if (this.#updateInProgress) {\n // Clear old pending and replace with latest\n this.#pendingTimestamps.clear();\n uncached.forEach((t) => this.#pendingTimestamps.add(t));\n\n // Schedule a retry (debounced via RAF)\n if (!this.#retryScheduled) {\n this.#retryScheduled = true;\n requestAnimationFrame(() => {\n this.#retryScheduled = false;\n if (this.#pendingTimestamps.size > 0) {\n const pending = Array.from(this.#pendingTimestamps);\n this.#pendingTimestamps.clear();\n this.#updateTimegroupCapture(pending);\n }\n });\n }\n return;\n }\n this.#updateInProgress = true;\n\n try {\n if (this.#timegroupGenerator) {\n // Generator is running - abort and reset queue to exactly what we need now\n // Abort in-flight capture\n this.#timegroupGeneratorAbort?.abort();\n\n // Create new abort controller for the updated queue\n this.#timegroupGeneratorAbort = new AbortController();\n\n // Reset queue to exactly what we need now\n this.#timegroupQueue.reset(uncached);\n } else if (this.#timegroupClone) {\n // Generator finished, restart with existing clone\n this.#timegroupQueue.reset(uncached);\n\n // Create new abort controller\n this.#timegroupGeneratorAbort = new AbortController();\n\n this.#timegroupGenerator = generateThumbnailsFromClone(\n this.#timegroupClone.clone,\n this.#previewContainer!,\n this.#timegroupQueue,\n {\n scale: 0.25,\n contentReadyMode: \"blocking\",\n blockingTimeoutMs: 1000,\n signal: this.#timegroupGeneratorAbort.signal,\n },\n );\n await this.#consumeTimegroupGenerator();\n } else {\n // No generator or clone, start fresh\n await this.#startTimegroupGenerator(timegroup, uncached);\n }\n } finally {\n this.#updateInProgress = false;\n\n // Check if there are pending timestamps that need processing\n // This happens when updates were skipped while this update was in progress\n if (this.#pendingTimestamps.size > 0 && !this.#retryScheduled) {\n this.#retryScheduled = true;\n requestAnimationFrame(() => {\n this.#retryScheduled = false;\n if (this.#pendingTimestamps.size > 0) {\n const pending = Array.from(this.#pendingTimestamps);\n this.#pendingTimestamps.clear();\n this.#updateTimegroupCapture(pending);\n }\n });\n }\n }\n }\n\n /**\n * Start timegroup thumbnail generator\n */\n async #startTimegroupGenerator(\n timegroup: EFTimegroup,\n timestamps: number[],\n ): Promise<void> {\n // Create render clone\n this.#timegroupClone = await timegroup.createRenderClone();\n\n // Use the original container from createRenderClone (already configured)\n this.#previewContainer = this.#timegroupClone.container as HTMLDivElement;\n\n // CRITICAL: Wait for Lit to process shadow DOM updates after moving to new container\n await this.#timegroupClone.clone.updateComplete;\n\n // Also wait for all nested Lit elements to update\n const litElements = this.#previewContainer.querySelectorAll(\"*\");\n const updatePromises: Promise<any>[] = [];\n for (const el of litElements) {\n if (\"updateComplete\" in el) {\n updatePromises.push((el as any).updateComplete);\n }\n }\n await Promise.all(updatePromises);\n\n // Wait AGAIN specifically for text segments (they may need to re-render after move)\n const textSegments =\n this.#previewContainer.querySelectorAll(\"ef-text-segment\");\n const textUpdatePromises: Promise<any>[] = [];\n for (const seg of textSegments) {\n if (\"updateComplete\" in seg) {\n textUpdatePromises.push((seg as any).updateComplete);\n }\n }\n await Promise.all(textUpdatePromises);\n\n // CRITICAL: Wait for ef-text to split text into segments\n // EFText.connectedCallback schedules splitText in requestAnimationFrame\n // We must wait for that RAF to fire before capturing\n await new Promise((resolve) => requestAnimationFrame(resolve));\n\n // WARMUP: Do a seek to the first timestamp to \"prime\" the clone\n // Guard: clone may have been disposed during prior awaits\n if (!this.#timegroupClone) return;\n if (timestamps.length > 0) {\n await this.#timegroupClone.clone.seekForRender(timestamps[0]!);\n }\n\n // CRITICAL: Wait for fonts to load\n // Text won't render correctly if fonts aren't ready\n await document.fonts.ready;\n\n // CRITICAL: Wait for all images to load\n const images = this.#previewContainer.querySelectorAll(\"img\");\n const imagePromises: Promise<void>[] = [];\n for (const img of images) {\n if (!img.complete) {\n imagePromises.push(\n new Promise((resolve, _reject) => {\n img.onload = () => resolve();\n img.onerror = () => resolve(); // Don't block on errors\n // Timeout after 5s\n setTimeout(() => resolve(), 5000);\n }),\n );\n }\n }\n await Promise.all(imagePromises);\n\n // Guard: clone may have been disposed during prior awaits\n if (!this.#timegroupClone) return;\n\n // Initialize queue\n this.#timegroupQueue.reset(timestamps);\n\n // Create abort controller for this generator\n this.#timegroupGeneratorAbort = new AbortController();\n\n // Start generator using the fresh container\n this.#timegroupGenerator = generateThumbnailsFromClone(\n this.#timegroupClone.clone,\n this.#previewContainer,\n this.#timegroupQueue,\n {\n scale: 0.25,\n contentReadyMode: \"blocking\",\n blockingTimeoutMs: 1000,\n signal: this.#timegroupGeneratorAbort.signal,\n },\n );\n\n // Consume generator (CRITICAL: Must await to prevent concurrent consumers)\n await this.#consumeTimegroupGenerator();\n }\n\n /**\n * Consume generator and handle cleanup\n */\n async #consumeTimegroupGenerator(): Promise<void> {\n // CRITICAL: Prevent concurrent consumers\n if (this.#consumerRunning) {\n return;\n }\n this.#consumerRunning = true;\n\n if (!this.#timegroupGenerator) {\n this.#consumerRunning = false;\n return;\n }\n\n try {\n for await (const { timeMs, canvas } of this.#timegroupGenerator) {\n this.#thumbnailCache.set(timeMs, canvas);\n this.#scheduleRender();\n }\n } catch (err) {\n console.warn(\"Timegroup thumbnail generation error:\", err);\n } finally {\n // Generator finished, but keep clone alive for reuse\n this.#timegroupGenerator = null;\n this.#consumerRunning = false;\n }\n }\n\n /**\n * Cleanup timegroup generator and clone\n */\n #cleanupTimegroupGenerator(): void {\n // Abort any in-flight work\n this.#timegroupGeneratorAbort?.abort();\n this.#timegroupGeneratorAbort = null;\n\n this.#timegroupGenerator = null;\n\n // Remove preview container from DOM\n if (this.#previewContainer) {\n this.#previewContainer.remove();\n this.#previewContainer = null;\n }\n\n // Cleanup render clone\n if (this.#timegroupClone) {\n this.#timegroupClone.cleanup();\n this.#timegroupClone = null;\n }\n }\n\n /**\n * Clear all canvas elements\n */\n #clearCanvas(): void {\n const container = this.#canvasContainer.value;\n if (container) {\n container.innerHTML = \"\";\n }\n }\n\n /**\n * Translate composition time to source time for videos (handles trim)\n */\n #getSourceTimeMs(compositionTimeMs: number): number {\n if (this.useIntrinsicDuration) {\n return compositionTimeMs;\n }\n const el = this.targetElement;\n if (el instanceof EFVideo) {\n return compositionTimeMs + (el.sourceStartMs ?? 0);\n }\n return compositionTimeMs;\n }\n\n /**\n * Draw thumbnails to canvas elements\n */\n #drawThumbnails(\n thumbnails: ThumbnailDescriptor[],\n results: ThumbnailResult[],\n ): void {\n const container = this.#canvasContainer.value;\n if (!container) return;\n\n // Clear existing canvases\n container.innerHTML = \"\";\n\n for (let i = 0; i < thumbnails.length; i++) {\n const thumbnail = thumbnails[i];\n const result = results[i];\n\n if (!thumbnail) continue;\n\n const canvas = document.createElement(\"canvas\");\n canvas.width = thumbnail.width;\n canvas.height = thumbnail.height;\n canvas.style.left = `${thumbnail.x}px`;\n canvas.style.top = \"0\";\n canvas.style.width = `${thumbnail.width}px`;\n canvas.style.height = `${thumbnail.height}px`;\n\n const ctx = canvas.getContext(\"2d\");\n if (!ctx) continue;\n\n if (result?.canvas) {\n // Draw actual thumbnail\n ctx.drawImage(result.canvas, 0, 0, thumbnail.width, thumbnail.height);\n\n // Draw timestamp overlay if enabled\n if (this.previewSettings?.showThumbnailTimestamps) {\n ctx.fillStyle = \"rgba(0, 0, 0, 0.8)\";\n ctx.fillRect(2, 2, 95, 16);\n ctx.fillStyle = \"yellow\";\n ctx.font = \"11px monospace\";\n ctx.textAlign = \"left\";\n ctx.textBaseline = \"top\";\n ctx.fillText(`${Math.round(thumbnail.timeMs)}ms`, 5, 4);\n }\n } else {\n // Draw placeholder with timestamp text\n const bgColor =\n getComputedStyle(this)\n .getPropertyValue(\"--ef-color-bg-inset\")\n .trim() || \"rgba(100, 100, 100, 0.3)\";\n ctx.fillStyle = bgColor;\n ctx.fillRect(0, 0, thumbnail.width, thumbnail.height);\n\n const borderColor =\n getComputedStyle(this)\n .getPropertyValue(\"--ef-color-border-subtle\")\n .trim() || \"rgba(150, 150, 150, 0.5)\";\n ctx.strokeStyle = borderColor;\n ctx.lineWidth = 1;\n ctx.strokeRect(0, 0, thumbnail.width, thumbnail.height);\n\n ctx.fillStyle = \"white\";\n ctx.font = \"10px monospace\";\n ctx.textAlign = \"center\";\n ctx.textBaseline = \"middle\";\n ctx.fillText(\n `${Math.round(thumbnail.timeMs)}ms`,\n thumbnail.width / 2,\n thumbnail.height / 2,\n );\n }\n\n container.appendChild(canvas);\n }\n }\n\n render() {\n // Error: No target specified (neither target string nor targetElement)\n if (!this.target && !this.targetElement) {\n return html`<div class=\"error-message\">No target specified</div>`;\n }\n\n // Error: Target element not found (when using target string)\n if (this.target && !this.targetElement) {\n return html`<div class=\"error-message\">\n Target element \"${this.target}\" not found\n </div>`;\n }\n\n // Error: Invalid target type\n if (!this.isValidTarget) {\n const elementType =\n (this.targetElement as any).tagName?.toLowerCase() || \"unknown\";\n return html`<div class=\"error-message\">\n Invalid target: \"${elementType}\" must be ef-video or root ef-timegroup\n </div>`;\n }\n\n // Calculate track width to clip thumbnails at track end\n const durationMs = this.#effectiveDurationMs;\n const pixelsPerMs = this.#effectivePixelsPerMs;\n const trackWidthPx = durationMs * pixelsPerMs;\n\n // Render canvas container with explicit width clipping\n return html`<div \n class=\"thumbnail-container\" \n style=\"max-width: ${trackWidthPx}px;\"\n ${ref(this.#canvasContainer)}\n ></div>`;\n }\n}\n\ndeclare global {\n interface HTMLElementTagNameMap {\n \"ef-thumbnail-strip\": EFThumbnailStrip;\n }\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"EFThumbnailStrip.js","names":["#timestamps","EFThumbnailStrip","#timelineState","#thumbnailDimensions","#effectiveDurationMs","#effectivePixelsPerMs","#hostWidth","#targetController","#resizeObserver","#scheduleRender","#abortController","#cleanupTimegroupGenerator","#detachTargetListeners","#calculateThumbnailDimensions","#attachTargetListeners","#targetReadyStateHandler","#targetContentChangeHandler","thumbnails: ThumbnailDescriptor[]","#previousPixelsPerMs","#thumbnailPhase","#renderRequested","#renderThumbnails","#calculateVisibleThumbnails","#clearCanvas","#lastRequiredTimestamps","#updateVideoCapture","#updateTimegroupCapture","results: ThumbnailResult[]","#thumbnailCache","nearestTimeMs: number | null","#drawThumbnails","#getSourceTimeMs","#updateInProgress","#pendingTimestamps","#retryScheduled","#timegroupGenerator","#timegroupGeneratorAbort","#timegroupQueue","#timegroupClone","#previewContainer","#consumeTimegroupGenerator","#startTimegroupGenerator","updatePromises: Promise<any>[]","textUpdatePromises: Promise<any>[]","imagePromises: Promise<void>[]","#consumerRunning","#canvasContainer"],"sources":["../../../../src/gui/timeline/tracks/EFThumbnailStrip.ts"],"sourcesContent":["import { consume } from \"@lit/context\";\nimport { css, html, LitElement } from \"lit\";\nimport { customElement, property, state } from \"lit/decorators.js\";\nimport { createRef, ref, type Ref } from \"lit/directives/ref.js\";\n\nimport { EFTimegroup } from \"../../../elements/EFTimegroup.js\";\nimport { EFVideo } from \"../../../elements/EFVideo.js\";\nimport { TargetController } from \"../../../elements/TargetController.js\";\nimport { ThumbnailExtractor } from \"../../../elements/EFMedia/shared/ThumbnailExtractor.js\";\nimport {\n generateThumbnailsFromClone,\n type GeneratedThumbnail,\n type ThumbnailQueue,\n} from \"../../../preview/renderTimegroupToCanvas.js\";\n\nimport { quantizeToFrameTimeMs } from \"../../../utils/frameTime.js\";\nimport { TWMixin } from \"../../TWMixin.js\";\nimport {\n timelineStateContext,\n type TimelineState,\n} from \"../timelineStateContext.js\";\nimport {\n previewSettingsContext,\n type PreviewSettings,\n} from \"../../previewSettingsContext.js\";\n\n/** Padding for virtual rendering */\nconst VIRTUAL_RENDER_PADDING_PX = 100;\n\n/**\n * Mutable queue for timestamp generation.\n * Allows updating timestamps while generator is consuming them.\n */\nclass MutableTimestampQueue implements ThumbnailQueue {\n #timestamps: number[] = [];\n\n /** Replace entire queue with new timestamps (sorted) */\n reset(timestamps: number[]): void {\n this.#timestamps = [...timestamps].sort((a, b) => a - b);\n }\n\n /** Keep only these specific timestamps (maintains order) */\n retainOnly(timestamps: number[]): void {\n const keep = new Set(timestamps);\n this.#timestamps = this.#timestamps.filter((t) => keep.has(t));\n }\n\n /** Append timestamps to end (sorted) */\n append(timestamps: number[]): void {\n this.#timestamps.push(...[...timestamps].sort((a, b) => a - b));\n }\n\n /** Get next timestamp (removes from front) */\n shift(): number | undefined {\n return this.#timestamps.shift();\n }\n\n /** Get remaining timestamps without modifying queue */\n remaining(): number[] {\n return [...this.#timestamps];\n }\n\n /** Check if queue is empty */\n isEmpty(): boolean {\n return this.#timestamps.length === 0;\n }\n}\n\n/**\n * Descriptor for a thumbnail to render\n */\ninterface ThumbnailDescriptor {\n timeMs: number;\n x: number;\n width: number;\n height: number;\n}\n\n/**\n * Result of thumbnail rendering (canvas or error)\n */\ninterface ThumbnailResult {\n canvas: CanvasImageSource | null;\n error?: Error;\n}\n\n/**\n * Thumbnail strip component that renders thumbnails for video or timegroup elements.\n *\n * Features:\n * - Targets ef-video or root ef-timegroup via target attribute\n * - Batch video thumbnail extraction via ThumbnailExtractor\n * - Canvas rendering for timegroups at low resolution\n * - Viewport-based lazy loading with scroll calculation\n * - Fixed visual spacing (consistent at all zoom levels)\n * - Error indicators for failed thumbnails\n */\n@customElement(\"ef-thumbnail-strip\")\nexport class EFThumbnailStrip extends TWMixin(LitElement) {\n static styles = [\n css`\n :host {\n display: block;\n position: relative;\n width: 100%;\n height: 100%;\n overflow: hidden;\n }\n\n .thumbnail-container {\n position: relative;\n width: 100%;\n height: 100%;\n overflow: hidden;\n }\n\n .error-message {\n display: flex;\n align-items: center;\n justify-content: center;\n width: 100%;\n height: 100%;\n padding: 8px;\n color: var(--ef-color-error);\n font-size: 12px;\n background: color-mix(in srgb, var(--ef-color-error) 10%, transparent);\n }\n\n canvas {\n position: absolute;\n image-rendering: pixelated;\n image-rendering: crisp-edges;\n }\n\n .shimmer-overlay {\n display: none;\n position: absolute;\n inset: 0;\n background: linear-gradient(\n 90deg,\n color-mix(in srgb, var(--ef-color-text, #fafafa) 12%, transparent) 0%,\n color-mix(in srgb, var(--ef-color-text, #fafafa) 28%, transparent) 50%,\n color-mix(in srgb, var(--ef-color-text, #fafafa) 12%, transparent) 100%\n );\n background-size: 200% 100%;\n pointer-events: none;\n }\n\n .shimmer-overlay.active {\n display: block;\n animation: shimmer-strip var(--ef-loading-shimmer-duration, 1.5s) linear infinite;\n }\n\n @keyframes shimmer-strip {\n 0% { background-position: 200% 0; }\n 100% { background-position: -200% 0; }\n }\n `,\n ];\n\n @property({ type: String })\n target = \"\";\n\n @property({ attribute: false })\n targetElement: Element | null = null;\n\n @property({ type: Number, attribute: \"thumbnail-height\" })\n thumbnailHeight = 24;\n\n @property({ type: Number, attribute: \"thumbnail-spacing-px\" })\n thumbnailSpacingPx = 48;\n\n @property({ type: Number, attribute: \"pixels-per-ms\" })\n pixelsPerMs: number | null = null;\n\n @property({ type: Boolean, attribute: \"use-intrinsic-duration\" })\n useIntrinsicDuration = false;\n\n @consume({ context: timelineStateContext, subscribe: true })\n @state()\n timelineState?: TimelineState;\n\n @consume({ context: previewSettingsContext, subscribe: true })\n @state()\n previewSettings?: PreviewSettings;\n\n @state()\n thumbnailDimensions = { width: 0, height: 0 };\n\n @state()\n _isLoadingThumbnails = false;\n\n #targetController?: TargetController;\n #abortController: AbortController | null = null;\n #renderRequested = false;\n #canvasContainer: Ref<HTMLDivElement> = createRef();\n #lastRequiredTimestamps = \"\";\n #thumbnailCache = new Map<number, CanvasImageSource>();\n\n // Timegroup thumbnail generation state\n #timegroupQueue = new MutableTimestampQueue();\n #timegroupClone: {\n clone: EFTimegroup;\n container: HTMLElement;\n cleanup: () => void;\n } | null = null;\n #timegroupGenerator: AsyncGenerator<GeneratedThumbnail> | null = null;\n #timegroupGeneratorAbort: AbortController | null = null;\n #previewContainer: HTMLDivElement | null = null;\n #updateInProgress = false; // Lock to prevent concurrent updates\n #consumerRunning = false; // Lock to prevent concurrent consumers\n #pendingTimestamps = new Set<number>(); // Timestamps requested while update in progress\n #retryScheduled = false; // Flag to prevent duplicate retry schedules\n #thumbnailPhase: number = 0; // Phase offset for thumbnail grid\n #previousPixelsPerMs: number | null = null; // Track zoom changes\n #targetReadyStateHandler: (() => void) | null = null;\n #targetContentChangeHandler: (() => void) | null = null;\n #resizeObserver: ResizeObserver | null = null;\n #hostWidth = 0;\n\n /**\n * Check if target is valid (EFVideo or root EFTimegroup)\n */\n get isValidTarget(): boolean {\n const el = this.targetElement;\n if (!el) return false;\n\n if (el instanceof EFVideo) return true;\n\n if (el instanceof EFTimegroup) {\n // Only root timegroups\n return (el as any).isRootTimegroup === true;\n }\n\n return false;\n }\n\n get #timelineState(): TimelineState | undefined {\n return this.timelineState;\n }\n\n get #thumbnailDimensions() {\n return this.thumbnailDimensions;\n }\n\n get #effectiveDurationMs(): number {\n const element = this.targetElement;\n if (!element) return 0;\n if (this.useIntrinsicDuration) {\n return (\n (element as any).intrinsicDurationMs ?? (element as any).durationMs ?? 0\n );\n }\n return (element as any).durationMs ?? 0;\n }\n\n get #effectivePixelsPerMs(): number {\n if (this.#timelineState?.pixelsPerMs != null) {\n return this.#timelineState.pixelsPerMs;\n }\n if (this.pixelsPerMs != null) {\n return this.pixelsPerMs;\n }\n const durationMs = this.#effectiveDurationMs;\n if (this.#hostWidth > 0 && durationMs > 0) {\n return this.#hostWidth / durationMs;\n }\n return 0.04;\n }\n\n connectedCallback(): void {\n super.connectedCallback();\n // Only use TargetController if target is set and targetElement is not directly set\n if (this.target && !this.targetElement) {\n this.#targetController = new TargetController(this);\n }\n this.#resizeObserver = new ResizeObserver((entries) => {\n const entry = entries[0];\n if (!entry) return;\n const width = entry.contentRect.width;\n if (width !== this.#hostWidth) {\n this.#hostWidth = width;\n this.requestUpdate();\n this.#scheduleRender();\n }\n });\n this.#resizeObserver.observe(this);\n }\n\n disconnectedCallback(): void {\n super.disconnectedCallback();\n this.#abortController?.abort();\n this.#cleanupTimegroupGenerator();\n this.#detachTargetListeners(this.targetElement);\n this.#resizeObserver?.disconnect();\n this.#resizeObserver = null;\n }\n\n protected willUpdate(\n changedProperties: Map<string | number | symbol, unknown>,\n ): void {\n super.willUpdate(changedProperties);\n\n // Create TargetController if target is set and targetElement is not directly set\n if (changedProperties.has(\"target\")) {\n if (this.target && !this.targetElement && !this.#targetController) {\n this.#targetController = new TargetController(this);\n }\n }\n\n // Recalculate thumbnail dimensions if target changed\n if (\n changedProperties.has(\"targetElement\") ||\n changedProperties.has(\"thumbnailHeight\")\n ) {\n this.thumbnailDimensions = this.#calculateThumbnailDimensions();\n }\n\n // Manage event listeners when target changes\n if (changedProperties.has(\"targetElement\")) {\n const oldTarget = changedProperties.get(\n \"targetElement\",\n ) as Element | null;\n this.#detachTargetListeners(oldTarget);\n this.#attachTargetListeners(this.targetElement);\n }\n }\n\n #attachTargetListeners(target: Element | null): void {\n if (!target) return;\n\n this.#targetReadyStateHandler = () => {\n this.requestUpdate();\n this.#scheduleRender();\n };\n this.#targetContentChangeHandler = () => {\n this.requestUpdate();\n this.#scheduleRender();\n };\n target.addEventListener(\"readystatechange\", this.#targetReadyStateHandler);\n target.addEventListener(\"contentchange\", this.#targetContentChangeHandler);\n\n // Late-subscriber: if the target already transitioned to \"ready\" before\n // we attached, the event was missed. The contentReadyState property\n // serves exactly this purpose — check it and render if needed.\n if ((target as any).contentReadyState === \"ready\") {\n this.requestUpdate();\n this.#scheduleRender();\n }\n }\n\n #detachTargetListeners(target: Element | null): void {\n if (!target) return;\n if (this.#targetReadyStateHandler) {\n target.removeEventListener(\n \"readystatechange\",\n this.#targetReadyStateHandler,\n );\n this.#targetReadyStateHandler = null;\n }\n if (this.#targetContentChangeHandler) {\n target.removeEventListener(\n \"contentchange\",\n this.#targetContentChangeHandler,\n );\n this.#targetContentChangeHandler = null;\n }\n }\n\n updated(changedProperties: Map<string | number | symbol, unknown>): void {\n super.updated(changedProperties);\n\n if (\n changedProperties.has(\"targetElement\") ||\n changedProperties.has(\"thumbnailSpacingPx\") ||\n changedProperties.has(\"pixelsPerMs\") ||\n changedProperties.has(\"thumbnailHeight\") ||\n changedProperties.has(\"timelineState\")\n ) {\n this.#scheduleRender();\n }\n }\n\n /**\n * Calculate thumbnail dimensions from target element's actual bounds\n */\n #calculateThumbnailDimensions(): { width: number; height: number } {\n const el = this.targetElement;\n if (!el) return { width: 0, height: 0 };\n\n // Get actual visible bounds from DOM\n const bounds = el.getBoundingClientRect();\n if (bounds.width === 0 || bounds.height === 0) {\n // Element not yet rendered or no size, use default aspect ratio\n return {\n width: this.thumbnailHeight * (16 / 9),\n height: this.thumbnailHeight,\n };\n }\n\n const aspectRatio = bounds.width / bounds.height;\n const width = Math.round(this.thumbnailHeight * aspectRatio);\n\n return { width, height: this.thumbnailHeight };\n }\n\n /**\n * Calculate visible thumbnails based on viewport\n */\n #calculateVisibleThumbnails(): ThumbnailDescriptor[] {\n if (!this.isValidTarget) return [];\n\n const element = this.targetElement;\n if (!element) return [];\n\n const scrollLeft = this.#timelineState?.viewportScrollLeft ?? 0;\n const viewportWidth =\n this.#timelineState?.viewportWidth ?? (this.#hostWidth || 800);\n const pixelsPerMs = this.#effectivePixelsPerMs;\n\n const durationMs = this.#effectiveDurationMs;\n if (durationMs === 0) return [];\n\n const trackWidthPx = durationMs * pixelsPerMs;\n\n // Get FPS for quantization\n const fps = (element as any).fps ?? 30;\n\n const visibleStartPx = scrollLeft - VIRTUAL_RENDER_PADDING_PX;\n const visibleEndPx = scrollLeft + viewportWidth + VIRTUAL_RENDER_PADDING_PX;\n\n const thumbnails: ThumbnailDescriptor[] = [];\n const { width, height } = this.#thumbnailDimensions;\n\n // Read minimum gap from CSS variable (--ef-thumbnail-gap, default 2px)\n const gapPx =\n parseFloat(\n getComputedStyle(this).getPropertyValue(\"--ef-thumbnail-gap\"),\n ) || 2;\n // Stride must be at least thumbnail width + gap to prevent overlap\n const thumbnailStride = Math.max(this.thumbnailSpacingPx, width + gapPx);\n\n // Detect zoom by checking if pixelsPerMs changed\n const isZoom =\n this.#previousPixelsPerMs !== null &&\n this.#previousPixelsPerMs !== pixelsPerMs;\n\n if (this.#previousPixelsPerMs === null) {\n // First render: align grid to track start (t=0)\n this.#thumbnailPhase = 0;\n } else if (isZoom) {\n // On zoom: snap a thumbnail to near the left edge of viewport\n // This prevents visual slip during zoom operations\n this.#thumbnailPhase = scrollLeft % thumbnailStride;\n } else if (scrollLeft < thumbnailStride) {\n // When scrolled near the start, realign to t=0 to avoid left gap\n this.#thumbnailPhase = 0;\n }\n // During normal scroll: phase unchanged, grid scrolls naturally with track\n\n this.#previousPixelsPerMs = pixelsPerMs;\n\n // Generate thumbnail grid anchored at phase offset\n // Each thumbnail is at absolute track position: phase + (i * stride)\n // This means grid is stable in track space (scrolls naturally)\n const startIndex = Math.max(\n 0,\n Math.floor((visibleStartPx - this.#thumbnailPhase) / thumbnailStride),\n );\n const endIndex = Math.ceil(\n (visibleEndPx - this.#thumbnailPhase) / thumbnailStride,\n );\n\n for (let i = startIndex; i <= endIndex; i++) {\n const thumbX = this.#thumbnailPhase + i * thumbnailStride;\n\n // Only include if within track bounds\n if (thumbX >= 0 && thumbX < trackWidthPx) {\n // Convert position to time (leading edge)\n const rawTimeMs = thumbX / pixelsPerMs;\n const timeMs = quantizeToFrameTimeMs(rawTimeMs, fps);\n\n if (timeMs >= 0 && timeMs < durationMs) {\n thumbnails.push({ timeMs, x: thumbX, width, height });\n }\n }\n }\n\n return thumbnails;\n }\n\n /**\n * Schedule thumbnail render on next frame\n */\n #scheduleRender(): void {\n if (this.#renderRequested) return;\n this.#renderRequested = true;\n\n requestAnimationFrame(() => {\n this.#renderRequested = false;\n this.#renderThumbnails();\n });\n }\n\n /**\n * Render thumbnails with cancellation support\n */\n async #renderThumbnails(): Promise<void> {\n // Cancel previous render\n this.#abortController?.abort();\n this.#abortController = new AbortController();\n const signal = this.#abortController.signal;\n\n const visibleThumbnails = this.#calculateVisibleThumbnails();\n if (visibleThumbnails.length === 0) {\n this.#clearCanvas();\n return;\n }\n\n // Check if required timestamps changed\n const requiredTimestamps = visibleThumbnails.map((t) => t.timeMs);\n const timestampsString = requiredTimestamps.join(\", \");\n if (timestampsString !== this.#lastRequiredTimestamps) {\n this.#lastRequiredTimestamps = timestampsString;\n\n // Update capture queue\n if (this.targetElement instanceof EFVideo) {\n this.#updateVideoCapture(requiredTimestamps, signal).catch((error) => {\n // Ignore AbortErrors - these are expected when renders are cancelled\n if (error instanceof DOMException && error.name === \"AbortError\") {\n return;\n }\n console.error(\"Thumbnail capture error:\", error);\n });\n } else if (this.targetElement instanceof EFTimegroup) {\n this.#updateTimegroupCapture(requiredTimestamps);\n }\n }\n\n if (signal.aborted) return;\n\n // Draw thumbnails - use nearest neighbor if exact timestamp not cached\n const maxNeighborDistanceMs = 3000; // Don't use thumbnails more than 3s away\n const results: ThumbnailResult[] = visibleThumbnails.map((t) => {\n let canvas = this.#thumbnailCache.get(t.timeMs);\n\n // If exact match not found, find nearest cached thumbnail\n if (!canvas) {\n let nearestTimeMs: number | null = null;\n let minDistance = Infinity;\n\n for (const cachedTimeMs of this.#thumbnailCache.keys()) {\n const distance = Math.abs(cachedTimeMs - t.timeMs);\n if (distance < minDistance && distance <= maxNeighborDistanceMs) {\n minDistance = distance;\n nearestTimeMs = cachedTimeMs;\n }\n }\n\n if (nearestTimeMs !== null) {\n canvas = this.#thumbnailCache.get(nearestTimeMs);\n }\n }\n\n return { canvas: canvas ?? null };\n });\n this.#drawThumbnails(visibleThumbnails, results);\n\n const hasEmptySlots = results.some((r) => r.canvas === null);\n if (this._isLoadingThumbnails !== hasEmptySlots) {\n this._isLoadingThumbnails = hasEmptySlots;\n }\n }\n\n /**\n * Update video thumbnail capture\n */\n async #updateVideoCapture(\n timestamps: number[],\n signal: AbortSignal,\n ): Promise<void> {\n const video = this.targetElement as EFVideo;\n if (!video) return;\n\n // Filter out cached timestamps\n const uncached = timestamps.filter((t) => !this.#thumbnailCache.has(t));\n if (uncached.length === 0) return;\n\n const mediaEngineTask = video.mediaEngineTask;\n if (!mediaEngineTask) return;\n\n const mediaEngine = await mediaEngineTask.taskComplete;\n if (!mediaEngine) return;\n\n const sourceTimestamps = uncached.map((t) => this.#getSourceTimeMs(t));\n\n const extractor = new ThumbnailExtractor(mediaEngine);\n const videoTrack = mediaEngine.tracks.video ?? mediaEngine.tracks.scrub;\n if (!videoTrack) return;\n\n const results = await extractor.extractThumbnails(\n sourceTimestamps,\n videoTrack,\n video.durationMs ?? 0,\n signal,\n );\n\n // Store in cache and trigger redraw\n for (let i = 0; i < uncached.length; i++) {\n const thumbnail = results[i]?.thumbnail;\n const timestamp = uncached[i];\n if (thumbnail && timestamp !== undefined) {\n this.#thumbnailCache.set(timestamp, thumbnail);\n }\n }\n\n this.#scheduleRender();\n }\n\n /**\n * Update timegroup thumbnail capture using mutable queue\n */\n async #updateTimegroupCapture(timestamps: number[]): Promise<void> {\n const timegroup = this.targetElement as EFTimegroup;\n if (!timegroup) return;\n\n // Filter out cached timestamps\n const uncached = timestamps\n .filter((t) => !this.#thumbnailCache.has(t))\n .sort((a, b) => a - b);\n if (uncached.length === 0) {\n return;\n }\n\n // CRITICAL: If update already in progress, REPLACE pending (not add)\n // We only want the LATEST required timestamps, not a union of all previous ones\n if (this.#updateInProgress) {\n // Clear old pending and replace with latest\n this.#pendingTimestamps.clear();\n uncached.forEach((t) => this.#pendingTimestamps.add(t));\n\n // Schedule a retry (debounced via RAF)\n if (!this.#retryScheduled) {\n this.#retryScheduled = true;\n requestAnimationFrame(() => {\n this.#retryScheduled = false;\n if (this.#pendingTimestamps.size > 0) {\n const pending = Array.from(this.#pendingTimestamps);\n this.#pendingTimestamps.clear();\n this.#updateTimegroupCapture(pending);\n }\n });\n }\n return;\n }\n this.#updateInProgress = true;\n\n try {\n if (this.#timegroupGenerator) {\n // Generator is running - abort and reset queue to exactly what we need now\n // Abort in-flight capture\n this.#timegroupGeneratorAbort?.abort();\n\n // Create new abort controller for the updated queue\n this.#timegroupGeneratorAbort = new AbortController();\n\n // Reset queue to exactly what we need now\n this.#timegroupQueue.reset(uncached);\n } else if (this.#timegroupClone) {\n // Generator finished, restart with existing clone\n this.#timegroupQueue.reset(uncached);\n\n // Create new abort controller\n this.#timegroupGeneratorAbort = new AbortController();\n\n this.#timegroupGenerator = generateThumbnailsFromClone(\n this.#timegroupClone.clone,\n this.#previewContainer!,\n this.#timegroupQueue,\n {\n scale: 0.25,\n contentReadyMode: \"blocking\",\n blockingTimeoutMs: 1000,\n signal: this.#timegroupGeneratorAbort.signal,\n },\n );\n await this.#consumeTimegroupGenerator();\n } else {\n // No generator or clone, start fresh\n await this.#startTimegroupGenerator(timegroup, uncached);\n }\n } finally {\n this.#updateInProgress = false;\n\n // Check if there are pending timestamps that need processing\n // This happens when updates were skipped while this update was in progress\n if (this.#pendingTimestamps.size > 0 && !this.#retryScheduled) {\n this.#retryScheduled = true;\n requestAnimationFrame(() => {\n this.#retryScheduled = false;\n if (this.#pendingTimestamps.size > 0) {\n const pending = Array.from(this.#pendingTimestamps);\n this.#pendingTimestamps.clear();\n this.#updateTimegroupCapture(pending);\n }\n });\n }\n }\n }\n\n /**\n * Start timegroup thumbnail generator\n */\n async #startTimegroupGenerator(\n timegroup: EFTimegroup,\n timestamps: number[],\n ): Promise<void> {\n // Create render clone\n this.#timegroupClone = await timegroup.createRenderClone();\n\n // Use the original container from createRenderClone (already configured)\n this.#previewContainer = this.#timegroupClone.container as HTMLDivElement;\n\n // CRITICAL: Wait for Lit to process shadow DOM updates after moving to new container\n await this.#timegroupClone.clone.updateComplete;\n\n // Also wait for all nested Lit elements to update\n const litElements = this.#previewContainer.querySelectorAll(\"*\");\n const updatePromises: Promise<any>[] = [];\n for (const el of litElements) {\n if (\"updateComplete\" in el) {\n updatePromises.push((el as any).updateComplete);\n }\n }\n await Promise.all(updatePromises);\n\n // Wait AGAIN specifically for text segments (they may need to re-render after move)\n const textSegments =\n this.#previewContainer.querySelectorAll(\"ef-text-segment\");\n const textUpdatePromises: Promise<any>[] = [];\n for (const seg of textSegments) {\n if (\"updateComplete\" in seg) {\n textUpdatePromises.push((seg as any).updateComplete);\n }\n }\n await Promise.all(textUpdatePromises);\n\n // CRITICAL: Wait for ef-text to split text into segments\n // EFText.connectedCallback schedules splitText in requestAnimationFrame\n // We must wait for that RAF to fire before capturing\n await new Promise((resolve) => requestAnimationFrame(resolve));\n\n // WARMUP: Do a seek to the first timestamp to \"prime\" the clone\n // Guard: clone may have been disposed during prior awaits\n if (!this.#timegroupClone) return;\n if (timestamps.length > 0) {\n await this.#timegroupClone.clone.seekForRender(timestamps[0]!);\n }\n\n // CRITICAL: Wait for fonts to load\n // Text won't render correctly if fonts aren't ready\n await document.fonts.ready;\n\n // CRITICAL: Wait for all images to load\n const images = this.#previewContainer.querySelectorAll(\"img\");\n const imagePromises: Promise<void>[] = [];\n for (const img of images) {\n if (!img.complete) {\n imagePromises.push(\n new Promise((resolve, _reject) => {\n img.onload = () => resolve();\n img.onerror = () => resolve(); // Don't block on errors\n // Timeout after 5s\n setTimeout(() => resolve(), 5000);\n }),\n );\n }\n }\n await Promise.all(imagePromises);\n\n // Guard: clone may have been disposed during prior awaits\n if (!this.#timegroupClone) return;\n\n // Initialize queue\n this.#timegroupQueue.reset(timestamps);\n\n // Create abort controller for this generator\n this.#timegroupGeneratorAbort = new AbortController();\n\n // Start generator using the fresh container\n this.#timegroupGenerator = generateThumbnailsFromClone(\n this.#timegroupClone.clone,\n this.#previewContainer,\n this.#timegroupQueue,\n {\n scale: 0.25,\n contentReadyMode: \"blocking\",\n blockingTimeoutMs: 1000,\n signal: this.#timegroupGeneratorAbort.signal,\n },\n );\n\n // Consume generator (CRITICAL: Must await to prevent concurrent consumers)\n await this.#consumeTimegroupGenerator();\n }\n\n /**\n * Consume generator and handle cleanup\n */\n async #consumeTimegroupGenerator(): Promise<void> {\n // CRITICAL: Prevent concurrent consumers\n if (this.#consumerRunning) {\n return;\n }\n this.#consumerRunning = true;\n\n if (!this.#timegroupGenerator) {\n this.#consumerRunning = false;\n return;\n }\n\n try {\n for await (const { timeMs, canvas } of this.#timegroupGenerator) {\n this.#thumbnailCache.set(timeMs, canvas);\n this.#scheduleRender();\n }\n } catch (err) {\n console.warn(\"Timegroup thumbnail generation error:\", err);\n } finally {\n // Generator finished, but keep clone alive for reuse\n this.#timegroupGenerator = null;\n this.#consumerRunning = false;\n }\n }\n\n /**\n * Cleanup timegroup generator and clone\n */\n #cleanupTimegroupGenerator(): void {\n // Abort any in-flight work\n this.#timegroupGeneratorAbort?.abort();\n this.#timegroupGeneratorAbort = null;\n\n this.#timegroupGenerator = null;\n\n // Remove preview container from DOM\n if (this.#previewContainer) {\n this.#previewContainer.remove();\n this.#previewContainer = null;\n }\n\n // Cleanup render clone\n if (this.#timegroupClone) {\n this.#timegroupClone.cleanup();\n this.#timegroupClone = null;\n }\n }\n\n /**\n * Clear all canvas elements\n */\n #clearCanvas(): void {\n const container = this.#canvasContainer.value;\n if (container) {\n container.innerHTML = \"\";\n }\n }\n\n /**\n * Translate composition time to source time for videos (handles trim)\n */\n #getSourceTimeMs(compositionTimeMs: number): number {\n if (this.useIntrinsicDuration) {\n return compositionTimeMs;\n }\n const el = this.targetElement;\n if (el instanceof EFVideo) {\n return compositionTimeMs + (el.sourceStartMs ?? 0);\n }\n return compositionTimeMs;\n }\n\n /**\n * Draw thumbnails to canvas elements\n */\n #drawThumbnails(\n thumbnails: ThumbnailDescriptor[],\n results: ThumbnailResult[],\n ): void {\n const container = this.#canvasContainer.value;\n if (!container) return;\n\n // Clear existing canvases\n container.innerHTML = \"\";\n\n for (let i = 0; i < thumbnails.length; i++) {\n const thumbnail = thumbnails[i];\n const result = results[i];\n\n if (!thumbnail) continue;\n\n const canvas = document.createElement(\"canvas\");\n canvas.width = thumbnail.width;\n canvas.height = thumbnail.height;\n canvas.style.left = `${thumbnail.x}px`;\n canvas.style.top = \"0\";\n canvas.style.width = `${thumbnail.width}px`;\n canvas.style.height = `${thumbnail.height}px`;\n\n const ctx = canvas.getContext(\"2d\");\n if (!ctx) continue;\n\n if (result?.canvas) {\n // Draw actual thumbnail\n ctx.drawImage(result.canvas, 0, 0, thumbnail.width, thumbnail.height);\n\n // Draw timestamp overlay if enabled\n if (this.previewSettings?.showThumbnailTimestamps) {\n ctx.fillStyle = \"rgba(0, 0, 0, 0.8)\";\n ctx.fillRect(2, 2, 95, 16);\n ctx.fillStyle = \"yellow\";\n ctx.font = \"11px monospace\";\n ctx.textAlign = \"left\";\n ctx.textBaseline = \"top\";\n ctx.fillText(`${Math.round(thumbnail.timeMs)}ms`, 5, 4);\n }\n } else {\n // Draw placeholder with timestamp text\n const bgColor =\n getComputedStyle(this)\n .getPropertyValue(\"--ef-color-bg-inset\")\n .trim() || \"rgba(100, 100, 100, 0.3)\";\n ctx.fillStyle = bgColor;\n ctx.fillRect(0, 0, thumbnail.width, thumbnail.height);\n\n const borderColor =\n getComputedStyle(this)\n .getPropertyValue(\"--ef-color-border-subtle\")\n .trim() || \"rgba(150, 150, 150, 0.5)\";\n ctx.strokeStyle = borderColor;\n ctx.lineWidth = 1;\n ctx.strokeRect(0, 0, thumbnail.width, thumbnail.height);\n\n ctx.fillStyle = \"white\";\n ctx.font = \"10px monospace\";\n ctx.textAlign = \"center\";\n ctx.textBaseline = \"middle\";\n ctx.fillText(\n `${Math.round(thumbnail.timeMs)}ms`,\n thumbnail.width / 2,\n thumbnail.height / 2,\n );\n }\n\n container.appendChild(canvas);\n }\n }\n\n render() {\n // Error: No target specified (neither target string nor targetElement)\n if (!this.target && !this.targetElement) {\n return html`<div class=\"error-message\">No target specified</div>`;\n }\n\n // Error: Target element not found (when using target string)\n if (this.target && !this.targetElement) {\n return html`<div class=\"error-message\">\n Target element \"${this.target}\" not found\n </div>`;\n }\n\n // Error: Invalid target type\n if (!this.isValidTarget) {\n const elementType =\n (this.targetElement as any).tagName?.toLowerCase() || \"unknown\";\n return html`<div class=\"error-message\">\n Invalid target: \"${elementType}\" must be ef-video or root ef-timegroup\n </div>`;\n }\n\n // Calculate track width to clip thumbnails at track end\n const durationMs = this.#effectiveDurationMs;\n const pixelsPerMs = this.#effectivePixelsPerMs;\n const trackWidthPx = durationMs * pixelsPerMs;\n\n // Render canvas container with explicit width clipping, plus shimmer overlay\n return html`\n <div\n class=\"thumbnail-container\"\n style=\"max-width: ${trackWidthPx}px;\"\n ${ref(this.#canvasContainer)}\n ></div>\n <div\n class=\"shimmer-overlay ${this._isLoadingThumbnails ? \"active\" : \"\"}\"\n ></div>\n `;\n }\n}\n\ndeclare global {\n interface HTMLElementTagNameMap {\n \"ef-thumbnail-strip\": EFThumbnailStrip;\n }\n}\n"],"mappings":"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"}
|
|
@@ -69,9 +69,9 @@ let EFVideoTrack = class EFVideoTrack$1 extends TrackItem {
|
|
|
69
69
|
this.#abortController = new AbortController();
|
|
70
70
|
try {
|
|
71
71
|
if (video.mediaEngineTask) {
|
|
72
|
-
if ((await video.mediaEngineTask.taskComplete)?.
|
|
72
|
+
if ((await video.mediaEngineTask.taskComplete)?.tracks.audio) {
|
|
73
73
|
this._hasAudio = true;
|
|
74
|
-
const waveformData = await extractWaveformData(
|
|
74
|
+
const waveformData = await extractWaveformData(video, this.#abortController.signal);
|
|
75
75
|
if (waveformData) {
|
|
76
76
|
this._waveformData = waveformData;
|
|
77
77
|
this.#scheduleRender();
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"VideoTrack.js","names":["EFVideoTrack","#checkAndLoadAudioWaveform","#lastSrc","#abortController","#scheduleRender","#renderRequested","#renderAudioOverlay","#drawAudioWaveform","#getTrackHeight"],"sources":["../../../../src/gui/timeline/tracks/VideoTrack.ts"],"sourcesContent":["import { consume } from \"@lit/context\";\nimport { css, html, nothing } from \"lit\";\nimport { customElement, state } from \"lit/decorators.js\";\nimport { createRef, ref } from \"lit/directives/ref.js\";\nimport { styleMap } from \"lit/directives/style-map.js\";\nimport { EFVideo } from \"../../../elements/EFVideo.js\";\n\n// TrackItem must be pre-loaded before this module is imported\n// See preloadTracks.ts for the initialization sequence\nimport { TrackItem } from \"./TrackItem.js\";\nimport { extractWaveformData, type WaveformData } from \"./waveformUtils.js\";\nimport {\n timelineStateContext,\n type TimelineState,\n} from \"../timelineStateContext.js\";\nimport \"./EFThumbnailStrip.js\";\n\n/** Padding for virtual rendering */\nconst VIRTUAL_RENDER_PADDING_PX = 100;\n\n/** Height of thumbnail section */\nconst THUMBNAIL_HEIGHT = 24;\n/** Height of audio section when present */\nconst AUDIO_SECTION_HEIGHT = 14;\n\n@customElement(\"ef-video-track\")\nexport class EFVideoTrack extends TrackItem {\n static override styles = [\n ...TrackItem.styles,\n css`\n .video-content {\n display: flex;\n flex-direction: column;\n height: 100%;\n }\n .thumbnail-section {\n position: relative;\n flex: 0 0 ${THUMBNAIL_HEIGHT}px;\n height: ${THUMBNAIL_HEIGHT}px;\n background: var(--ef-color-bg-inset);\n }\n .audio-section {\n position: relative;\n flex: 0 0 ${AUDIO_SECTION_HEIGHT}px;\n height: ${AUDIO_SECTION_HEIGHT}px;\n background: var(--ef-color-bg-elevated);\n border-top: 1px solid var(--ef-color-border-subtle);\n overflow: hidden;\n }\n .audio-section-canvas {\n position: absolute;\n top: 0;\n height: 100%;\n }\n `,\n ];\n\n audioCanvasRef = createRef<HTMLCanvasElement>();\n\n @consume({ context: timelineStateContext, subscribe: true })\n @state()\n private _timelineState?: TimelineState;\n\n @state()\n private _waveformData: WaveformData | null = null;\n\n @state()\n private _hasAudio = false;\n\n #lastSrc: string | null = null;\n #abortController: AbortController | null = null;\n #renderRequested = false;\n\n /**\n * Check if video has audio and load waveform data\n */\n async #checkAndLoadAudioWaveform(): Promise<void> {\n const video = this.element as EFVideo;\n const src = video?.src;\n\n if (!src || src === this.#lastSrc) {\n return;\n }\n\n this.#lastSrc = src;\n this._hasAudio = false;\n this._waveformData = null;\n\n // Cancel any in-progress load\n this.#abortController?.abort();\n this.#abortController = new AbortController();\n\n try {\n // Wait for media engine to determine if video has audio\n if (video.mediaEngineTask) {\n const mediaEngine = await video.mediaEngineTask.taskComplete;\n if (mediaEngine?.audioRendition) {\n this._hasAudio = true;\n\n // Load waveform data\n const waveformData = await extractWaveformData(\n src,\n this.#abortController.signal,\n );\n\n if (waveformData) {\n this._waveformData = waveformData;\n this.#scheduleRender();\n }\n }\n }\n } catch (error) {\n if (!(error instanceof DOMException && error.name === \"AbortError\")) {\n // Silently fail - audio overlay is optional\n }\n }\n }\n\n #scheduleRender(): void {\n if (this.#renderRequested) return;\n this.#renderRequested = true;\n\n requestAnimationFrame(() => {\n this.#renderRequested = false;\n this.#renderAudioOverlay();\n });\n }\n\n #renderAudioOverlay(): void {\n const canvas = this.audioCanvasRef.value;\n const waveformData = this._waveformData;\n\n if (!canvas || !waveformData || !this._hasAudio) return;\n\n const video = this.element as EFVideo;\n const durationMs = video.durationMs ?? 0;\n if (durationMs === 0) return;\n\n const pixelsPerMs = this._timelineState?.pixelsPerMs ?? this.pixelsPerMs;\n const trackWidthPx = durationMs * pixelsPerMs;\n const trackStartMs = video.startTimeMs ?? 0;\n const trackStartPx = trackStartMs * pixelsPerMs;\n\n // Get scroll/viewport info\n const scrollLeft = this._timelineState?.viewportScrollLeft ?? 0;\n const viewportWidth = this._timelineState?.viewportWidth ?? 800;\n\n // Calculate visible region\n const visibleLeftPx = scrollLeft - VIRTUAL_RENDER_PADDING_PX;\n const visibleRightPx =\n scrollLeft + viewportWidth + VIRTUAL_RENDER_PADDING_PX;\n const trackEndPx = trackStartPx + trackWidthPx;\n\n // Check visibility\n if (trackEndPx < visibleLeftPx || trackStartPx > visibleRightPx) {\n canvas.style.display = \"none\";\n return;\n }\n canvas.style.display = \"block\";\n\n // Calculate visible portion within track\n const visibleStartInTrack = Math.max(0, visibleLeftPx - trackStartPx);\n const visibleEndInTrack = Math.min(\n trackWidthPx,\n visibleRightPx - trackStartPx,\n );\n const visibleWidthPx = visibleEndInTrack - visibleStartInTrack;\n\n if (visibleWidthPx <= 0) return;\n\n const height = AUDIO_SECTION_HEIGHT;\n const dpr = window.devicePixelRatio || 1;\n\n // Set canvas size\n const targetWidth = Math.ceil(visibleWidthPx * dpr);\n const targetHeight = Math.ceil(height * dpr);\n\n if (canvas.width !== targetWidth || canvas.height !== targetHeight) {\n canvas.width = targetWidth;\n canvas.height = targetHeight;\n }\n\n canvas.style.left = `${visibleStartInTrack}px`;\n canvas.style.width = `${visibleWidthPx}px`;\n\n const ctx = canvas.getContext(\"2d\");\n if (!ctx) return;\n\n ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n ctx.clearRect(0, 0, visibleWidthPx, height);\n\n // Calculate time range to render\n const sourceInMs = video.sourceStartMs ?? 0;\n const timeStartMs = sourceInMs + visibleStartInTrack / pixelsPerMs;\n const timeEndMs = sourceInMs + visibleEndInTrack / pixelsPerMs;\n\n // Draw waveform in dedicated section\n this.#drawAudioWaveform(\n ctx,\n waveformData,\n visibleWidthPx,\n height,\n timeStartMs,\n timeEndMs,\n );\n }\n\n #drawAudioWaveform(\n ctx: CanvasRenderingContext2D,\n waveformData: WaveformData,\n width: number,\n height: number,\n startMs: number,\n endMs: number,\n ): void {\n const { peaks, samplesPerSecond } = waveformData;\n\n const startSample = Math.floor((startMs / 1000) * samplesPerSecond);\n const endSample = Math.ceil((endMs / 1000) * samplesPerSecond);\n const sampleCount = endSample - startSample;\n\n if (sampleCount <= 0 || width <= 0) return;\n\n const centerY = height / 2;\n const halfHeight = height / 2 - 1;\n const pixelsPerSample = width / sampleCount;\n\n // Draw filled waveform\n ctx.fillStyle =\n getComputedStyle(this).getPropertyValue(\"--ef-color-success\").trim() ||\n \"rgb(74, 222, 128)\";\n ctx.globalAlpha = 0.9;\n ctx.beginPath();\n\n // Draw top half (max values) left to right\n for (let i = 0; i <= sampleCount; i++) {\n const sampleIndex = startSample + i;\n const peakIndex = sampleIndex * 2;\n if (peakIndex + 1 >= peaks.length) break;\n\n const maxValue = peaks[peakIndex + 1] ?? 0;\n const px = i * pixelsPerSample;\n const py = centerY - maxValue * halfHeight;\n\n if (i === 0) {\n ctx.moveTo(px, py);\n } else {\n ctx.lineTo(px, py);\n }\n }\n\n // Draw bottom half (min values) right to left\n for (let i = sampleCount; i >= 0; i--) {\n const sampleIndex = startSample + i;\n const peakIndex = sampleIndex * 2;\n if (peakIndex >= peaks.length) continue;\n\n const minValue = peaks[peakIndex] ?? 0;\n const px = i * pixelsPerSample;\n const py = centerY - minValue * halfHeight;\n\n ctx.lineTo(px, py);\n }\n\n ctx.closePath();\n ctx.fill();\n\n // Draw center line\n ctx.globalAlpha = 0.3;\n ctx.strokeStyle =\n getComputedStyle(this).getPropertyValue(\"--ef-color-success\").trim() ||\n \"rgb(74, 222, 128)\";\n ctx.lineWidth = 1;\n ctx.beginPath();\n ctx.moveTo(0, centerY);\n ctx.lineTo(width, centerY);\n ctx.stroke();\n\n ctx.globalAlpha = 1;\n }\n\n connectedCallback(): void {\n super.connectedCallback();\n this.#checkAndLoadAudioWaveform();\n }\n\n disconnectedCallback(): void {\n super.disconnectedCallback();\n this.#abortController?.abort();\n }\n\n updated(changedProperties: Map<string | number | symbol, unknown>): void {\n super.updated(changedProperties);\n\n const video = this.element as EFVideo;\n if (video?.src !== this.#lastSrc) {\n this.#checkAndLoadAudioWaveform();\n }\n\n if (\n changedProperties.has(\"_timelineState\") ||\n changedProperties.has(\"_waveformData\")\n ) {\n this.#scheduleRender();\n }\n\n // Always schedule render after update\n if (this._waveformData) {\n this.#scheduleRender();\n }\n }\n\n /**\n * Get the total track height based on whether audio is present\n */\n #getTrackHeight(): number {\n if (this._hasAudio && this._waveformData) {\n return THUMBNAIL_HEIGHT + AUDIO_SECTION_HEIGHT;\n }\n return THUMBNAIL_HEIGHT;\n }\n\n override render() {\n const video = this.element as EFVideo;\n const elementId = (this.element as HTMLElement).id || \"\";\n\n // Don't render thumbnail strip until we have a valid EFVideo element\n if (!(video instanceof EFVideo)) {\n return html``;\n }\n const trimStartMs = this.element.trimStartMs ?? 0;\n const trimEndMs = this.element.trimEndMs ?? 0;\n const intrinsicDurationMs =\n this.element.intrinsicDurationMs ?? this.element.durationMs;\n\n const trackHeight = this.#getTrackHeight();\n const hasAudioSection = this._hasAudio && this._waveformData;\n\n const typeColor = this.getElementTypeColor();\n\n return html`<div style=${styleMap(this.gutterStyles)}>\n <div\n ?data-focused=${this.isFocused}\n @mouseenter=${() => {\n if (this.focusContext) {\n this.focusContext.focusedElement = this.element;\n }\n }}\n @mouseleave=${() => {\n if (this.focusContext) {\n this.focusContext.focusedElement = null;\n }\n }}\n >\n <div\n ?data-focused=${this.isFocused}\n class=\"trim-container\"\n style=${styleMap({\n ...this.trimPortionStyles,\n height: `${trackHeight}px`,\n backgroundColor: this.isFocused\n ? \"color-mix(in srgb, var(--ef-color-primary) 25%, transparent)\"\n : \"var(--ef-color-bg-inset)\",\n borderLeft: `3px solid ${typeColor}`,\n borderRadius: \"3px\",\n })}\n >\n <div class=\"video-content\">\n <div class=\"thumbnail-section\">\n <ef-thumbnail-strip\n .targetElement=${this.element}\n thumbnail-height=${THUMBNAIL_HEIGHT}\n thumbnail-spacing-px=\"48\"\n pixels-per-ms=${this.pixelsPerMs}\n ></ef-thumbnail-strip>\n </div>\n ${\n hasAudioSection\n ? html`<div class=\"audio-section\">\n <canvas ${ref(this.audioCanvasRef)} class=\"audio-section-canvas\"></canvas>\n </div>`\n : nothing\n }\n </div>\n ${\n this.enableTrim\n ? html`<ef-trim-handles\n element-id=${elementId}\n pixels-per-ms=${this.pixelsPerMs}\n trim-start-ms=${trimStartMs}\n trim-end-ms=${trimEndMs}\n intrinsic-duration-ms=${intrinsicDurationMs}\n @trim-change=${this.handleTrimChange}\n ></ef-trim-handles>`\n : nothing\n }\n </div>\n </div>\n ${this.renderChildren()}\n </div>`;\n }\n}\n\ndeclare global {\n interface HTMLElementTagNameMap {\n \"ef-video-track\": EFVideoTrack;\n }\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"VideoTrack.js","names":["EFVideoTrack","#checkAndLoadAudioWaveform","#lastSrc","#abortController","#scheduleRender","#renderRequested","#renderAudioOverlay","#drawAudioWaveform","#getTrackHeight"],"sources":["../../../../src/gui/timeline/tracks/VideoTrack.ts"],"sourcesContent":["import { consume } from \"@lit/context\";\nimport { css, html, nothing } from \"lit\";\nimport { customElement, state } from \"lit/decorators.js\";\nimport { createRef, ref } from \"lit/directives/ref.js\";\nimport { styleMap } from \"lit/directives/style-map.js\";\nimport { EFVideo } from \"../../../elements/EFVideo.js\";\n\n// TrackItem must be pre-loaded before this module is imported\n// See preloadTracks.ts for the initialization sequence\nimport { TrackItem } from \"./TrackItem.js\";\nimport { extractWaveformData, type WaveformData } from \"./waveformUtils.js\";\nimport {\n timelineStateContext,\n type TimelineState,\n} from \"../timelineStateContext.js\";\nimport \"./EFThumbnailStrip.js\";\n\n/** Padding for virtual rendering */\nconst VIRTUAL_RENDER_PADDING_PX = 100;\n\n/** Height of thumbnail section */\nconst THUMBNAIL_HEIGHT = 24;\n/** Height of audio section when present */\nconst AUDIO_SECTION_HEIGHT = 14;\n\n@customElement(\"ef-video-track\")\nexport class EFVideoTrack extends TrackItem {\n static override styles = [\n ...TrackItem.styles,\n css`\n .video-content {\n display: flex;\n flex-direction: column;\n height: 100%;\n }\n .thumbnail-section {\n position: relative;\n flex: 0 0 ${THUMBNAIL_HEIGHT}px;\n height: ${THUMBNAIL_HEIGHT}px;\n background: var(--ef-color-bg-inset);\n }\n .audio-section {\n position: relative;\n flex: 0 0 ${AUDIO_SECTION_HEIGHT}px;\n height: ${AUDIO_SECTION_HEIGHT}px;\n background: var(--ef-color-bg-elevated);\n border-top: 1px solid var(--ef-color-border-subtle);\n overflow: hidden;\n }\n .audio-section-canvas {\n position: absolute;\n top: 0;\n height: 100%;\n }\n `,\n ];\n\n audioCanvasRef = createRef<HTMLCanvasElement>();\n\n @consume({ context: timelineStateContext, subscribe: true })\n @state()\n private _timelineState?: TimelineState;\n\n @state()\n private _waveformData: WaveformData | null = null;\n\n @state()\n private _hasAudio = false;\n\n #lastSrc: string | null = null;\n #abortController: AbortController | null = null;\n #renderRequested = false;\n\n /**\n * Check if video has audio and load waveform data\n */\n async #checkAndLoadAudioWaveform(): Promise<void> {\n const video = this.element as EFVideo;\n const src = video?.src;\n\n if (!src || src === this.#lastSrc) {\n return;\n }\n\n this.#lastSrc = src;\n this._hasAudio = false;\n this._waveformData = null;\n\n // Cancel any in-progress load\n this.#abortController?.abort();\n this.#abortController = new AbortController();\n\n try {\n // Wait for media engine to determine if video has audio\n if (video.mediaEngineTask) {\n const mediaEngine = await video.mediaEngineTask.taskComplete;\n if (mediaEngine?.tracks.audio) {\n this._hasAudio = true;\n\n const waveformData = await extractWaveformData(\n video,\n this.#abortController.signal,\n );\n\n if (waveformData) {\n this._waveformData = waveformData;\n this.#scheduleRender();\n }\n }\n }\n } catch (error) {\n if (!(error instanceof DOMException && error.name === \"AbortError\")) {\n // Silently fail - audio overlay is optional\n }\n }\n }\n\n #scheduleRender(): void {\n if (this.#renderRequested) return;\n this.#renderRequested = true;\n\n requestAnimationFrame(() => {\n this.#renderRequested = false;\n this.#renderAudioOverlay();\n });\n }\n\n #renderAudioOverlay(): void {\n const canvas = this.audioCanvasRef.value;\n const waveformData = this._waveformData;\n\n if (!canvas || !waveformData || !this._hasAudio) return;\n\n const video = this.element as EFVideo;\n const durationMs = video.durationMs ?? 0;\n if (durationMs === 0) return;\n\n const pixelsPerMs = this._timelineState?.pixelsPerMs ?? this.pixelsPerMs;\n const trackWidthPx = durationMs * pixelsPerMs;\n const trackStartMs = video.startTimeMs ?? 0;\n const trackStartPx = trackStartMs * pixelsPerMs;\n\n // Get scroll/viewport info\n const scrollLeft = this._timelineState?.viewportScrollLeft ?? 0;\n const viewportWidth = this._timelineState?.viewportWidth ?? 800;\n\n // Calculate visible region\n const visibleLeftPx = scrollLeft - VIRTUAL_RENDER_PADDING_PX;\n const visibleRightPx =\n scrollLeft + viewportWidth + VIRTUAL_RENDER_PADDING_PX;\n const trackEndPx = trackStartPx + trackWidthPx;\n\n // Check visibility\n if (trackEndPx < visibleLeftPx || trackStartPx > visibleRightPx) {\n canvas.style.display = \"none\";\n return;\n }\n canvas.style.display = \"block\";\n\n // Calculate visible portion within track\n const visibleStartInTrack = Math.max(0, visibleLeftPx - trackStartPx);\n const visibleEndInTrack = Math.min(\n trackWidthPx,\n visibleRightPx - trackStartPx,\n );\n const visibleWidthPx = visibleEndInTrack - visibleStartInTrack;\n\n if (visibleWidthPx <= 0) return;\n\n const height = AUDIO_SECTION_HEIGHT;\n const dpr = window.devicePixelRatio || 1;\n\n // Set canvas size\n const targetWidth = Math.ceil(visibleWidthPx * dpr);\n const targetHeight = Math.ceil(height * dpr);\n\n if (canvas.width !== targetWidth || canvas.height !== targetHeight) {\n canvas.width = targetWidth;\n canvas.height = targetHeight;\n }\n\n canvas.style.left = `${visibleStartInTrack}px`;\n canvas.style.width = `${visibleWidthPx}px`;\n\n const ctx = canvas.getContext(\"2d\");\n if (!ctx) return;\n\n ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n ctx.clearRect(0, 0, visibleWidthPx, height);\n\n // Calculate time range to render\n const sourceInMs = video.sourceStartMs ?? 0;\n const timeStartMs = sourceInMs + visibleStartInTrack / pixelsPerMs;\n const timeEndMs = sourceInMs + visibleEndInTrack / pixelsPerMs;\n\n // Draw waveform in dedicated section\n this.#drawAudioWaveform(\n ctx,\n waveformData,\n visibleWidthPx,\n height,\n timeStartMs,\n timeEndMs,\n );\n }\n\n #drawAudioWaveform(\n ctx: CanvasRenderingContext2D,\n waveformData: WaveformData,\n width: number,\n height: number,\n startMs: number,\n endMs: number,\n ): void {\n const { peaks, samplesPerSecond } = waveformData;\n\n const startSample = Math.floor((startMs / 1000) * samplesPerSecond);\n const endSample = Math.ceil((endMs / 1000) * samplesPerSecond);\n const sampleCount = endSample - startSample;\n\n if (sampleCount <= 0 || width <= 0) return;\n\n const centerY = height / 2;\n const halfHeight = height / 2 - 1;\n const pixelsPerSample = width / sampleCount;\n\n // Draw filled waveform\n ctx.fillStyle =\n getComputedStyle(this).getPropertyValue(\"--ef-color-success\").trim() ||\n \"rgb(74, 222, 128)\";\n ctx.globalAlpha = 0.9;\n ctx.beginPath();\n\n // Draw top half (max values) left to right\n for (let i = 0; i <= sampleCount; i++) {\n const sampleIndex = startSample + i;\n const peakIndex = sampleIndex * 2;\n if (peakIndex + 1 >= peaks.length) break;\n\n const maxValue = peaks[peakIndex + 1] ?? 0;\n const px = i * pixelsPerSample;\n const py = centerY - maxValue * halfHeight;\n\n if (i === 0) {\n ctx.moveTo(px, py);\n } else {\n ctx.lineTo(px, py);\n }\n }\n\n // Draw bottom half (min values) right to left\n for (let i = sampleCount; i >= 0; i--) {\n const sampleIndex = startSample + i;\n const peakIndex = sampleIndex * 2;\n if (peakIndex >= peaks.length) continue;\n\n const minValue = peaks[peakIndex] ?? 0;\n const px = i * pixelsPerSample;\n const py = centerY - minValue * halfHeight;\n\n ctx.lineTo(px, py);\n }\n\n ctx.closePath();\n ctx.fill();\n\n // Draw center line\n ctx.globalAlpha = 0.3;\n ctx.strokeStyle =\n getComputedStyle(this).getPropertyValue(\"--ef-color-success\").trim() ||\n \"rgb(74, 222, 128)\";\n ctx.lineWidth = 1;\n ctx.beginPath();\n ctx.moveTo(0, centerY);\n ctx.lineTo(width, centerY);\n ctx.stroke();\n\n ctx.globalAlpha = 1;\n }\n\n connectedCallback(): void {\n super.connectedCallback();\n this.#checkAndLoadAudioWaveform();\n }\n\n disconnectedCallback(): void {\n super.disconnectedCallback();\n this.#abortController?.abort();\n }\n\n updated(changedProperties: Map<string | number | symbol, unknown>): void {\n super.updated(changedProperties);\n\n const video = this.element as EFVideo;\n if (video?.src !== this.#lastSrc) {\n this.#checkAndLoadAudioWaveform();\n }\n\n if (\n changedProperties.has(\"_timelineState\") ||\n changedProperties.has(\"_waveformData\")\n ) {\n this.#scheduleRender();\n }\n\n // Always schedule render after update\n if (this._waveformData) {\n this.#scheduleRender();\n }\n }\n\n /**\n * Get the total track height based on whether audio is present\n */\n #getTrackHeight(): number {\n if (this._hasAudio && this._waveformData) {\n return THUMBNAIL_HEIGHT + AUDIO_SECTION_HEIGHT;\n }\n return THUMBNAIL_HEIGHT;\n }\n\n override render() {\n const video = this.element as EFVideo;\n const elementId = (this.element as HTMLElement).id || \"\";\n\n // Don't render thumbnail strip until we have a valid EFVideo element\n if (!(video instanceof EFVideo)) {\n return html``;\n }\n const trimStartMs = this.element.trimStartMs ?? 0;\n const trimEndMs = this.element.trimEndMs ?? 0;\n const intrinsicDurationMs =\n this.element.intrinsicDurationMs ?? this.element.durationMs;\n\n const trackHeight = this.#getTrackHeight();\n const hasAudioSection = this._hasAudio && this._waveformData;\n\n const typeColor = this.getElementTypeColor();\n\n return html`<div style=${styleMap(this.gutterStyles)}>\n <div\n ?data-focused=${this.isFocused}\n @mouseenter=${() => {\n if (this.focusContext) {\n this.focusContext.focusedElement = this.element;\n }\n }}\n @mouseleave=${() => {\n if (this.focusContext) {\n this.focusContext.focusedElement = null;\n }\n }}\n >\n <div\n ?data-focused=${this.isFocused}\n class=\"trim-container\"\n style=${styleMap({\n ...this.trimPortionStyles,\n height: `${trackHeight}px`,\n backgroundColor: this.isFocused\n ? \"color-mix(in srgb, var(--ef-color-primary) 25%, transparent)\"\n : \"var(--ef-color-bg-inset)\",\n borderLeft: `3px solid ${typeColor}`,\n borderRadius: \"3px\",\n })}\n >\n <div class=\"video-content\">\n <div class=\"thumbnail-section\">\n <ef-thumbnail-strip\n .targetElement=${this.element}\n thumbnail-height=${THUMBNAIL_HEIGHT}\n thumbnail-spacing-px=\"48\"\n pixels-per-ms=${this.pixelsPerMs}\n ></ef-thumbnail-strip>\n </div>\n ${\n hasAudioSection\n ? html`<div class=\"audio-section\">\n <canvas ${ref(this.audioCanvasRef)} class=\"audio-section-canvas\"></canvas>\n </div>`\n : nothing\n }\n </div>\n ${\n this.enableTrim\n ? html`<ef-trim-handles\n element-id=${elementId}\n pixels-per-ms=${this.pixelsPerMs}\n trim-start-ms=${trimStartMs}\n trim-end-ms=${trimEndMs}\n intrinsic-duration-ms=${intrinsicDurationMs}\n @trim-change=${this.handleTrimChange}\n ></ef-trim-handles>`\n : nothing\n }\n </div>\n </div>\n ${this.renderChildren()}\n </div>`;\n }\n}\n\ndeclare global {\n interface HTMLElementTagNameMap {\n \"ef-video-track\": EFVideoTrack;\n }\n}\n"],"mappings":"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"}
|
|
@@ -1,30 +1,30 @@
|
|
|
1
1
|
//#region src/gui/timeline/tracks/waveformUtils.ts
|
|
2
|
-
/**
|
|
3
|
-
* Waveform extraction utilities for DAW-style audio visualization.
|
|
4
|
-
*
|
|
5
|
-
* Extracts min/max peak pairs from audio data at a given resolution.
|
|
6
|
-
* Designed for timeline visualization where we need to see amplitude
|
|
7
|
-
* overview across the entire audio duration.
|
|
8
|
-
*/
|
|
9
2
|
/** Samples per second for waveform data - balances resolution vs. data size */
|
|
10
3
|
const WAVEFORM_SAMPLES_PER_SECOND = 100;
|
|
11
4
|
/** Simple cache for waveform data keyed by audio URL */
|
|
12
5
|
const waveformCache = /* @__PURE__ */ new Map();
|
|
13
6
|
/**
|
|
14
|
-
* Extract waveform peak data from
|
|
15
|
-
*
|
|
16
|
-
*
|
|
7
|
+
* Extract waveform peak data from a media element.
|
|
8
|
+
* Fetches audio through the media engine's transcoding pipeline,
|
|
9
|
+
* then decodes with Web Audio API.
|
|
10
|
+
* Results are cached by src URL.
|
|
17
11
|
*/
|
|
18
|
-
async function extractWaveformData(
|
|
19
|
-
const
|
|
12
|
+
async function extractWaveformData(element, signal) {
|
|
13
|
+
const src = element.src;
|
|
14
|
+
if (!src) return null;
|
|
15
|
+
const cached = waveformCache.get(src);
|
|
20
16
|
if (cached) return cached;
|
|
21
17
|
try {
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
const
|
|
18
|
+
const mediaEngine = await element.getMediaEngine(signal);
|
|
19
|
+
signal?.throwIfAborted();
|
|
20
|
+
if (!mediaEngine?.tracks.audio) return null;
|
|
21
|
+
const durationMs = mediaEngine.durationMs;
|
|
22
|
+
if (!durationMs || durationMs <= 0) return null;
|
|
23
|
+
const abortSignal = signal ?? new AbortController().signal;
|
|
24
|
+
const audioSpan = await element.fetchAudioSpanningTime(0, durationMs, abortSignal);
|
|
25
|
+
signal?.throwIfAborted();
|
|
26
|
+
if (!audioSpan) return null;
|
|
27
|
+
const arrayBuffer = await audioSpan.blob.arrayBuffer();
|
|
28
28
|
signal?.throwIfAborted();
|
|
29
29
|
const audioContext = new OfflineAudioContext(1, 1, 44100);
|
|
30
30
|
let audioBuffer;
|
|
@@ -40,7 +40,7 @@ async function extractWaveformData(audioUrl, signal) {
|
|
|
40
40
|
durationMs: audioBuffer.duration * 1e3,
|
|
41
41
|
samplesPerSecond: WAVEFORM_SAMPLES_PER_SECOND
|
|
42
42
|
};
|
|
43
|
-
waveformCache.set(
|
|
43
|
+
waveformCache.set(src, waveformData);
|
|
44
44
|
return waveformData;
|
|
45
45
|
} catch (error) {
|
|
46
46
|
if (error instanceof DOMException && error.name === "AbortError") throw error;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"waveformUtils.js","names":["audioBuffer: AudioBuffer","waveformData: WaveformData"],"sources":["../../../../src/gui/timeline/tracks/waveformUtils.ts"],"sourcesContent":["/**\n * Waveform extraction utilities for DAW-style audio visualization.\n *\n * Extracts min/max peak pairs from audio data at a given resolution.\n * Designed for timeline visualization where we need to see amplitude\n * overview across the entire audio duration.\n */\n\n/** Samples per second for waveform data - balances resolution vs. data size */\nexport const WAVEFORM_SAMPLES_PER_SECOND = 100;\n\n/** Waveform peak data: alternating min/max values normalized to [-1, 1] */\nexport interface WaveformData {\n /** Peak data: [min0, max0, min1, max1, ...] normalized to [-1, 1] */\n peaks: Float32Array;\n /** Duration of the audio in milliseconds */\n durationMs: number;\n /** Samples per second (for interpreting peaks array) */\n samplesPerSecond: number;\n}\n\n/** Simple cache for waveform data keyed by audio URL */\nconst waveformCache = new Map<string, WaveformData>();\n\n/**\n * Extract waveform peak data from
|
|
1
|
+
{"version":3,"file":"waveformUtils.js","names":["audioBuffer: AudioBuffer","waveformData: WaveformData"],"sources":["../../../../src/gui/timeline/tracks/waveformUtils.ts"],"sourcesContent":["/**\n * Waveform extraction utilities for DAW-style audio visualization.\n *\n * Extracts min/max peak pairs from audio data at a given resolution.\n * Designed for timeline visualization where we need to see amplitude\n * overview across the entire audio duration.\n */\n\nimport type { EFMedia } from \"../../../elements/EFMedia.js\";\n\n/** Samples per second for waveform data - balances resolution vs. data size */\nexport const WAVEFORM_SAMPLES_PER_SECOND = 100;\n\n/** Waveform peak data: alternating min/max values normalized to [-1, 1] */\nexport interface WaveformData {\n /** Peak data: [min0, max0, min1, max1, ...] normalized to [-1, 1] */\n peaks: Float32Array;\n /** Duration of the audio in milliseconds */\n durationMs: number;\n /** Samples per second (for interpreting peaks array) */\n samplesPerSecond: number;\n}\n\n/** Simple cache for waveform data keyed by audio URL */\nconst waveformCache = new Map<string, WaveformData>();\n\n/**\n * Extract waveform peak data from a media element.\n * Fetches audio through the media engine's transcoding pipeline,\n * then decodes with Web Audio API.\n * Results are cached by src URL.\n */\nexport async function extractWaveformData(\n element: EFMedia,\n signal?: AbortSignal,\n): Promise<WaveformData | null> {\n const src = element.src;\n if (!src) return null;\n\n const cached = waveformCache.get(src);\n if (cached) {\n return cached;\n }\n\n try {\n const mediaEngine = await element.getMediaEngine(signal);\n signal?.throwIfAborted();\n\n if (!mediaEngine?.tracks.audio) {\n return null;\n }\n\n const durationMs = mediaEngine.durationMs;\n if (!durationMs || durationMs <= 0) {\n return null;\n }\n\n const abortSignal = signal ?? new AbortController().signal;\n const audioSpan = await element.fetchAudioSpanningTime(\n 0,\n durationMs,\n abortSignal,\n );\n signal?.throwIfAborted();\n\n if (!audioSpan) {\n return null;\n }\n\n const arrayBuffer = await audioSpan.blob.arrayBuffer();\n signal?.throwIfAborted();\n\n // Decode audio data\n const audioContext = new OfflineAudioContext(1, 1, 44100);\n let audioBuffer: AudioBuffer;\n\n try {\n audioBuffer = await audioContext.decodeAudioData(arrayBuffer);\n } catch (decodeError) {\n console.warn(\"Failed to decode audio for waveform:\", decodeError);\n return null;\n }\n\n signal?.throwIfAborted();\n\n // Extract peaks from the decoded audio\n const peaks = extractPeaksFromBuffer(\n audioBuffer,\n WAVEFORM_SAMPLES_PER_SECOND,\n );\n const decodedDurationMs = audioBuffer.duration * 1000;\n\n const waveformData: WaveformData = {\n peaks,\n durationMs: decodedDurationMs,\n samplesPerSecond: WAVEFORM_SAMPLES_PER_SECOND,\n };\n\n waveformCache.set(src, waveformData);\n\n return waveformData;\n } catch (error) {\n if (error instanceof DOMException && error.name === \"AbortError\") {\n throw error;\n }\n console.warn(\"Error extracting waveform data:\", error);\n return null;\n }\n}\n\n/**\n * Extract min/max peaks from an AudioBuffer.\n * Returns Float32Array with alternating [min, max, min, max, ...] values.\n */\nfunction extractPeaksFromBuffer(\n buffer: AudioBuffer,\n samplesPerSecond: number,\n): Float32Array {\n const channelData = buffer.getChannelData(0); // Use first channel\n const sampleRate = buffer.sampleRate;\n const duration = buffer.duration;\n\n // Calculate how many samples to output\n const outputSamples = Math.ceil(duration * samplesPerSecond);\n\n // Each output sample has min and max\n const peaks = new Float32Array(outputSamples * 2);\n\n // Samples per output window\n const samplesPerWindow = Math.floor(sampleRate / samplesPerSecond);\n\n for (let i = 0; i < outputSamples; i++) {\n const startSample = i * samplesPerWindow;\n const endSample = Math.min(\n startSample + samplesPerWindow,\n channelData.length,\n );\n\n let min = 0;\n let max = 0;\n\n for (let j = startSample; j < endSample; j++) {\n const sample = channelData[j] ?? 0;\n if (sample < min) min = sample;\n if (sample > max) max = sample;\n }\n\n // Store as alternating min/max pairs\n peaks[i * 2] = min;\n peaks[i * 2 + 1] = max;\n }\n\n return peaks;\n}\n\n/**\n * Render waveform data to a canvas context.\n * Draws a filled waveform path centered vertically.\n */\nexport function renderWaveformToCanvas(\n ctx: CanvasRenderingContext2D,\n waveformData: WaveformData,\n x: number,\n y: number,\n width: number,\n height: number,\n color: string,\n startMs: number = 0,\n endMs?: number,\n): void {\n const { peaks, durationMs, samplesPerSecond } = waveformData;\n const actualEndMs = endMs ?? durationMs;\n\n // Calculate which samples to render\n const startSample = Math.floor((startMs / 1000) * samplesPerSecond);\n const endSample = Math.ceil((actualEndMs / 1000) * samplesPerSecond);\n const sampleCount = endSample - startSample;\n\n if (sampleCount <= 0) return;\n\n const centerY = y + height / 2;\n const halfHeight = height / 2;\n const pixelsPerSample = width / sampleCount;\n\n ctx.fillStyle = color;\n ctx.beginPath();\n\n // Draw top half (max values) left to right\n for (let i = 0; i < sampleCount; i++) {\n const sampleIndex = startSample + i;\n const peakIndex = sampleIndex * 2;\n const maxValue = peaks[peakIndex + 1] ?? 0;\n\n const px = x + i * pixelsPerSample;\n const py = centerY - maxValue * halfHeight;\n\n if (i === 0) {\n ctx.moveTo(px, py);\n } else {\n ctx.lineTo(px, py);\n }\n }\n\n // Draw bottom half (min values) right to left\n for (let i = sampleCount - 1; i >= 0; i--) {\n const sampleIndex = startSample + i;\n const peakIndex = sampleIndex * 2;\n const minValue = peaks[peakIndex] ?? 0;\n\n const px = x + i * pixelsPerSample;\n const py = centerY - minValue * halfHeight;\n\n ctx.lineTo(px, py);\n }\n\n ctx.closePath();\n ctx.fill();\n}\n\n/**\n * Clear waveform cache (useful for testing or memory management)\n */\nexport function clearWaveformCache(): void {\n waveformCache.clear();\n}\n"],"mappings":";;AAWA,MAAa,8BAA8B;;AAa3C,MAAM,gCAAgB,IAAI,KAA2B;;;;;;;AAQrD,eAAsB,oBACpB,SACA,QAC8B;CAC9B,MAAM,MAAM,QAAQ;AACpB,KAAI,CAAC,IAAK,QAAO;CAEjB,MAAM,SAAS,cAAc,IAAI,IAAI;AACrC,KAAI,OACF,QAAO;AAGT,KAAI;EACF,MAAM,cAAc,MAAM,QAAQ,eAAe,OAAO;AACxD,UAAQ,gBAAgB;AAExB,MAAI,CAAC,aAAa,OAAO,MACvB,QAAO;EAGT,MAAM,aAAa,YAAY;AAC/B,MAAI,CAAC,cAAc,cAAc,EAC/B,QAAO;EAGT,MAAM,cAAc,UAAU,IAAI,iBAAiB,CAAC;EACpD,MAAM,YAAY,MAAM,QAAQ,uBAC9B,GACA,YACA,YACD;AACD,UAAQ,gBAAgB;AAExB,MAAI,CAAC,UACH,QAAO;EAGT,MAAM,cAAc,MAAM,UAAU,KAAK,aAAa;AACtD,UAAQ,gBAAgB;EAGxB,MAAM,eAAe,IAAI,oBAAoB,GAAG,GAAG,MAAM;EACzD,IAAIA;AAEJ,MAAI;AACF,iBAAc,MAAM,aAAa,gBAAgB,YAAY;WACtD,aAAa;AACpB,WAAQ,KAAK,wCAAwC,YAAY;AACjE,UAAO;;AAGT,UAAQ,gBAAgB;EASxB,MAAMC,eAA6B;GACjC,OAPY,uBACZ,aACA,4BACD;GAKC,YAJwB,YAAY,WAAW;GAK/C,kBAAkB;GACnB;AAED,gBAAc,IAAI,KAAK,aAAa;AAEpC,SAAO;UACA,OAAO;AACd,MAAI,iBAAiB,gBAAgB,MAAM,SAAS,aAClD,OAAM;AAER,UAAQ,KAAK,mCAAmC,MAAM;AACtD,SAAO;;;;;;;AAQX,SAAS,uBACP,QACA,kBACc;CACd,MAAM,cAAc,OAAO,eAAe,EAAE;CAC5C,MAAM,aAAa,OAAO;CAC1B,MAAM,WAAW,OAAO;CAGxB,MAAM,gBAAgB,KAAK,KAAK,WAAW,iBAAiB;CAG5D,MAAM,QAAQ,IAAI,aAAa,gBAAgB,EAAE;CAGjD,MAAM,mBAAmB,KAAK,MAAM,aAAa,iBAAiB;AAElE,MAAK,IAAI,IAAI,GAAG,IAAI,eAAe,KAAK;EACtC,MAAM,cAAc,IAAI;EACxB,MAAM,YAAY,KAAK,IACrB,cAAc,kBACd,YAAY,OACb;EAED,IAAI,MAAM;EACV,IAAI,MAAM;AAEV,OAAK,IAAI,IAAI,aAAa,IAAI,WAAW,KAAK;GAC5C,MAAM,SAAS,YAAY,MAAM;AACjC,OAAI,SAAS,IAAK,OAAM;AACxB,OAAI,SAAS,IAAK,OAAM;;AAI1B,QAAM,IAAI,KAAK;AACf,QAAM,IAAI,IAAI,KAAK;;AAGrB,QAAO"}
|
|
@@ -58,6 +58,14 @@ declare class QualityUpgradeScheduler {
|
|
|
58
58
|
* Removes queued tasks. Does NOT abort in-flight fetches.
|
|
59
59
|
*/
|
|
60
60
|
cancelForOwner(owner: string): void;
|
|
61
|
+
/**
|
|
62
|
+
* Check whether a task is currently in-flight (started, not yet complete).
|
|
63
|
+
*/
|
|
64
|
+
isActive(key: string): boolean;
|
|
65
|
+
/**
|
|
66
|
+
* Check whether a task is waiting in the queue (submitted but not yet started).
|
|
67
|
+
*/
|
|
68
|
+
isPending(key: string): boolean;
|
|
61
69
|
/**
|
|
62
70
|
* Get snapshot of current queue state for debugging.
|
|
63
71
|
*/
|
|
@@ -35,7 +35,7 @@ var QualityUpgradeScheduler = class {
|
|
|
35
35
|
if (this.#abortController.signal.aborted) return;
|
|
36
36
|
this.#queue = this.#queue.filter((t) => t.owner !== owner);
|
|
37
37
|
for (const task of tasks) {
|
|
38
|
-
if (this.#activeTasks.has(task.key)
|
|
38
|
+
if (this.#activeTasks.has(task.key)) continue;
|
|
39
39
|
this.#queue.push(task);
|
|
40
40
|
}
|
|
41
41
|
this.#queue.sort((a, b) => a.deadlineMs - b.deadlineMs);
|
|
@@ -98,6 +98,18 @@ var QualityUpgradeScheduler = class {
|
|
|
98
98
|
});
|
|
99
99
|
}
|
|
100
100
|
/**
|
|
101
|
+
* Check whether a task is currently in-flight (started, not yet complete).
|
|
102
|
+
*/
|
|
103
|
+
isActive(key) {
|
|
104
|
+
return this.#activeTasks.has(key);
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Check whether a task is waiting in the queue (submitted but not yet started).
|
|
108
|
+
*/
|
|
109
|
+
isPending(key) {
|
|
110
|
+
return this.#queue.some((t) => t.key === key);
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
101
113
|
* Get snapshot of current queue state for debugging.
|
|
102
114
|
*/
|
|
103
115
|
getQueueSnapshot() {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"QualityUpgradeScheduler.js","names":["#requestFrameRender","#maxConcurrent","#isCached","#abortController","#queue","#activeTasks","#completedTasks","#processQueue","#startTask","results: UpgradeTaskStatus[]"],"sources":["../../src/preview/QualityUpgradeScheduler.ts"],"sourcesContent":["/**\n * QualityUpgradeScheduler: Centralized deadline-ordered work queue\n *\n * Coordinates main-quality segment fetching across multiple video elements.\n * Generic scheduler that doesn't understand media concepts (segments, renditions, etc.)\n * - only processes { key, deadlineMs, fetch, owner } tuples.\n *\n * Design principles:\n * - Deadline-based ordering: always process nearest deadline first\n * - Ground-truth cache validation: check cache before starting any fetch\n * - In-flight fetches never cancelled: they populate shared cache\n * - Event-driven: elements submit tasks only on state changes, not every frame\n */\n\nexport interface UpgradeTask {\n /** Opaque dedup key (e.g. \"${owner}:${segmentId}:${renditionId}\") */\n key: string;\n /** Fetch function that populates the cache */\n fetch: (signal: AbortSignal) => Promise<void>;\n /** Timeline time when this segment will be needed */\n deadlineMs: number;\n /** Element ID, for bulk operations */\n owner: string;\n}\n\nexport interface UpgradeTaskStatus {\n key: string;\n owner: string;\n deadlineMs: number;\n status: \"queued\" | \"active\" | \"completed\" | \"failed\";\n error?: string;\n}\n\nexport interface OwnerProgress {\n queued: number;\n active: number;\n completed: number;\n failed: number;\n}\n\ninterface ActiveTask {\n task: UpgradeTask;\n startedAt: number;\n promise: Promise<void>;\n}\n\ninterface CompletedTask {\n key: string;\n owner: string;\n status: \"completed\" | \"failed\";\n error?: string;\n}\n\nexport class QualityUpgradeScheduler {\n #maxConcurrent: number;\n #queue: UpgradeTask[] = [];\n #activeTasks = new Map<string, ActiveTask>();\n #completedTasks = new Map<string, CompletedTask>();\n #abortController: AbortController;\n #requestFrameRender: () => void;\n #isCached?: (key: string) => boolean;\n\n constructor(options: {\n requestFrameRender: () => void;\n maxConcurrent?: number;\n isCached?: (key: string) => boolean;\n }) {\n this.#requestFrameRender = options.requestFrameRender;\n this.#maxConcurrent = options.maxConcurrent ?? 4;\n this.#isCached = options.isCached;\n this.#abortController = new AbortController();\n }\n\n /**\n * Add tasks without affecting existing ones (additive).\n * Used for lookahead extension during playback.\n */\n enqueue(tasks: UpgradeTask[]): void {\n if (this.#abortController.signal.aborted) return;\n\n for (const task of tasks) {\n // Skip if already queued, active, or completed\n if (\n this.#queue.some((t) => t.key === task.key) ||\n this.#activeTasks.has(task.key) ||\n this.#completedTasks.has(task.key)\n ) {\n continue;\n }\n\n this.#queue.push(task);\n }\n\n // Sort queue by deadline (ascending)\n this.#queue.sort((a, b) => a.deadlineMs - b.deadlineMs);\n\n // Start processing if we have capacity\n this.#processQueue();\n }\n\n /**\n * Replace all queued tasks for an owner.\n * Used on seeks, trim changes, timeline position changes where old deadlines are stale.\n * Does NOT cancel in-flight tasks (they populate shared cache).\n */\n replaceForOwner(owner: string, tasks: UpgradeTask[]): void {\n if (this.#abortController.signal.aborted) return;\n\n // Remove queued (not active) tasks for this owner\n this.#queue = this.#queue.filter((t) => t.owner !== owner);\n\n // Add new tasks\n for (const task of tasks) {\n // Skip if already active or completed\n if (\n this.#activeTasks.has(task.key) ||\n this.#completedTasks.has(task.key)\n ) {\n continue;\n }\n\n this.#queue.push(task);\n }\n\n // Sort queue by deadline (ascending)\n this.#queue.sort((a, b) => a.deadlineMs - b.deadlineMs);\n\n // Start processing if we have capacity\n this.#processQueue();\n }\n\n /**\n * Cancel all tasks for an owner.\n * Removes queued tasks. Does NOT abort in-flight fetches.\n */\n cancelForOwner(owner: string): void {\n // Remove from queue\n this.#queue = this.#queue.filter((t) => t.owner !== owner);\n\n // Remove from completed tracking (allows resubmission)\n for (const [key, task] of this.#completedTasks.entries()) {\n if (task.owner === owner) {\n this.#completedTasks.delete(key);\n }\n }\n\n // Note: we do NOT cancel active tasks - they populate the shared cache\n }\n\n /**\n * Process the queue - start tasks up to maxConcurrent limit.\n */\n #processQueue(): void {\n if (this.#abortController.signal.aborted) return;\n\n while (\n this.#activeTasks.size < this.#maxConcurrent &&\n this.#queue.length > 0\n ) {\n const task = this.#queue.shift();\n if (!task) break;\n\n // Ground-truth cache check before starting\n if (this.#isCached?.(task.key)) {\n // Already cached from another path, mark as completed and continue\n this.#completedTasks.set(task.key, {\n key: task.key,\n owner: task.owner,\n status: \"completed\",\n });\n continue;\n }\n\n // Start the task\n this.#startTask(task);\n }\n }\n\n /**\n * Start a single task.\n */\n #startTask(task: UpgradeTask): void {\n const promise = task\n .fetch(this.#abortController.signal)\n .then(() => {\n // Success\n this.#activeTasks.delete(task.key);\n this.#completedTasks.set(task.key, {\n key: task.key,\n owner: task.owner,\n status: \"completed\",\n });\n\n // Trigger re-render so upgraded quality gets displayed\n this.#requestFrameRender();\n\n // Start next task if available\n this.#processQueue();\n })\n .catch((error) => {\n // Failure\n this.#activeTasks.delete(task.key);\n\n // Don't track AbortError as failure (intentional cancellation)\n const isAbortError =\n error instanceof DOMException && error.name === \"AbortError\";\n\n if (!isAbortError) {\n this.#completedTasks.set(task.key, {\n key: task.key,\n owner: task.owner,\n status: \"failed\",\n error: error instanceof Error ? error.message : String(error),\n });\n }\n\n // Continue processing queue even after failure\n this.#processQueue();\n });\n\n this.#activeTasks.set(task.key, {\n task,\n startedAt: performance.now(),\n promise,\n });\n }\n\n /**\n * Get snapshot of current queue state for debugging.\n */\n getQueueSnapshot(): UpgradeTaskStatus[] {\n const results: UpgradeTaskStatus[] = [];\n\n // Queued tasks\n for (const task of this.#queue) {\n results.push({\n key: task.key,\n owner: task.owner,\n deadlineMs: task.deadlineMs,\n status: \"queued\",\n });\n }\n\n // Active tasks\n for (const [key, activeTask] of this.#activeTasks.entries()) {\n results.push({\n key,\n owner: activeTask.task.owner,\n deadlineMs: activeTask.task.deadlineMs,\n status: \"active\",\n });\n }\n\n // Completed tasks\n for (const [key, completed] of this.#completedTasks.entries()) {\n results.push({\n key,\n owner: completed.owner,\n deadlineMs: 0, // No longer relevant\n status: completed.status as \"completed\" | \"failed\",\n error: completed.error,\n });\n }\n\n return results;\n }\n\n /**\n * Get progress for a specific owner.\n */\n getOwnerProgress(owner: string): OwnerProgress {\n const queued = this.#queue.filter((t) => t.owner === owner).length;\n\n let active = 0;\n for (const activeTask of this.#activeTasks.values()) {\n if (activeTask.task.owner === owner) {\n active++;\n }\n }\n\n let completed = 0;\n let failed = 0;\n for (const task of this.#completedTasks.values()) {\n if (task.owner === owner) {\n if (task.status === \"completed\") {\n completed++;\n } else {\n failed++;\n }\n }\n }\n\n return { queued, active, completed, failed };\n }\n\n /**\n * Dispose the scheduler - abort all in-flight work.\n */\n dispose(): void {\n // Suppress in-flight task rejections before aborting to avoid unhandled\n // rejection events from the synchronous abort signal firing.\n for (const activeTask of this.#activeTasks.values()) {\n activeTask.promise.catch(() => {});\n }\n this.#abortController.abort();\n this.#queue = [];\n this.#activeTasks.clear();\n this.#completedTasks.clear();\n }\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"QualityUpgradeScheduler.js","names":["#requestFrameRender","#maxConcurrent","#isCached","#abortController","#queue","#activeTasks","#completedTasks","#processQueue","#startTask","results: UpgradeTaskStatus[]"],"sources":["../../src/preview/QualityUpgradeScheduler.ts"],"sourcesContent":["/**\n * QualityUpgradeScheduler: Centralized deadline-ordered work queue\n *\n * Coordinates main-quality segment fetching across multiple video elements.\n * Generic scheduler that doesn't understand media concepts (segments, renditions, etc.)\n * - only processes { key, deadlineMs, fetch, owner } tuples.\n *\n * Design principles:\n * - Deadline-based ordering: always process nearest deadline first\n * - Ground-truth cache validation: check cache before starting any fetch\n * - In-flight fetches never cancelled: they populate shared cache\n * - Event-driven: elements submit tasks only on state changes, not every frame\n */\n\nexport interface UpgradeTask {\n /** Opaque dedup key (e.g. \"${owner}:${segmentId}:${renditionId}\") */\n key: string;\n /** Fetch function that populates the cache */\n fetch: (signal: AbortSignal) => Promise<void>;\n /** Timeline time when this segment will be needed */\n deadlineMs: number;\n /** Element ID, for bulk operations */\n owner: string;\n}\n\nexport interface UpgradeTaskStatus {\n key: string;\n owner: string;\n deadlineMs: number;\n status: \"queued\" | \"active\" | \"completed\" | \"failed\";\n error?: string;\n}\n\nexport interface OwnerProgress {\n queued: number;\n active: number;\n completed: number;\n failed: number;\n}\n\ninterface ActiveTask {\n task: UpgradeTask;\n startedAt: number;\n promise: Promise<void>;\n}\n\ninterface CompletedTask {\n key: string;\n owner: string;\n status: \"completed\" | \"failed\";\n error?: string;\n}\n\nexport class QualityUpgradeScheduler {\n #maxConcurrent: number;\n #queue: UpgradeTask[] = [];\n #activeTasks = new Map<string, ActiveTask>();\n #completedTasks = new Map<string, CompletedTask>();\n #abortController: AbortController;\n #requestFrameRender: () => void;\n #isCached?: (key: string) => boolean;\n\n constructor(options: {\n requestFrameRender: () => void;\n maxConcurrent?: number;\n isCached?: (key: string) => boolean;\n }) {\n this.#requestFrameRender = options.requestFrameRender;\n this.#maxConcurrent = options.maxConcurrent ?? 4;\n this.#isCached = options.isCached;\n this.#abortController = new AbortController();\n }\n\n /**\n * Add tasks without affecting existing ones (additive).\n * Used for lookahead extension during playback.\n */\n enqueue(tasks: UpgradeTask[]): void {\n if (this.#abortController.signal.aborted) return;\n\n for (const task of tasks) {\n // Skip if already queued, active, or completed\n if (\n this.#queue.some((t) => t.key === task.key) ||\n this.#activeTasks.has(task.key) ||\n this.#completedTasks.has(task.key)\n ) {\n continue;\n }\n\n this.#queue.push(task);\n }\n\n // Sort queue by deadline (ascending)\n this.#queue.sort((a, b) => a.deadlineMs - b.deadlineMs);\n\n // Start processing if we have capacity\n this.#processQueue();\n }\n\n /**\n * Replace all queued tasks for an owner.\n * Used on seeks, trim changes, timeline position changes where old deadlines are stale.\n * Does NOT cancel in-flight tasks (they populate shared cache).\n */\n replaceForOwner(owner: string, tasks: UpgradeTask[]): void {\n if (this.#abortController.signal.aborted) return;\n\n // Remove queued (not active) tasks for this owner\n this.#queue = this.#queue.filter((t) => t.owner !== owner);\n\n // Add new tasks\n for (const task of tasks) {\n // Skip only if the fetch is already in-flight — it will populate the\n // cache when it completes. Completed tasks are intentionally NOT skipped\n // here so that cache eviction is handled correctly: if a segment was\n // previously fetched but has since been evicted from the LRU cache,\n // #computeLookaheadSegments will include it again and it must re-run.\n if (this.#activeTasks.has(task.key)) {\n continue;\n }\n\n this.#queue.push(task);\n }\n\n // Sort queue by deadline (ascending)\n this.#queue.sort((a, b) => a.deadlineMs - b.deadlineMs);\n\n // Start processing if we have capacity\n this.#processQueue();\n }\n\n /**\n * Cancel all tasks for an owner.\n * Removes queued tasks. Does NOT abort in-flight fetches.\n */\n cancelForOwner(owner: string): void {\n // Remove from queue\n this.#queue = this.#queue.filter((t) => t.owner !== owner);\n\n // Remove from completed tracking (allows resubmission)\n for (const [key, task] of this.#completedTasks.entries()) {\n if (task.owner === owner) {\n this.#completedTasks.delete(key);\n }\n }\n\n // Note: we do NOT cancel active tasks - they populate the shared cache\n }\n\n /**\n * Process the queue - start tasks up to maxConcurrent limit.\n */\n #processQueue(): void {\n if (this.#abortController.signal.aborted) return;\n\n while (\n this.#activeTasks.size < this.#maxConcurrent &&\n this.#queue.length > 0\n ) {\n const task = this.#queue.shift();\n if (!task) break;\n\n // Ground-truth cache check before starting\n if (this.#isCached?.(task.key)) {\n // Already cached from another path, mark as completed and continue\n this.#completedTasks.set(task.key, {\n key: task.key,\n owner: task.owner,\n status: \"completed\",\n });\n continue;\n }\n\n // Start the task\n this.#startTask(task);\n }\n }\n\n /**\n * Start a single task.\n */\n #startTask(task: UpgradeTask): void {\n const promise = task\n .fetch(this.#abortController.signal)\n .then(() => {\n // Success\n this.#activeTasks.delete(task.key);\n this.#completedTasks.set(task.key, {\n key: task.key,\n owner: task.owner,\n status: \"completed\",\n });\n\n // Trigger re-render so upgraded quality gets displayed\n this.#requestFrameRender();\n\n // Start next task if available\n this.#processQueue();\n })\n .catch((error) => {\n // Failure\n this.#activeTasks.delete(task.key);\n\n // Don't track AbortError as failure (intentional cancellation)\n const isAbortError =\n error instanceof DOMException && error.name === \"AbortError\";\n\n if (!isAbortError) {\n this.#completedTasks.set(task.key, {\n key: task.key,\n owner: task.owner,\n status: \"failed\",\n error: error instanceof Error ? error.message : String(error),\n });\n }\n\n // Continue processing queue even after failure\n this.#processQueue();\n });\n\n this.#activeTasks.set(task.key, {\n task,\n startedAt: performance.now(),\n promise,\n });\n }\n\n /**\n * Check whether a task is currently in-flight (started, not yet complete).\n */\n isActive(key: string): boolean {\n return this.#activeTasks.has(key);\n }\n\n /**\n * Check whether a task is waiting in the queue (submitted but not yet started).\n */\n isPending(key: string): boolean {\n return this.#queue.some((t) => t.key === key);\n }\n\n /**\n * Get snapshot of current queue state for debugging.\n */\n getQueueSnapshot(): UpgradeTaskStatus[] {\n const results: UpgradeTaskStatus[] = [];\n\n // Queued tasks\n for (const task of this.#queue) {\n results.push({\n key: task.key,\n owner: task.owner,\n deadlineMs: task.deadlineMs,\n status: \"queued\",\n });\n }\n\n // Active tasks\n for (const [key, activeTask] of this.#activeTasks.entries()) {\n results.push({\n key,\n owner: activeTask.task.owner,\n deadlineMs: activeTask.task.deadlineMs,\n status: \"active\",\n });\n }\n\n // Completed tasks\n for (const [key, completed] of this.#completedTasks.entries()) {\n results.push({\n key,\n owner: completed.owner,\n deadlineMs: 0, // No longer relevant\n status: completed.status as \"completed\" | \"failed\",\n error: completed.error,\n });\n }\n\n return results;\n }\n\n /**\n * Get progress for a specific owner.\n */\n getOwnerProgress(owner: string): OwnerProgress {\n const queued = this.#queue.filter((t) => t.owner === owner).length;\n\n let active = 0;\n for (const activeTask of this.#activeTasks.values()) {\n if (activeTask.task.owner === owner) {\n active++;\n }\n }\n\n let completed = 0;\n let failed = 0;\n for (const task of this.#completedTasks.values()) {\n if (task.owner === owner) {\n if (task.status === \"completed\") {\n completed++;\n } else {\n failed++;\n }\n }\n }\n\n return { queued, active, completed, failed };\n }\n\n /**\n * Dispose the scheduler - abort all in-flight work.\n */\n dispose(): void {\n // Suppress in-flight task rejections before aborting to avoid unhandled\n // rejection events from the synchronous abort signal firing.\n for (const activeTask of this.#activeTasks.values()) {\n activeTask.promise.catch(() => {});\n }\n this.#abortController.abort();\n this.#queue = [];\n this.#activeTasks.clear();\n this.#completedTasks.clear();\n }\n}\n"],"mappings":"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"}
|
|
@@ -167,7 +167,7 @@ async function renderTimegroupToVideo(timegroup, options = {}) {
|
|
|
167
167
|
if (signal?.aborted) throw new RenderCancelledError();
|
|
168
168
|
};
|
|
169
169
|
resetRenderState();
|
|
170
|
-
const { clone: renderClone, cleanup: cleanupRenderClone } = await timegroup.createRenderClone();
|
|
170
|
+
const { clone: renderClone, container: cloneContainer, cleanup: cleanupRenderClone } = await timegroup.createRenderClone();
|
|
171
171
|
const timestamps = [];
|
|
172
172
|
for (let i = 0; i < config.totalFrames; i++) timestamps.push(config.startMs + i * config.frameDurationMs);
|
|
173
173
|
let output = null;
|
|
@@ -238,7 +238,7 @@ async function renderTimegroupToVideo(timegroup, options = {}) {
|
|
|
238
238
|
background: getComputedStyle(timegroup).background || "#000"
|
|
239
239
|
});
|
|
240
240
|
logger.debug(`[renderTimegroupToVideo] Using direct timeline serialization`);
|
|
241
|
-
previewContainer.appendChild(
|
|
241
|
+
previewContainer.appendChild(cloneContainer);
|
|
242
242
|
previewContainer.classList.add("ef-render-clone-container");
|
|
243
243
|
previewContainer.style.cssText += ";position:fixed;left:-99999px;top:-99999px;pointer-events:none;";
|
|
244
244
|
document.body.appendChild(previewContainer);
|
|
@@ -355,8 +355,8 @@ async function renderTimegroupToVideo(timegroup, options = {}) {
|
|
|
355
355
|
}
|
|
356
356
|
} finally {
|
|
357
357
|
renderContext.dispose();
|
|
358
|
-
cleanupRenderClone();
|
|
359
358
|
if (previewContainer.parentNode) previewContainer.parentNode.removeChild(previewContainer);
|
|
359
|
+
cleanupRenderClone();
|
|
360
360
|
}
|
|
361
361
|
}
|
|
362
362
|
|