mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 09:36:08 +00:00
refactor(video): share R2 playback URL resolution and guard drift resync
- move resolveR2PlaybackUrl into lib/video-upload-validation.ts so the compare view and the main video page cannot drift apart - validate the resolved URL with isPlayableVideoUrl before it reaches <video src> - add a per-player cooldown so a follower that cannot keep up is not seeked every second, which would stutter rather than correct
This commit is contained in:
+25
-19
@@ -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<YT.Player | PlayerAdapter, number>());
|
||||
|
||||
// Direct DOM refs for progress bar / playhead / timecode — updated in the RAF loop
|
||||
const progressBarRef = useRef<HTMLDivElement>(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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 `<video src>`: proxy paths must be a well-formed
|
||||
* upload route, anything else must be plain http(s).
|
||||
*/
|
||||
export function isPlayableVideoUrl(url: string): boolean {
|
||||
if (url.startsWith(VIDEO_PROXY_PREFIX)) {
|
||||
return videoProxyPathToObjectKey(url) !== null;
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return parsed.protocol === 'http:' || parsed.protocol === 'https:';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function objectKeyToVideoProxyPath(objectKey: string): string | null {
|
||||
if (!objectKey.startsWith(VIDEO_OBJECT_KEY_PREFIX)) return null;
|
||||
const filename = objectKey.slice(VIDEO_OBJECT_KEY_PREFIX.length);
|
||||
|
||||
Reference in New Issue
Block a user