diff --git a/app/(dashboard)/projects/[projectId]/videos/[videoId]/compare/compare-versions-page-client.tsx b/app/(dashboard)/projects/[projectId]/videos/[videoId]/compare/compare-versions-page-client.tsx index 1edec62..682b0b9 100644 --- a/app/(dashboard)/projects/[projectId]/videos/[videoId]/compare/compare-versions-page-client.tsx +++ b/app/(dashboard)/projects/[projectId]/videos/[videoId]/compare/compare-versions-page-client.tsx @@ -29,6 +29,7 @@ import { DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { resolvePublicBunnyCdnHostname } from '@/lib/bunny-cdn'; +import { isPlayableVideoUrl, resolveR2PlaybackUrl } from '@/lib/video-upload-validation'; import { cn } from '@/lib/utils'; interface Version { @@ -91,6 +92,11 @@ function formatTime(seconds: number): string { return `${mins}:${secs.toString().padStart(2, '0')}`; } +// Panels drifting past this from the source player read as out-of-sync playback. +const MAX_PANEL_DRIFT_SECONDS = 0.35; +// Minimum gap between two corrective seeks of the same panel. +const RESYNC_COOLDOWN_MS = 4000; + const isSafeUrl = (url: string) => { try { const parsed = new URL(url); @@ -100,21 +106,6 @@ const isSafeUrl = (url: string) => { } }; -// Mirrors the playback-url resolution the main video page uses for direct -// R2 uploads: media streams through the app's upload route. -function resolveR2PlaybackUrl(version: Version): string { - if (version.originalUrl.startsWith('/api/upload/video/')) { - return version.originalUrl; - } - if (version.originalUrl.startsWith('videos/')) { - return `/api/upload/video/${version.originalUrl.slice('videos/'.length)}`; - } - if (version.videoId.startsWith('videos/')) { - return `/api/upload/video/${version.videoId.slice('videos/'.length)}`; - } - return version.originalUrl; -} - export default function CompareVersionsPageClient({ projectId, videoId, @@ -158,6 +149,7 @@ export default function CompareVersionsPageClient({ const durationRef = useRef(0); const lastCommitRef = useRef(0); const lastSyncRef = useRef(0); + const resyncCooldownRef = useRef(new WeakMap()); // Direct DOM refs for progress bar / playhead / timecode — updated in the RAF loop const progressBarRef = useRef(null); @@ -279,13 +271,19 @@ export default function CompareVersionsPageClient({ // Re-sync followers that drift from the source player — providers // buffer at different speeds and drift past ~350ms reads as - // out-of-sync playback. + // out-of-sync playback. The per-player cooldown keeps a follower + // that simply cannot keep up (slow network, HLS rebuffering) from + // being seeked every second, which would stutter rather than correct. if (playing && t !== undefined && timestamp - lastSyncRef.current >= 1000) { lastSyncRef.current = timestamp; + const cooldowns = resyncCooldownRef.current; for (let i = 1; i < players.length; i += 1) { + const follower = players[i]; + if (timestamp - (cooldowns.get(follower) ?? 0) < RESYNC_COOLDOWN_MS) continue; try { - if (Math.abs(players[i].getCurrentTime() - t) > 0.35) { - players[i].seekTo(t, true); + if (Math.abs(follower.getCurrentTime() - t) > MAX_PANEL_DRIFT_SECONDS) { + cooldowns.set(follower, timestamp); + follower.seekTo(t, true); } } catch { // Player not ready @@ -1058,6 +1056,14 @@ function R2Panel({ const videoEl = videoRef.current; if (!videoEl) return; + // Same guard the rest of the page applies before putting a URL in the DOM: + // proxy paths must be a well-formed upload route, anything else http(s). + const playbackUrl = resolveR2PlaybackUrl(version); + if (!isPlayableVideoUrl(playbackUrl)) { + console.error('Unsafe R2 playback URL, panel not registered:', playbackUrl); + return; + } + let cachedTime = 0; let cachedDuration = 0; let isPlaying = false; @@ -1128,7 +1134,7 @@ function R2Panel({ videoEl.addEventListener('pause', onPause); videoEl.addEventListener('ended', onEnded); - videoEl.src = resolveR2PlaybackUrl(version); + videoEl.src = playbackUrl; videoEl.load(); onRegister(version.id, adapter); diff --git a/components/video-page-content.tsx b/components/video-page-content.tsx index 77c2428..85bd498 100644 --- a/components/video-page-content.tsx +++ b/components/video-page-content.tsx @@ -14,6 +14,7 @@ import { VideoPageError } from '@/components/video-page/video-page-error'; import { GuestNameGate } from '@/components/video-page/guest-name-gate'; import { useCommentMedia } from '@/components/video-page/hooks/use-comment-media'; import { validateAnnotationStrokes } from '@/lib/validation'; +import { resolveR2PlaybackUrl } from '@/lib/video-upload-validation'; import { useVersionActions } from '@/components/video-page/hooks/use-version-actions'; import { useWatchProgress } from '@/components/video-page/hooks/use-watch-progress'; import { useVideoPlayer } from '@/components/video-page/hooks/use-video-player'; @@ -288,18 +289,7 @@ export function VideoPageContent({ return `https://${bunnyCdnHostname}/${activeVersion.videoId}/playlist.m3u8`; } if (activeVersion.providerId === 'r2') { - if (activeVersion.originalUrl.startsWith('/api/upload/video/')) { - return activeVersion.originalUrl; - } - if (activeVersion.originalUrl.startsWith('videos/')) { - const filename = activeVersion.originalUrl.slice('videos/'.length); - return `/api/upload/video/${filename}`; - } - if (activeVersion.videoId.startsWith('videos/')) { - const filename = activeVersion.videoId.slice('videos/'.length); - return `/api/upload/video/${filename}`; - } - return activeVersion.originalUrl; + return resolveR2PlaybackUrl(activeVersion); } try { const url = new URL(activeVersion.originalUrl); diff --git a/lib/video-upload-validation.ts b/lib/video-upload-validation.ts index 031fae5..bb369e6 100644 --- a/lib/video-upload-validation.ts +++ b/lib/video-upload-validation.ts @@ -57,6 +57,7 @@ export function isAllowedVideoFile(fileName: string, mime: string | undefined): } export const VIDEO_OBJECT_KEY_PREFIX = 'videos/'; +export const VIDEO_PROXY_PREFIX = '/api/upload/video/'; const SAFE_VIDEO_BASENAME = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.[a-z0-9]+$/i; @@ -66,17 +67,50 @@ export function buildVideoObjectKey(filename: string): string { } export function videoProxyPathFromFilename(filename: string): string { - return `/api/upload/video/${filename}`; + return `${VIDEO_PROXY_PREFIX}${filename}`; } export function videoProxyPathToObjectKey(proxyPath: string): string | null { - const prefix = '/api/upload/video/'; - if (!proxyPath.startsWith(prefix)) return null; - const filename = proxyPath.slice(prefix.length); + if (!proxyPath.startsWith(VIDEO_PROXY_PREFIX)) return null; + const filename = proxyPath.slice(VIDEO_PROXY_PREFIX.length); if (!SAFE_VIDEO_BASENAME.test(filename)) return null; return buildVideoObjectKey(filename); } +/** + * Playback URL for a direct-upload (`r2`) version: media always streams through + * the app's own upload route. Shared by the video page and the compare view so + * the two cannot drift. + */ +export function resolveR2PlaybackUrl(version: { videoId: string; originalUrl: string }): string { + if (version.originalUrl.startsWith(VIDEO_PROXY_PREFIX)) { + return version.originalUrl; + } + if (version.originalUrl.startsWith(VIDEO_OBJECT_KEY_PREFIX)) { + return videoProxyPathFromFilename(version.originalUrl.slice(VIDEO_OBJECT_KEY_PREFIX.length)); + } + if (version.videoId.startsWith(VIDEO_OBJECT_KEY_PREFIX)) { + return videoProxyPathFromFilename(version.videoId.slice(VIDEO_OBJECT_KEY_PREFIX.length)); + } + return version.originalUrl; +} + +/** + * Guards what ends up in a `