From bcf67bff1de096b9224d5b356efea7616fdcc9eb Mon Sep 17 00:00:00 2001 From: webadderall <131426131+webadderall@users.noreply.github.com> Date: Sat, 11 Apr 2026 18:41:52 +1000 Subject: [PATCH] fix(playback): improve cursor rendering and layout calculations - Refine cursor telemetry loop with extension coordinate support - Improve DPI-aware cursor rendering pipeline - Extend layout utilities for extension-driven scene transforms --- .../videoPlayback/cursorLoopTelemetry.ts | 9 +++-- .../videoPlayback/cursorRenderer.ts | 28 +++++++++------ .../video-editor/videoPlayback/layoutUtils.ts | 36 +++++++++++++++---- .../videoPlayback/videoEventHandlers.ts | 2 ++ 4 files changed, 56 insertions(+), 19 deletions(-) diff --git a/src/components/video-editor/videoPlayback/cursorLoopTelemetry.ts b/src/components/video-editor/videoPlayback/cursorLoopTelemetry.ts index 4c513e19..bdb5e6a6 100644 --- a/src/components/video-editor/videoPlayback/cursorLoopTelemetry.ts +++ b/src/components/video-editor/videoPlayback/cursorLoopTelemetry.ts @@ -174,12 +174,15 @@ export function buildLoopedCursorTelemetry( const sourceStartMs = firstSample.timeMs; const sourceEndMs = Math.max(sourceStartMs, findLastMovingSampleTime(boundedSamples)); const sourceDurationMs = Math.max(1, sourceEndMs - sourceStartMs); - const playbackWindowMs = Math.max(1, motionEndMs); + // Keep remapped times in source-time coordinates so cursor lookups + // (which always use video.currentTime, i.e. source time) stay correct + // even when the visible timeline window doesn't start at 0. + const motionDurationMs = Math.max(1, motionEndMs - timelineStartClampedMs); const startingCursorType = findFirstStableCursorType(boundedSamples); const loopedSamples: CursorTelemetryPoint[] = [ { ...firstSample, - timeMs: 0, + timeMs: timelineStartClampedMs, interactionType: undefined, cursorType: startingCursorType, }, @@ -187,7 +190,7 @@ export function buildLoopedCursorTelemetry( for (const sample of boundedSamples) { const progress = clamp((sample.timeMs - sourceStartMs) / sourceDurationMs, 0, 1); - const mappedTimeMs = Math.round(playbackWindowMs * progress); + const mappedTimeMs = Math.round(timelineStartClampedMs + motionDurationMs * progress); if (mappedTimeMs <= loopedSamples[loopedSamples.length - 1].timeMs) { loopedSamples[loopedSamples.length - 1] = { diff --git a/src/components/video-editor/videoPlayback/cursorRenderer.ts b/src/components/video-editor/videoPlayback/cursorRenderer.ts index bb5eee27..81135600 100644 --- a/src/components/video-editor/videoPlayback/cursorRenderer.ts +++ b/src/components/video-editor/videoPlayback/cursorRenderer.ts @@ -91,7 +91,7 @@ export const DEFAULT_CURSOR_CONFIG: CursorRenderConfig = { const REFERENCE_WIDTH = 1920; const MIN_CURSOR_VIEWPORT_SCALE = 0.55; -const CLICK_RING_FADE_MS = 240; +const CLICK_RING_FADE_MS = 600; const CURSOR_MOTION_BLUR_BASE_MULTIPLIER = 0.08; const CURSOR_TIME_DISCONTINUITY_MS = 100; const CURSOR_SWAY_SMOOTHING_MULTIPLIER = 0.7; @@ -852,13 +852,6 @@ export class SmoothedCursorState { } } -function drawClickRing(graphics: Graphics, px: number, py: number, h: number, progress: number) { - void graphics; - void px; - void py; - void h; - void progress; -} export class PixiCursorOverlay { public readonly container: Container; @@ -1000,6 +993,22 @@ export class PixiCursorOverlay { this.customCursorSprite.anchor.set(asset.anchorX, asset.anchorY); } + getSmoothedCursorSnapshot(): { + cx: number; + cy: number; + trail: Array<{ cx: number; cy: number }>; + } | null { + if (!this.container.visible) { + return null; + } + + return { + cx: this.state.x, + cy: this.state.y, + trail: this.state.trail.map((point) => ({ cx: point.x, cy: point.y })), + }; + } + update( samples: CursorTelemetryPoint[], timeMs: number, @@ -1053,7 +1062,7 @@ export class PixiCursorOverlay { const px = viewport.x + this.state.x * viewport.width; const py = viewport.y + this.state.y * viewport.height; const h = this.config.dotRadius * getCursorViewportScale(viewport); - const { cursorType, clickBounceProgress, clickProgress } = getCursorVisualState( + const { cursorType, clickBounceProgress } = getCursorVisualState( samples, timeMs, this.config.clickBounceDuration, @@ -1066,7 +1075,6 @@ export class PixiCursorOverlay { const swayRotation = this.updateCursorSway(px, py, timeMs, shouldFreezeCursorMotion); this.clickRingGraphics.clear(); - drawClickRing(this.clickRingGraphics, px, py, h, clickProgress); const spriteKey = (cursorType in this.cursorSprites ? cursorType : "arrow") as CursorAssetKey; diff --git a/src/components/video-editor/videoPlayback/layoutUtils.ts b/src/components/video-editor/videoPlayback/layoutUtils.ts index ec1dd6af..a80b8645 100644 --- a/src/components/video-editor/videoPlayback/layoutUtils.ts +++ b/src/components/video-editor/videoPlayback/layoutUtils.ts @@ -12,6 +12,8 @@ interface LayoutParams { lockedVideoDimensions?: { width: number; height: number } | null; borderRadius?: number; padding?: number; + /** Screen insets from the active device frame, used to scale/center the full frame */ + frameInsets?: { top: number; right: number; bottom: number; left: number } | null; } interface LayoutResult { @@ -30,7 +32,7 @@ interface LayoutResult { } export function layoutVideoContent(params: LayoutParams): LayoutResult | null { - const { container, app, videoSprite, maskGraphics, videoElement, cropRegion, lockedVideoDimensions, borderRadius = 0, padding = 0 } = params; + const { container, app, videoSprite, maskGraphics, videoElement, cropRegion, lockedVideoDimensions, borderRadius = 0, padding = 0, frameInsets } = params; const videoWidth = lockedVideoDimensions?.width || videoElement.videoWidth; const videoHeight = lockedVideoDimensions?.height || videoElement.videoHeight; @@ -68,9 +70,20 @@ export function layoutVideoContent(params: LayoutParams): LayoutResult | null { const maxDisplayWidth = width * paddingScale; const maxDisplayHeight = height * paddingScale; + // When a device frame is active, the frame extends beyond the video area. + // We need to scale so the ENTIRE frame (video + bezels) fits in the viewport, + // then center the full frame, not just the video content. + const insets = frameInsets; + // Fraction of the full frame occupied by the screen area + const screenFracW = insets ? (1 - insets.left - insets.right) : 1; + const screenFracH = insets ? (1 - insets.top - insets.bottom) : 1; + // Full frame dimensions in video pixels (the frame image is this large relative to the screen) + const fullFrameVideoW = croppedVideoWidth / screenFracW; + const fullFrameVideoH = croppedVideoHeight / screenFracH; + const scale = Math.min( - maxDisplayWidth / croppedVideoWidth, - maxDisplayHeight / croppedVideoHeight, + maxDisplayWidth / fullFrameVideoW, + maxDisplayHeight / fullFrameVideoH, ); videoSprite.scale.set(scale); @@ -83,9 +96,20 @@ export function layoutVideoContent(params: LayoutParams): LayoutResult | null { const croppedDisplayWidth = croppedVideoWidth * scale; const croppedDisplayHeight = croppedVideoHeight * scale; - // Center the cropped region in the container - const centerOffsetX = (width - croppedDisplayWidth) / 2; - const centerOffsetY = (height - croppedDisplayHeight) / 2; + // Center the full frame (or just the video if no frame) in the container + // Full frame display dimensions + const fullFrameDisplayW = fullFrameVideoW * scale; + const fullFrameDisplayH = fullFrameVideoH * scale; + // The full frame's top-left, centered in the viewport + const frameCenterX = (width - fullFrameDisplayW) / 2; + const frameCenterY = (height - fullFrameDisplayH) / 2; + // The screen area starts at frameCenterX + insets.left * fullFrameDisplayW + const centerOffsetX = insets + ? frameCenterX + insets.left * fullFrameDisplayW + : (width - croppedDisplayWidth) / 2; + const centerOffsetY = insets + ? frameCenterY + insets.top * fullFrameDisplayH + : (height - croppedDisplayHeight) / 2; // Position the full video sprite so that when we apply the mask, // the cropped region appears centered diff --git a/src/components/video-editor/videoPlayback/videoEventHandlers.ts b/src/components/video-editor/videoPlayback/videoEventHandlers.ts index 9ee7ae54..0502581d 100644 --- a/src/components/video-editor/videoPlayback/videoEventHandlers.ts +++ b/src/components/video-editor/videoPlayback/videoEventHandlers.ts @@ -1,5 +1,6 @@ import type React from 'react'; import type { TrimRegion, SpeedRegion } from '../types'; +import { extensionHost } from '@/lib/extensions'; interface VideoEventHandlersParams { video: HTMLVideoElement; @@ -31,6 +32,7 @@ export function createVideoEventHandlers(params: VideoEventHandlersParams) { const emitTime = (timeValue: number) => { currentTimeRef.current = timeValue * 1000; onTimeUpdate(timeValue); + extensionHost.emitEvent({ type: 'playback:timeupdate', timeMs: timeValue * 1000 }); }; // Helper function to check if current time is within a trim region