/** * CanvasRenderer - Renders VideoFrames to canvas with frame-perfect timing * Uses a frame queue and presentation loop for smooth 60Hz playback */ import type { SubtitleCue } from "../types"; /** * A snapshot of the live 360° camera + projection, enough to reproject an * equirectangular frame to exactly what the user currently sees. Consumed by * the thumbnail/preview renderer so seek-bar previews match the on-screen view. */ export interface VRView { yaw: number; pitch: number; fov: number; aspect: number; half: boolean; fisheye: boolean; sbs: boolean; stereographic: boolean; texAspect: number; } export declare class CanvasRenderer { private canvas; private gl; private program; private texture; private vao; private width; private height; private colorSpace; private hasNativeHDRSupport; private frameQueue; private static readonly MAX_FRAME_QUEUE; private hdrEnabled; private isHDRSource; private isHighBitDepth; private _loggedFrameFormat; private static readonly AMBIENT_SIZE; private ambientFbo; private ambientTex; private ambientEnabled; private ambientPixels; private rafId; private isPlaying; private isVideoConfigured; private _maxDpr; private _paintSamples; private _adaptDprChecked; private static readonly PAINT_SAMPLE_COUNT; private static readonly PAINT_THRESHOLD_MS; private getAudioTime; private _isAudioHealthy; private presentationStartTime; private presentationStartPts; private lastPresentedPts; private syncedToAudio; private lastKnownAudioTime; private playbackRate; private justSeeked; private framesPresented; private currentTime; private videoFrameRate; private rotation; private metadataRotation; private manualRotation; private containerWidth; private containerHeight; private fitMode; private letterboxColor; private letterboxTarget; private vr360Enabled; private vrProgram; private vrLocs; private vrHalf; private vrFisheye; private vrStereoSbs; private vrStereographic; private static readonly VR_FISHEYE_FOV; private static readonly VR_PLANET_SCALE; private vrTexAspect; private vrYaw; private vrPitch; private vrFov; private vrYawTarget; private vrPitchTarget; private vrFovTarget; private vrYawVel; private vrPitchVel; private vrAnimRaf; private static readonly VR_STIFFNESS; private static readonly VR_DAMPING; private static readonly VR_FOV_LERP; private static readonly VR_DEFAULT_FOV; private static readonly VR_MIN_FOV; private static readonly VR_MAX_FOV; private activeSubtitleCue; private _lastRenderedSubtitleKey; private _lastRenderedSubtitlePlain; private _subtitleMeasureCanvas; private _subtitleFontCache; private _subtitleRerenderRafId; private subtitleCues; private subtitleOverlay; private subtitleControlsPadding; private subtitleDelay; private currentScaleX; private currentScaleY; private lastTargetScaleX; private lastTargetScaleY; private fitAnimRafId; /** * We must retain a clone of the last rendered frame because: * 1. resizing the canvas clears it (black screen) * 2. if paused, frameQueue is likely empty, so we have nothing to redraw * 3. we need to redraw the *current* image to restore the view */ private lastRenderedFrame; constructor(canvas: HTMLCanvasElement | OffscreenCanvas, subtitleOverlay?: HTMLElement); private detectHDRColorSpace; /** * Configure renderer dimensions and color space for HDR support */ configure(width: number, height: number, colorPrimaries?: string, colorTransfer?: string, frameRate?: number, rotation?: number, isHDR?: boolean, pixelFormat?: string): void; private initWebGL; /** * Original simple WebGL initialization for Chromium (native HDR support) * This is the exact original configuration that works best for Chromium browsers */ private initWebGLSimple; /** * WebGL initialization with HDR tone mapping shader for non-Chromium browsers * Safari/Firefox need explicit PQ decoding and tone mapping */ private initWebGLWithHDR; /** * Compile the 360° VR program lazily on first use. Reuses the existing * fullscreen-quad VAO (location 0 = a_position spans the clip-space quad) * and the existing video texture; only this program + its camera uniforms * are new. The fragment shader reconstructs a view ray per pixel from the * NDC position + camera yaw/pitch/fov, then maps that direction to an * equirectangular (longitude/latitude) texture coordinate. */ private initVRProgram; /** * Render one frame as a viewed sphere. The texture has already been bound + * uploaded by drawFrame; here we only set the full-canvas viewport, switch * to the VR program, push the camera uniforms and draw the fullscreen quad. * Full 360° wraps horizontally (WRAP_S = REPEAT for a seamless ±180° seam); * VR180 covers a single front hemisphere, so it clamps and clips to black. */ private drawVRFrame; /** * Shared adaptive-DPR paint sampling. Called from both the flat and VR draw * paths so 360 playback also benefits from the 2x→1x backbuffer downgrade on * GPU-bound devices (360 raycasting is fragment-heavy at 4K). */ private sampleAdaptiveDpr; /** Turn equirectangular 360° rendering on/off. The intent is stored * unconditionally; the VR program is compiled when GL is ready — which may * be NOW (toggled during playback) or later (the `vr` attribute requests 360 * before the first frame/poster has configured the context). configure() and * drawFrame both compile lazily, so an early enable still paints the poster * in 360. Repaints immediately when paused so the toggle is visible. */ setVR360(enabled: boolean): void; isVR360Enabled(): boolean; /** * The currently-displayed decoded VideoFrame (or null). Used as a fallback * capture source when reading the WebGL canvas back via toDataURL comes out * blank — some GPUs return an all-black buffer for hardware-decoded frames * even with preserveDrawingBuffer. The frame is owned by the renderer and may * be closed on the next present, so consume it synchronously. */ getCurrentFrame(): VideoFrame | null; /** * Snapshot the live 360° camera + projection so a thumbnail/preview can be * reprojected to exactly what the user currently sees. Returns the CURRENT * (rendered, post-spring) yaw/pitch/fov — i.e. the on-screen view, not the * drag target. Null when 360 is off. */ getVRView(): VRView | null; /** Choose the VR projection/layout: * - half: false = full 360° equirectangular, true = front-hemisphere (VR180). * - fisheye: true = equidistant fisheye instead of equirectangular. * - stereoSbs: true = side-by-side stereo (render the left eye only). * drawVRFrame reads these. */ setVRProjection(half: boolean, fisheye?: boolean, stereoSbs?: boolean, stereographic?: boolean): void; /** * Keep the VR180 camera inside the content so no black void is ever shown. * The viewport's half-FOV (vertical from u_fov, horizontal via the canvas * aspect) is subtracted from the content's angular half-extents to get the * yaw/pitch limits, and zoom-out is capped so the viewport can't grow past * the content vertically. No-op for full 360° (it covers everything). */ private clampVRCamera; /** Pan the camera by a pointer drag. dx/dy are CSS pixels; viewportPx is the * canvas CSS height, so pan speed scales with the current zoom (FOV). Moves * the *target* — the spring eases the rendered view toward it. */ nudgeVR360(dx: number, dy: number, viewportPx: number): void; /** Zoom by adjusting FOV. delta>0 zooms out (e.g. wheel deltaY). Eases. */ zoomVR360(delta: number): void; /** Recentre the camera (yaw/pitch 0, default FOV) — animated, not a snap. */ resetVRView(): void; /** * Advance the camera one tick toward its target: an underdamped spring for * yaw/pitch (soft settle/bounce) and a linear ease for FOV. Returns true once * everything has effectively converged. */ private stepVRCamera; /** * Drive the camera spring on its own rAF loop while it's unsettled. Steps at * a fixed 60Hz dt so the feel is frame-rate independent. During playback the * presentation loop already repaints each frame (reading the stepped current * values), so this loop only repaints when paused — it never double-draws. */ private ensureVRAnimating; /** Repaint the retained frame once (e.g. on toggle) so the change is visible * immediately even while paused. Continuous animation goes through the * spring loop (ensureVRAnimating). */ private redrawForVR; /** * Draw a still image (custom or postertime-generated poster) to the canvas * through the normal frame path, so 360° mode projects it like a video frame. * A `poster` URL makes the player skip the initial decode, so without this the * canvas stays blank in 360 and only the flat overlay shows. Retained as * lastRenderedFrame so resize and camera nudges repaint it. */ renderPosterImage(image: CanvasImageSource): void; private lastPrimaries?; private lastTransfer?; /** * Set HDR enabled state */ setHDREnabled(enabled: boolean): void; /** * Check if the current video source supports HDR */ isHDRSupported(): boolean; resize(width: number, height: number, fromRotate?: boolean): void; /** * Set letterbox/pillarbox color (for ambient background effect). * Color is applied on the next frame draw via clearColor. */ setLetterboxColor(r: number, g: number, b: number): void; /** * Set fit mode for canvas rendering * - 'contain': Scale to fit while maintaining aspect ratio (default) * - 'cover': Scale to cover entire canvas while maintaining aspect ratio (may crop) * - 'fill': Stretch to fill entire canvas (may distort aspect ratio) */ setFitMode(mode: "contain" | "cover" | "fill" | "zoom" | "control"): void; private startFitAnimation; /** * Set audio time provider for A/V sync * Pass null to disable A/V sync and run video independently */ setAudioTimeProvider(getAudioTime: (() => number) | null, isAudioHealthy?: (() => boolean) | null): void; /** * Queue a VideoFrame for presentation (instead of immediate render) */ queueFrame(frame: VideoFrame): void; /** * Render a VideoFrame immediately (for simple cases) */ render(frame: VideoFrame): void; /** * Start the presentation loop for smooth playback */ startPresentationLoop(): void; /** * Stop the presentation loop */ stopPresentationLoop(): void; /** * RAF-based presentation loop - presents frames at VSync-aligned times * For true 60fps, we present exactly one frame per RAF call when available */ private presentationLoop; /** * Get current playback time using wall clock with loose A/V sync * Video runs smoothly on wall clock, with periodic drift correction from audio * This ensures smooth 60fps video playback while maintaining A/V sync */ private getCurrentPlaybackTime; /** * Select the best frame to present for the current time * Uses timestamp-based presentation (like YouTube) - no forced frame repetition * Works smoothly for ALL frame rates: 24fps, 30fps, 50fps, 60fps, etc. */ private selectFrameForPresentation; /** * Upload a decoded VideoFrame into the currently-bound 2D texture. Tries * RGBA16F for high bit-depth content and falls back to RGBA8 on GL error or * exception. Shared by the flat and 360° draw paths. */ private uploadFrameTexture; /** * Draw a frame to the canvas */ private drawFrame; /** * Enable the 16×16 ambient mirror render. Cheap: ~256 fragment shader * invocations per drawn frame on top of the main draw. Call once when * ambient mode turns on; the matching `readAmbientPixels()` returns the * latest 16×16 RGBA buffer synchronously and effectively for free. */ enableAmbientMirror(): void; disableAmbientMirror(): void; /** * Read the latest mirrored frame as a 16×16 RGBA buffer. Synchronous and * cheap (256-pixel readPixels). Returns null if ambient mirror isn't * enabled or no frame has been drawn yet. */ readAmbientPixels(): Uint8Array | null; private _renderAmbientThumbnail; /** * Set subtitle overlay element (HTML element for better performance) */ setSubtitleOverlay(overlay: HTMLElement | null): void; /** * Tag the subtitle overlay with the source format. The styled black * backdrop is opt-in and only painted for WebVTT cues — that's the * karaoke-paced format from our YouTube proxy where the box reads as * a stable anchor for word-by-word reveal. * * All other formats render plain (text + shadow only): * - Text-based: srt / ass / ssa / ttml — traditional movie subs; * a backdrop reads as noise here. * - Image-based: pgs / dvd / dvb (vobsub, hdmv, dvb_subtitle) — * rendered through the cue.image path, untouched by this class. */ setSubtitleFormat(format: "vtt" | "srt" | "ass" | "ssa" | "ttml" | "pgs" | "dvd" | "dvb" | string | null): void; /** * Rotate video by 90 degrees clockwise */ rotate90(): number; /** * Get manual rotation (user-applied, not metadata) */ getRotation(): number; /** * Set manual rotation to a specific value (for save/restore in PiP) */ setManualRotation(deg: number): void; /** * Set extra bottom padding for subtitles when controls are visible * 0 = use default padding, >0 = use this value instead */ setSubtitleControlsPadding(padding: number): void; /** * Set subtitle cues for rendering * If cues array is provided, it replaces the current list * If a single cue is provided, it's added to the list (maintaining active cues) */ setSubtitleCues(cues: SubtitleCue[]): void; /** * Set subtitle delay in seconds (VLC/mpv convention). * Positive value: subtitles appear later than their original timing. * Negative value: subtitles appear earlier. */ setSubtitleDelay(seconds: number): void; /** Get current subtitle delay in seconds. */ getSubtitleDelay(): number; /** * Snapshot every cue currently in the cache as a plain array. Used by * the all-cues browser UI; we strip image cues since the browser is * text-only. */ getAllCues(): { start: number; end: number; text: string; }[]; /** * Render image subtitle in HTML overlay */ private renderImageSubtitleInOverlay; /** * Clear all subtitle cues */ clearSubtitles(): void; /** * Default bottom padding for the subtitle overlay, in CSS pixels. The * 60px floor we used to carry was a desktop-era guess at "above the * controls bar"; on a 250-pixel-tall embed it pinned the cue 24% up * the frame and made small-screen subtitles look like they were * floating mid-screen. Scale primarily with overlay height (≈ 8%), * cap at 80px on large players, and only floor at a low value so * tiny embeds still keep a few pixels of breathing room from the * very edge. */ private static computeSubtitleBottomPadding; /** * Coalesce subtitle re-render requests onto a single rAF tick. Used * by resize() so a window drag (which bursts ResizeObserver at * monitor-refresh rate) re-lays out the on-screen cue at most once * per frame instead of dozens of times. */ private scheduleSubtitleRerender; /** * Update active subtitle based on current time */ private updateActiveSubtitle; /** * Render active subtitle in HTML overlay (preferred) or on canvas (fallback) * Note: updateActiveSubtitle() should be called before this method */ private renderSubtitles; /** * Set playback rate */ setPlaybackRate(rate: number): void; /** * Get current time */ getCurrentTime(): number; /** * Check if frames are queued */ hasQueuedFrames(): boolean; /** * Get frame queue size */ getQueueSize(): number; /** * Timestamp (seconds) of the oldest frame still queued for presentation, * or -1 when the queue is empty. Used at EOF to tell whether the residual * frames are an unpresentable tail (PTS past the audio playout head, which * caps the clock) vs. frames that are still genuinely due. */ getHeadFrameTime(): number; /** * Get video rendering stats for nerd stats overlay */ getStats(): { framesPresented: number; frameQueueSize: number; colorSpace: string; resolution: string; syncedToAudio: boolean; }; /** * Drop frames whose pts is more than `toleranceSec` behind `targetTimeSec`. * Used on resume from pause to discard frames that became stale while the * audio clock advanced (e.g. mid-decode-warmup when the user fullscreens or * toggles tracks, causing the queue to retain pre-resume frames). * Returns the number of frames dropped. */ dropStaleFrames(targetTimeSec: number, toleranceSec?: number): number; /** * Clear frame queue (useful for seek operations) * Resets all presentation timing to prevent stuttering after seek */ clearQueue(): void; /** * Render an ImageBitmap */ renderBitmap(_bitmap: ImageBitmap): void; /** * Clear the canvas */ clear(): void; /** * Fill with black */ fillBlack(): void; /** * Get canvas element */ getCanvas(): HTMLCanvasElement | OffscreenCanvas; /** * Destroy renderer */ destroy(): void; } //# sourceMappingURL=CanvasRenderer.d.ts.map