feat(playlist): seamless playback of selected shots, cut only
Deploy / deploy (push) Successful in 3m12s

Check shots on the Playlist page and "Play selection" plays them back to
back without slate or handles. The cut comes from the version's handle and
slate metadata (new cutSpan in lib/handles); the media is never changed.

SequencePlayer double-buffers two videos: the next clip is parked a few
frames before its cut-in and pre-rolled hidden, the outgoing clip pauses on
its last cut frame, and the cut fires on the incoming clip's cut-in frame
(requestVideoFrameCallback), with the start time calibrated at each cut.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
twotalesanimation
2026-09-26 08:55:46 +02:00
parent 824695ef30
commit b596c97faf
5 changed files with 1087 additions and 12 deletions
+189 -8
View File
@@ -1,9 +1,10 @@
"use client";
import { useState, useEffect, useRef, useCallback } from "react";
import { useState, useEffect, useRef, useCallback, useMemo, startTransition } from "react";
import Image from "next/image";
import Link from "next/link";
import { ReviewPlayer, type ReviewPlayerRef } from "@/components/player/ReviewPlayer";
import { SequencePlayer, type SequenceClip } from "@/components/player/SequencePlayer";
import { useReviewStore } from "@/hooks/use-review-player";
import {
Select,
@@ -26,6 +27,8 @@ import {
ExternalLink,
ShieldCheck,
Send,
ListVideo,
X,
} from "lucide-react";
interface Project {
@@ -42,6 +45,7 @@ interface PlaylistVersion {
thumbnailUrl: string | null;
posterUrl: string | null;
fps: number;
duration?: number | null;
frameCount?: number | null;
hqFileUrl?: string | null;
handleHead?: number | null;
@@ -91,6 +95,10 @@ export function PlaylistClient({ projects, userRole }: PlaylistClientProps) {
const [activeShot, setActiveShot] = useState<PlaylistShot | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [isActioning, setIsActioning] = useState(false);
/** Shots checked for the seamless playlist, by shot id. */
const [selected, setSelected] = useState<Set<string>>(new Set());
const [sequenceMode, setSequenceMode] = useState(false);
const lastChecked = useRef<string | null>(null);
const canApprove = ["ADMIN", "PRODUCER", "SUPERVISOR"].includes(userRole);
@@ -117,6 +125,9 @@ export function PlaylistClient({ projects, userRole }: PlaylistClientProps) {
if (cancelled) return;
const list: PlaylistShot[] = data.shots ?? [];
setShots(list);
setSelected(loadSelection(projectId, list));
lastChecked.current = null;
setSequenceMode(false);
// Auto-select the first shot, or keep the current one if it still exists
setActiveShot((prev) => {
const stillExists = prev && list.some((s) => s.id === prev.id);
@@ -139,9 +150,78 @@ export function PlaylistClient({ projects, userRole }: PlaylistClientProps) {
const handleSelectShot = useCallback((shot: PlaylistShot) => {
reset();
setSequenceMode(false);
setActiveShot(shot);
}, [reset]);
const updateSelection = useCallback(
(next: Set<string>) => {
setSelected(next);
if (projectId) saveSelection(projectId, next);
if (next.size === 0) setSequenceMode(false);
},
[projectId]
);
/** Toggle one shot; shift-click sets every shot between it and the last one checked. */
const toggleChecked = (shot: PlaylistShot, shift: boolean) => {
const next = new Set(selected);
const on = !selected.has(shot.id);
const from = lastChecked.current ? shots.findIndex((s) => s.id === lastChecked.current) : -1;
const to = shots.findIndex((s) => s.id === shot.id);
const range = shift && from >= 0 ? shots.slice(Math.min(from, to), Math.max(from, to) + 1) : [shot];
for (const s of range) {
if (on) next.add(s.id);
else next.delete(s.id);
}
lastChecked.current = shot.id;
updateSelection(next);
};
const setGroupChecked = (group: PlaylistShot[], on: boolean) => {
const next = new Set(selected);
for (const s of group) {
if (on) next.add(s.id);
else next.delete(s.id);
}
updateSelection(next);
};
// The playlist, in edit order. Keyed on the versions it plays so a refresh
// (e.g. after approving a shot) doesn't restart playback.
const clipKey = shots
.filter((s) => selected.has(s.id))
.map((s) => s.latestVersion.id)
.join(",");
const clips = useMemo<SequenceClip[]>(
() =>
shots
.filter((s) => selected.has(s.id))
.map((s) => ({
id: s.latestVersion.id,
label: s.shotCode,
sublabel: `v${String(s.latestVersion.versionNumber).padStart(3, "0")}`,
url: s.latestVersion.fileUrl,
hqUrl: s.latestVersion.hqFileUrl,
fps: s.latestVersion.fps,
duration: s.latestVersion.duration,
handles: s.latestVersion,
})),
// eslint-disable-next-line react-hooks/exhaustive-deps
[clipKey]
);
const handleClipChange = useCallback(
(clip: SequenceClip) => {
// A transition, so re-rendering the page (shot list and all) yields to
// the video: rendered in one go, it dropped the frame after every cut.
startTransition(() => {
setActiveShot(shots.find((s) => s.latestVersion.id === clip.id) ?? null);
});
},
[shots]
);
const refreshShots = useCallback(() => {
if (!projectId) return;
fetch(`/api/playlist?projectId=${projectId}`)
@@ -220,6 +300,33 @@ export function PlaylistClient({ projects, userRole }: PlaylistClientProps) {
</Select>
</div>
{selected.size > 0 && (
sequenceMode ? (
<Button
size="sm"
variant="outline"
onClick={() => setSequenceMode(false)}
className="h-7 text-xs gap-1"
>
<X className="h-3 w-3" />
Exit playlist
</Button>
) : (
<Button
size="sm"
onClick={() => {
reset();
setSequenceMode(true);
}}
className="h-7 text-xs gap-1"
title="Play the checked shots back to back: cut only, no slate or handles"
>
<ListVideo className="h-3.5 w-3.5" />
Play selection ({selected.size})
</Button>
)
)}
{activeShot && (
<div className="flex items-center gap-2 ml-auto">
<span className="font-mono text-sm text-white">{activeShot.shotCode}</span>
@@ -272,7 +379,9 @@ export function PlaylistClient({ projects, userRole }: PlaylistClientProps) {
<div className="flex flex-1 min-h-0 overflow-hidden">
{/* Player */}
<div className="flex-1 min-w-0 bg-black flex flex-col">
{activeShot ? (
{sequenceMode && clips.length > 0 ? (
<SequencePlayer clips={clips} onClipChange={handleClipChange} />
) : activeShot ? (
<ReviewPlayer
key={activeShot.latestVersion.id}
ref={playerRef}
@@ -304,7 +413,26 @@ export function PlaylistClient({ projects, userRole }: PlaylistClientProps) {
<div className="px-3 py-2.5 border-b border-border shrink-0 flex items-center justify-between">
<span className="text-xs font-medium text-zinc-400 uppercase tracking-wider">Shots</span>
{shots.length > 0 && (
<span className="text-xs text-zinc-600">{shots.length}</span>
<div className="flex items-center gap-2 text-xs">
{selected.size > 0 ? (
<button
onClick={() => updateSelection(new Set())}
className="text-zinc-500 hover:text-zinc-200"
>
Clear
</button>
) : (
<button
onClick={() => setGroupChecked(shots, true)}
className="text-zinc-500 hover:text-zinc-200"
>
Select all
</button>
)}
<span className="text-zinc-600">
{selected.size > 0 ? `${selected.size}/${shots.length}` : shots.length}
</span>
</div>
)}
</div>
@@ -327,16 +455,25 @@ export function PlaylistClient({ projects, userRole }: PlaylistClientProps) {
<div>
{episodeGroups.map(([episode, episodeShots]) => (
<div key={episode}>
<div className="px-3 py-1.5 bg-zinc-900/80 sticky top-0 z-10 border-b border-zinc-800">
<label className="px-3 py-1.5 bg-zinc-900/80 sticky top-0 z-10 border-b border-zinc-800 flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
className="h-3 w-3 accent-blue-500 cursor-pointer"
checked={episodeShots.every((s) => selected.has(s.id))}
onChange={(e) => setGroupChecked(episodeShots, e.target.checked)}
title="Select this episode"
/>
<span className="text-[10px] font-semibold text-zinc-500 uppercase tracking-wider">
Ep {episode}
</span>
</div>
</label>
{episodeShots.map((shot) => (
<ShotThumbnail
key={shot.id}
shot={shot}
isActive={activeShot?.id === shot.id}
isChecked={selected.has(shot.id)}
onToggle={(shift) => toggleChecked(shot, shift)}
onClick={() => handleSelectShot(shot)}
/>
))}
@@ -350,6 +487,8 @@ export function PlaylistClient({ projects, userRole }: PlaylistClientProps) {
key={shot.id}
shot={shot}
isActive={activeShot?.id === shot.id}
isChecked={selected.has(shot.id)}
onToggle={(shift) => toggleChecked(shot, shift)}
onClick={() => handleSelectShot(shot)}
/>
))}
@@ -365,25 +504,45 @@ export function PlaylistClient({ projects, userRole }: PlaylistClientProps) {
function ShotThumbnail({
shot,
isActive,
isChecked,
onToggle,
onClick,
}: {
shot: PlaylistShot;
isActive: boolean;
isChecked: boolean;
onToggle: (shift: boolean) => void;
onClick: () => void;
}) {
const thumb = shot.latestVersion.thumbnailUrl ?? shot.latestVersion.posterUrl ?? shot.thumbnailUrl;
const ApprovalIcon = APPROVAL_ICONS[shot.latestVersion.approvalStatus] ?? Clock;
return (
<button
<div
role="button"
tabIndex={0}
onClick={onClick}
onKeyDown={(e) => {
if (e.key === "Enter") onClick();
}}
className={cn(
"w-full flex items-start gap-2.5 px-3 py-2.5 text-left transition-colors border-b border-zinc-800/60 group",
"w-full flex items-start gap-2.5 px-3 py-2.5 text-left transition-colors border-b border-zinc-800/60 group cursor-pointer",
isActive
? "bg-blue-500/10 border-l-2 border-l-blue-500"
: "hover:bg-zinc-800/40 border-l-2 border-l-transparent"
)}
>
<input
type="checkbox"
checked={isChecked}
onChange={() => {}}
onClick={(e) => {
e.stopPropagation();
onToggle(e.shiftKey);
}}
className="mt-3 h-3.5 w-3.5 shrink-0 accent-blue-500 cursor-pointer"
title="Add to playlist (shift-click for a range)"
/>
{/* Thumbnail */}
<div className="w-20 aspect-[2.39/1] rounded overflow-hidden bg-zinc-800 shrink-0 flex items-center justify-center">
{thumb ? (
@@ -415,6 +574,28 @@ function ShotThumbnail({
</span>
</div>
</div>
</button>
</div>
);
}
const selectionKey = (projectId: string) => `playlist:selection:${projectId}`;
/** The checked shots remembered for this project, minus any no longer listed. */
function loadSelection(projectId: string, shots: PlaylistShot[]): Set<string> {
try {
const ids: unknown = JSON.parse(localStorage.getItem(selectionKey(projectId)) ?? "[]");
if (!Array.isArray(ids)) return new Set();
const listed = new Set(shots.map((s) => s.id));
return new Set(ids.filter((id): id is string => typeof id === "string" && listed.has(id)));
} catch {
return new Set();
}
}
function saveSelection(projectId: string, selected: Set<string>) {
try {
localStorage.setItem(selectionKey(projectId), JSON.stringify([...selected]));
} catch {
// Storage unavailable: the selection just isn't remembered.
}
}
+2
View File
@@ -50,6 +50,7 @@ export async function GET(req: NextRequest) {
thumbnailUrl: true,
posterUrl: true,
fps: true,
duration: true,
frameCount: true,
hqFileUrl: true,
handleHead: true,
@@ -90,6 +91,7 @@ export async function GET(req: NextRequest) {
thumbnailUrl: latest.thumbnailUrl,
posterUrl: latest.posterUrl,
fps: latest.fps,
duration: latest.duration,
frameCount: latest.frameCount,
hqFileUrl: latest.hqFileUrl,
handleHead: latest.handleHead,
+822
View File
@@ -0,0 +1,822 @@
"use client";
/**
* Seamless playlist playback: plays the cut of each clip back to back — no
* slate, no handles — without the gap of loading one video after another.
*
* Two <video> elements are stacked. While one plays, the other has the next
* clip loaded and parked on its cut in-point; on the cut out-point they swap.
* The out-point is caught with requestVideoFrameCallback (per presented
* frame) where the browser has it, else every animation frame.
*
* The media is never changed: the cut comes from the version's handle and
* slate metadata (lib/handles.ts), as in the review player.
*/
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { cn } from "@/lib/utils";
import { cutSpan, type FrameSpan, type VersionHandles } from "@/lib/handles";
import { durationToFrameCount, frameToTime, frameToTimecode, timeToFrame } from "@/lib/frame-utils";
import {
ChevronFirst,
ChevronLast,
Loader2,
Pause,
Play,
Repeat,
Volume2,
VolumeX,
} from "lucide-react";
export interface SequenceClip {
/** Version id. */
id: string;
label: string;
sublabel?: string;
url: string;
hqUrl?: string | null;
fps: number;
/** Media duration in seconds, if known — sizes the timeline before loading. */
duration?: number | null;
handles: VersionHandles;
}
interface SequencePlayerProps {
clips: SequenceClip[];
/** Called with the clip now on screen. */
onClipChange?: (clip: SequenceClip, index: number) => void;
}
type Slot = 0 | 1;
interface Loaded {
index: number;
url: string;
/** Media frame it is parked on, once seeked. */
parkedAt?: number;
/** Its cut in-point, once the media length is known. */
cutIn?: number;
}
/** Seek a quarter frame in so float error can't land on the previous frame. */
const SEEK_BIAS = 0.25;
/**
* The next clip waits this many frames before its cut in (handle or slate —
* hidden anyway) so it's already running, past the stutter of a video
* starting up, when it comes on screen. Clips with less lead-in (a 1-frame
* slate, no handles) pre-roll what they have.
*/
const PREROLL_FRAMES = 4;
/**
* How long before its pre-roll frames the next clip must start. Chrome counts
* the parked frame as shown the moment play() is called, so ~0; calibrated at
* every cut for browsers that differ.
*/
const INITIAL_START_LATENCY_MS = 0;
/**
* Hold off loading the clip after next until the cut has settled: loading
* one video while another starts playing made the incoming clip drop a frame.
*/
const PRELOAD_DELAY_MS = 300;
function estimatedSpan(clip: SequenceClip): FrameSpan {
const total =
clip.duration && clip.duration > 0
? durationToFrameCount(clip.duration, clip.fps)
: (clip.handles.frameCount ?? 0) + 1;
return cutSpan(clip.handles, total) ?? { start: 0, end: Math.max(1, total) };
}
export function SequencePlayer({ clips, onClipChange }: SequencePlayerProps) {
const videoRefs = [useRef<HTMLVideoElement>(null), useRef<HTMLVideoElement>(null)];
const loaded = useRef<[Loaded | null, Loaded | null]>([null, null]);
/** Real spans, filled in as each clip's metadata arrives. */
const spans = useRef(new Map<string, FrameSpan>());
const [active, setActive] = useState<Slot>(0);
const activeRef = useRef<Slot>(0);
const [index, setIndex] = useState(0);
const indexRef = useRef(0);
const [frame, setFrame] = useState(0); // frame within the active media
const [playing, setPlaying] = useState(false);
const playingRef = useRef(false);
const [buffering, setBuffering] = useState(false);
const [loop, setLoop] = useState(true);
const loopRef = useRef(true);
loopRef.current = loop;
const [muted, setMuted] = useState(true);
const [hq, setHq] = useState(false);
const [spanVersion, bumpSpans] = useState(0);
/** The latest load into each slot; an older load's listeners stand down. */
const slotToken = useRef<[object | null, object | null]>([null, null]);
/** The clip on screen, by id, to stay on when the clip list changes. */
const shownId = useRef<string | null>(null);
/** Bumped on every goTo so a superseded load stops acting. */
const generation = useRef(0);
/** How much earlier than its pre-roll frames the next clip must start. */
const startLatency = useRef(INITIAL_START_LATENCY_MS);
const srcFor = useCallback(
(clip: SequenceClip) => (hq && clip.hqUrl ? clip.hqUrl : clip.url),
[hq]
);
const srcForRef = useRef(srcFor);
srcForRef.current = srcFor;
const spanOf = useCallback(
(i: number) => spans.current.get(clips[i].id) ?? estimatedSpan(clips[i]),
[clips]
);
/**
* Load clip `i` into a slot and park it on `frameInMedia` (default: its cut
* in, less `preroll` frames). Resolves false if superseded or the media failed.
*/
const prepare = useCallback(
(slot: Slot, i: number, frameInMedia?: number, preroll = 0): Promise<boolean> => {
const video = videoRefs[slot].current;
const clip = clips[i];
if (!video || !clip) return Promise.resolve(false);
const url = srcForRef.current(clip);
const gen = generation.current;
const token = {};
slotToken.current[slot] = token;
return new Promise<boolean>((resolve) => {
const superseded = () => {
if (slotToken.current[slot] === token) return false;
cleanup();
resolve(false);
return true;
};
const cleanup = () => {
video.removeEventListener("loadedmetadata", onMeta);
video.removeEventListener("seeked", onSeeked);
video.removeEventListener("error", onError);
};
const onError = () => {
if (superseded()) return;
cleanup();
resolve(false);
};
const onSeeked = () => {
if (superseded()) return;
cleanup();
resolve(gen === generation.current);
};
const seek = () => {
const total = durationToFrameCount(video.duration, clip.fps);
const span = cutSpan(clip.handles, total);
if (span) spans.current.set(clip.id, span);
const cutIn = span?.start ?? 0;
const target = frameInMedia ?? Math.max(0, cutIn - preroll);
const entry = loaded.current[slot];
if (entry) {
entry.cutIn = cutIn;
entry.parkedAt = target;
}
video.addEventListener("seeked", onSeeked);
video.currentTime = frameToTime(target + SEEK_BIAS, clip.fps);
};
const onMeta = () => {
if (superseded()) return;
video.removeEventListener("loadedmetadata", onMeta);
bumpSpans((n) => n + 1);
seek();
};
video.addEventListener("error", onError);
const current = loaded.current[slot];
if (current && current.url === url && video.readyState >= 1) {
loaded.current[slot] = { index: i, url };
seek();
} else {
loaded.current[slot] = { index: i, url };
video.addEventListener("loadedmetadata", onMeta);
video.src = url;
video.load();
}
});
},
// videoRefs are stable refs
// eslint-disable-next-line react-hooks/exhaustive-deps
[clips]
);
const nextIndex = useCallback(
(i: number) => (i + 1 < clips.length ? i + 1 : loopRef.current ? 0 : null),
[clips.length]
);
const preloadNext = useCallback(
(fromIndex: number) => {
const n = nextIndex(fromIndex);
if (n == null) return;
const standby = (1 - activeRef.current) as Slot;
void prepare(standby, n, undefined, PREROLL_FRAMES);
},
[nextIndex, prepare]
);
const show = useCallback(
(slot: Slot, i: number) => {
activeRef.current = slot;
indexRef.current = i;
setActive(slot);
setIndex(i);
// Straight on the DOM, not via a re-render: a render later, the outgoing
// clip would already have painted a frame past its cut.
const other = videoRefs[1 - slot].current;
if (other) {
other.pause();
other.muted = true;
other.style.opacity = "0";
}
const video = videoRefs[slot].current;
if (video) {
video.style.opacity = "1";
video.muted = muted;
setFrame(timeToFrame(video.currentTime, clips[i].fps));
}
shownId.current = clips[i].id;
onClipChange?.(clips[i], i);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[clips, muted, onClipChange]
);
/** Jump to clip `i` (at a media frame, default its cut in). */
const goTo = useCallback(
async (i: number, frameInMedia?: number, autoplay = playingRef.current) => {
if (!clips[i]) return;
generation.current += 1;
const gen = generation.current;
// Always via the hidden slot: the picture on screen stays put while the
// clip loads (loading into the visible slot flashed its slate), and when
// it already holds this clip — usually the next one — it's just a seek.
// A seek within the clip on screen stays where it is.
const onScreen = loaded.current[activeRef.current];
const slot =
onScreen?.index === i && onScreen.url === srcForRef.current(clips[i])
? activeRef.current
: ((1 - activeRef.current) as Slot);
videoRefs[0].current?.pause();
videoRefs[1].current?.pause();
setBuffering(true);
const ok = await prepare(slot, i, frameInMedia);
if (gen !== generation.current) return;
setBuffering(false);
if (!ok) return;
show(slot, i);
if (autoplay) {
playingRef.current = true;
setPlaying(true);
videoRefs[slot].current?.play().catch(() => {});
}
preloadNext(i);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[clips, prepare, show, preloadNext]
);
/** Put the (already playing) standby clip on screen. */
const cutTo = useCallback(
(slot: Slot, i: number) => {
videoRefs[1 - slot].current?.pause();
show(slot, i);
setTimeout(() => {
if (indexRef.current === i && activeRef.current === slot) preloadNext(i);
}, PRELOAD_DELAY_MS);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[show, preloadNext]
);
/**
* Cut to the next clip now — the fallback when it wasn't pre-rolled (no
* requestVideoFrameCallback, or it wasn't loaded in time).
*/
const advance = useCallback(async () => {
const i = indexRef.current;
const n = nextIndex(i);
const slot = activeRef.current;
const current = videoRefs[slot].current;
if (n == null) {
// End of the playlist: hold the last frame of the cut.
current?.pause();
if (current) current.currentTime = frameToTime(spanOf(i).end - 1 + SEEK_BIAS, clips[i].fps);
playingRef.current = false;
setPlaying(false);
return;
}
const standby = (1 - slot) as Slot;
const ready = loaded.current[standby];
const standbyVideo = videoRefs[standby].current;
if (ready?.index === n && ready.cutIn != null && standbyVideo && standbyVideo.readyState >= 2) {
// Parked short of its cut for a pre-roll: bring it up to the cut in.
if (ready.parkedAt !== ready.cutIn || standbyVideo.seeking) {
const ok = await prepare(standby, n);
if (!ok || !playingRef.current || indexRef.current !== i) return;
}
standbyVideo.play().catch(() => {});
cutTo(standby, n);
} else {
// Next clip not ready (slow network): load it, then carry on.
void goTo(n, undefined, true);
}
}, [nextIndex, spanOf, clips, prepare, cutTo, goTo]);
// Playing: watch the active clip for its out-point, pre-roll the next one
// so it arrives on its cut in-point as the out-point passes, and keep the
// frame readout live.
//
// A frame is reported as it goes to the screen, so waiting to see the first
// handle frame would mean it had already been shown. Instead the active
// clip pauses on the last frame of its cut (holding it, never decoding the
// handle), and the cut happens when the next clip reports its cut in-point.
useEffect(() => {
if (!playing) return;
const video = videoRefs[active].current;
const clip = clips[index];
if (!video || !clip) return;
const end = spanOf(index).end;
const n = nextIndex(index);
const standbySlot = (1 - active) as Slot;
const standbyVideo = videoRefs[standbySlot].current;
const hasRvfc = "requestVideoFrameCallback" in HTMLVideoElement.prototype;
let disposed = false;
let held = false; // active clip paused on its last cut frame
let prerolling = false;
let didCut = false;
let handle = 0;
let standbyHandle = 0;
let cutTimer: ReturnType<typeof setTimeout> | undefined;
let prerollTimer: ReturnType<typeof setTimeout> | undefined;
const standbyReady = () => {
const l = loaded.current[standbySlot];
return n != null &&
hasRvfc &&
standbyVideo &&
l?.index === n &&
l.cutIn != null &&
l.parkedAt != null &&
l.parkedAt <= l.cutIn &&
standbyVideo.readyState >= 2 &&
!standbyVideo.seeking
? { l: l as Required<Loaded>, sv: standbyVideo }
: null;
};
/** `cutAt`: when the next clip's cut in-point should reach the screen. */
const startPreroll = (r: NonNullable<ReturnType<typeof standbyReady>>, cutAt: number) => {
if (disposed || prerolling || standbyReady() == null) return;
prerolling = true;
const onStandbyFrame = (_now: number, meta: VideoFrameCallbackMetadata) => {
if (disposed) return;
if (timeToFrame(meta.mediaTime, clips[r.l.index].fps) >= r.l.cutIn) {
// Calibrate: arrived early → start later next time, and vice versa.
const error = meta.expectedDisplayTime - cutAt;
if (Math.abs(error) < 250) {
startLatency.current = Math.min(
300,
Math.max(-100, startLatency.current + error / 2)
);
}
didCut = true;
cutTo(standbySlot, r.l.index);
return;
}
standbyHandle = r.sv.requestVideoFrameCallback(onStandbyFrame);
};
standbyHandle = r.sv.requestVideoFrameCallback(onStandbyFrame);
r.sv.play().catch(() => {});
};
const check = (mediaTime: number, shownAt: number) => {
const f = timeToFrame(mediaTime, clip.fps);
setFrame(f);
const frameMs = 1000 / clip.fps / (video.playbackRate || 1);
const now = performance.now();
// When the out-point would reach the screen.
const cutAt = shownAt + (end - f) * frameMs;
// Start the next clip `lead` before the cut — on a timer, as frame
// callbacks only come once a frame.
if (!prerolling && prerollTimer === undefined) {
const r = standbyReady();
if (r) {
const lead =
startLatency.current + (r.l.cutIn - r.l.parkedAt) * (1000 / clips[r.l.index].fps);
const startIn = cutAt - now - lead;
if (startIn < frameMs * 2) {
prerollTimer = setTimeout(() => startPreroll(r, cutAt), Math.max(0, startIn));
}
}
}
if (f >= end - 1) {
held = true;
video.pause();
// Not pre-rolled: cut on time by hand. Pre-rolled: the next clip cuts
// itself in; this only catches it never arriving.
const wait =
Math.max(0, cutAt - now) + (prerolling || prerollTimer !== undefined ? 500 : 0);
cutTimer = setTimeout(() => {
if (!disposed && !didCut && playingRef.current) void advance();
}, wait);
return true;
}
return false;
};
if (hasRvfc) {
const onFrame = (_now: number, meta: VideoFrameCallbackMetadata) => {
if (disposed || check(meta.mediaTime, meta.expectedDisplayTime)) return;
handle = video.requestVideoFrameCallback(onFrame);
};
handle = video.requestVideoFrameCallback(onFrame);
} else {
const tick = () => {
if (disposed || check(video.currentTime, performance.now())) return;
handle = requestAnimationFrame(tick);
};
handle = requestAnimationFrame(tick);
}
// Media that ends before the computed out-point (duration rounding).
const onEnded = () => {
if (!held && !prerolling) {
held = true;
void advance();
}
};
video.addEventListener("ended", onEnded);
return () => {
disposed = true;
clearTimeout(cutTimer);
clearTimeout(prerollTimer);
video.removeEventListener("ended", onEnded);
if (hasRvfc) {
video.cancelVideoFrameCallback(handle);
standbyVideo?.cancelVideoFrameCallback(standbyHandle);
} else {
cancelAnimationFrame(handle);
}
// Stopped mid pre-roll (paused, skipped): park the next clip again.
if (prerolling && !didCut && standbyVideo && n != null) {
standbyVideo.pause();
void prepare(standbySlot, n, undefined, PREROLL_FRAMES);
}
};
// spans are read live; re-arm when the active clip changes
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [playing, active, index, clips, advance, cutTo]);
// A new clip list: stay on the clip on screen if it's still in the list
// (a shot checked or unchecked mid-review), else start from the first.
useEffect(() => {
if (!clips.length) return;
const video = videoRefs[activeRef.current].current;
const keep = shownId.current ? clips.findIndex((c) => c.id === shownId.current) : -1;
loaded.current = [null, null];
if (keep >= 0 && video) {
void goTo(keep, timeToFrame(video.currentTime, clips[keep].fps));
} else {
playingRef.current = false;
setPlaying(false);
void goTo(0, undefined, false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [clips]);
// Quality switch: reload the current clip at the same frame.
const lastHq = useRef(hq);
useEffect(() => {
if (lastHq.current === hq) return;
lastHq.current = hq;
const video = videoRefs[activeRef.current].current;
const clip = clips[indexRef.current];
if (!video || !clip) return;
const at = timeToFrame(video.currentTime, clip.fps);
loaded.current = [null, null];
void goTo(indexRef.current, at);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hq]);
// Reloading the standby when loop is toggled at the end of the list.
useEffect(() => {
if (clips.length) preloadNext(indexRef.current);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [loop]);
useEffect(() => {
const video = videoRefs[activeRef.current].current;
if (video) video.muted = muted;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [muted]);
// ── Transport ──────────────────────────────────────────────────────────────
const play = useCallback(() => {
const i = indexRef.current;
const video = videoRefs[activeRef.current].current;
const clip = clips[i];
if (!video || !clip) return;
const span = spanOf(i);
const f = timeToFrame(video.currentTime, clip.fps);
// Parked on the last frame of the cut (paused at the cut, or at the end
// of the playlist): carry on from the next clip, or start over.
if (f >= span.end - 1) {
if (nextIndex(i) == null) {
void goTo(0, undefined, true);
} else {
playingRef.current = true;
setPlaying(true);
void advance();
}
return;
}
if (f < span.start || f >= span.end) {
video.currentTime = frameToTime(span.start + SEEK_BIAS, clip.fps);
}
playingRef.current = true;
setPlaying(true);
video.play().catch(() => {});
}, [clips, spanOf, nextIndex, goTo, advance]);
const pause = useCallback(() => {
playingRef.current = false;
setPlaying(false);
videoRefs[activeRef.current].current?.pause();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const toggle = useCallback(() => (playingRef.current ? pause() : play()), [play, pause]);
/** Step within the cut; stepping past either end moves to the next/previous clip. */
const step = useCallback(
(delta: number) => {
pause();
const i = indexRef.current;
const video = videoRefs[activeRef.current].current;
const clip = clips[i];
if (!video || !clip) return;
const span = spanOf(i);
const f = timeToFrame(video.currentTime, clip.fps) + delta;
if (f >= span.end) {
if (i + 1 < clips.length) void goTo(i + 1, undefined, false);
return;
}
if (f < span.start) {
if (i > 0) void goTo(i - 1, spanOf(i - 1).end - 1, false);
return;
}
video.currentTime = frameToTime(f + SEEK_BIAS, clip.fps);
setFrame(f);
},
[clips, spanOf, goTo, pause]
);
const prevClip = useCallback(() => {
const i = indexRef.current;
const video = videoRefs[activeRef.current].current;
const clip = clips[i];
// Like a transport: first press returns to the head of this clip.
if (video && clip && timeToFrame(video.currentTime, clip.fps) > spanOf(i).start + 2) {
void goTo(i);
} else {
void goTo(Math.max(0, i - 1));
}
}, [clips, spanOf, goTo]);
const nextClip = useCallback(() => {
const n = nextIndex(indexRef.current);
if (n != null) void goTo(n);
}, [nextIndex, goTo]);
// Keyboard
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const t = e.target as HTMLElement | null;
if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable)) return;
switch (e.key) {
case " ":
case "k":
case "K":
e.preventDefault();
toggle();
break;
case "ArrowRight":
e.preventDefault();
step(1);
break;
case "ArrowLeft":
e.preventDefault();
step(-1);
break;
case "ArrowDown":
e.preventDefault();
nextClip();
break;
case "ArrowUp":
e.preventDefault();
prevClip();
break;
case "Home":
e.preventDefault();
void goTo(0);
break;
case "m":
case "M":
setMuted((m) => !m);
break;
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [toggle, step, nextClip, prevClip, goTo]);
// ── Timeline ───────────────────────────────────────────────────────────────
const segments = useMemo(() => {
let offset = 0;
return clips.map((clip, i) => {
const span = spanOf(i);
const length = span.end - span.start;
const seg = { clip, span, offset, length };
offset += length;
return seg;
});
// spans refine as clips load (bumpSpans)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [clips, spanOf, spanVersion]);
const totalLength = segments.reduce((sum, s) => sum + s.length, 0) || 1;
const current = segments[index];
const posInClip = current ? Math.min(Math.max(frame - current.span.start, 0), current.length - 1) : 0;
const position = current ? current.offset + posInClip : 0;
const fps = clips[index]?.fps ?? 24;
const seekTimeline = (e: React.MouseEvent<HTMLDivElement>) => {
const rect = e.currentTarget.getBoundingClientRect();
const target = ((e.clientX - rect.left) / rect.width) * totalLength;
const seg =
segments.find((s) => target < s.offset + s.length) ?? segments[segments.length - 1];
if (!seg) return;
const i = segments.indexOf(seg);
const f = seg.span.start + Math.min(seg.length - 1, Math.floor(target - seg.offset));
if (i === indexRef.current) {
const video = videoRefs[activeRef.current].current;
if (video) {
video.currentTime = frameToTime(f + SEEK_BIAS, seg.clip.fps);
setFrame(f);
}
} else {
void goTo(i, f);
}
};
const anyHq = clips.some((c) => c.hqUrl);
if (!clips.length) return null;
return (
<div className="flex flex-col flex-1 min-h-0 bg-black">
{/* Stage */}
<div className="relative flex-1 min-h-0" onClick={toggle}>
{[0, 1].map((slot) => (
<video
key={slot}
ref={videoRefs[slot]}
playsInline
muted
preload="auto"
// Initial stacking only; show() swaps opacity on the DOM from then on.
style={{ opacity: slot === 0 ? 1 : 0 }}
className="absolute inset-0 w-full h-full object-contain"
/>
))}
{current && (
<div className="absolute top-3 left-3 pointer-events-none flex items-center gap-2 rounded bg-black/60 px-2 py-1">
<span className="font-mono text-xs font-semibold text-white">{current.clip.label}</span>
{current.clip.sublabel && (
<span className="text-[10px] text-zinc-400">{current.clip.sublabel}</span>
)}
<span className="text-[10px] text-zinc-500">
{index + 1}/{clips.length}
</span>
</div>
)}
{buffering && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<Loader2 className="h-6 w-6 animate-spin text-zinc-400" />
</div>
)}
</div>
{/* Timeline: one segment per clip, sized by its cut length */}
<div className="px-3 pt-2 shrink-0">
<div
className="relative h-7 flex rounded overflow-hidden cursor-pointer bg-zinc-900 select-none"
onClick={seekTimeline}
>
{segments.map((s, i) => (
<div
key={s.clip.id}
style={{ width: `${(s.length / totalLength) * 100}%` }}
className={cn(
"h-full border-r border-black/70 px-1 flex items-center overflow-hidden",
i === index ? "bg-blue-500/30" : i % 2 ? "bg-zinc-800" : "bg-zinc-800/60"
)}
title={`${s.clip.label} · ${s.length} fr`}
>
<span className="text-[10px] font-mono text-zinc-300 truncate">{s.clip.label}</span>
</div>
))}
<div
className="absolute top-0 bottom-0 w-px bg-white pointer-events-none"
style={{ left: `${(position / totalLength) * 100}%` }}
/>
</div>
</div>
{/* Transport */}
<div className="flex items-center gap-1 px-3 py-2 shrink-0 text-zinc-300">
<TransportButton onClick={prevClip} title="Previous shot (↑)">
<ChevronFirst className="h-4 w-4" />
</TransportButton>
<TransportButton onClick={toggle} title="Play / pause (Space)">
{playing ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
</TransportButton>
<TransportButton onClick={nextClip} title="Next shot (↓)">
<ChevronLast className="h-4 w-4" />
</TransportButton>
<span className="ml-2 font-mono text-xs tabular-nums text-zinc-400">
{frameToTimecode(position, fps)}
<span className="text-zinc-600"> / {frameToTimecode(totalLength, fps)}</span>
</span>
<span className="ml-3 font-mono text-[10px] text-zinc-600 tabular-nums">
shot fr {posInClip + 1}/{current?.length ?? 0}
</span>
<div className="ml-auto flex items-center gap-1">
{anyHq && (
<button
onClick={() => setHq((v) => !v)}
onMouseDown={(e) => e.preventDefault()}
className={cn(
"px-2 h-7 rounded text-[10px] font-semibold uppercase tracking-wider border",
hq
? "border-blue-500/40 text-blue-300 bg-blue-500/10"
: "border-zinc-700 text-zinc-500 hover:text-zinc-300"
)}
title="High quality where available"
>
HQ
</button>
)}
<TransportButton
onClick={() => setLoop((v) => !v)}
title="Loop playlist"
active={loop}
>
<Repeat className="h-4 w-4" />
</TransportButton>
<TransportButton onClick={() => setMuted((m) => !m)} title="Mute (M)">
{muted ? <VolumeX className="h-4 w-4" /> : <Volume2 className="h-4 w-4" />}
</TransportButton>
</div>
</div>
</div>
);
}
function TransportButton({
onClick,
title,
active,
children,
}: {
onClick: () => void;
title: string;
active?: boolean;
children: React.ReactNode;
}) {
return (
<button
onClick={onClick}
// Keep focus off the button so Space stays play/pause.
onMouseDown={(e) => e.preventDefault()}
title={title}
className={cn(
"h-8 w-8 flex items-center justify-center rounded hover:bg-zinc-800 transition-colors",
active ? "text-blue-400" : "text-zinc-300"
)}
>
{children}
</button>
);
}
+33 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { computeEditRange, frameRegion, isHandleFrame } from "./handles";
import { computeEditRange, cutSpan, frameRegion, isHandleFrame } from "./handles";
describe("computeEditRange", () => {
it("is null when no handles are marked", () => {
@@ -92,3 +92,35 @@ describe("frameRegion", () => {
expect(isHandleFrame(range, 1)).toBe(true);
});
});
describe("cutSpan", () => {
it("plays the cut when handles are marked, skipping slate and handles", () => {
// 1 slate + 8 head + 84 cut + 8 tail = 101 MP4 frames
expect(cutSpan({ handleHead: 8, handleTail: 8, frameCount: 100 }, 101)).toEqual({
start: 9,
end: 93,
});
});
it("plays the whole picture minus the slate when no handles are marked", () => {
expect(cutSpan({ frameCount: 100 }, 101)).toEqual({ start: 1, end: 101 });
expect(cutSpan({ pictureStartFrame: 2 }, 50)).toEqual({ start: 2, end: 50 });
});
it("plays the whole media when nothing is known about it", () => {
expect(cutSpan(null, 48)).toEqual({ start: 0, end: 48 });
expect(cutSpan({}, 48)).toEqual({ start: 0, end: 48 });
});
it("is null until the media length is known", () => {
expect(cutSpan({ handleHead: 8, handleTail: 8 }, 0)).toBeNull();
});
it("never returns an empty span", () => {
// Handles longer than the picture
expect(cutSpan({ handleHead: 30, handleTail: 30, frameCount: 40 }, 40)).toEqual({
start: 0,
end: 40,
});
});
});
+41 -3
View File
@@ -61,6 +61,21 @@ export function computeEditRange(
const tail = Math.max(0, Math.floor(handles.handleTail ?? 0));
if (head === 0 && tail === 0) return null;
const { pictureStart, pictureEnd } = pictureBounds(handles, totalFrames);
const cutIn = Math.min(pictureStart + head, pictureEnd);
const tailStart = Math.max(cutIn, pictureEnd - tail);
return { pictureStart, cutIn, tailStart, pictureEnd };
}
/**
* Where the picture sits in the media: after any slate, up to the end of the
* rendered frames. See computeEditRange for how the slate is found.
*/
function pictureBounds(
handles: VersionHandles,
totalFrames: number
): { pictureStart: number; pictureEnd: number } {
const frameCount =
handles.frameCount != null && handles.frameCount > 0 ? Math.floor(handles.frameCount) : null;
@@ -80,10 +95,33 @@ export function computeEditRange(
const pictureEnd =
frameCount != null ? Math.min(totalFrames, pictureStart + frameCount) : totalFrames;
const cutIn = Math.min(pictureStart + head, pictureEnd);
const tailStart = Math.max(cutIn, pictureEnd - tail);
return { pictureStart, pictureEnd };
}
return { pictureStart, cutIn, tailStart, pictureEnd };
/** A half-open frame range [start, end) of the review media. */
export interface FrameSpan {
start: number;
end: number;
}
/**
* The frames a seamless playlist plays for this version: the cut when handles
* are marked, otherwise the whole picture — never the slate. Falls back to
* the whole media rather than an empty span, so a clip never plays nothing.
*/
export function cutSpan(
handles: VersionHandles | null | undefined,
totalFrames: number
): FrameSpan | null {
if (!(totalFrames > 0)) return null;
const edit = computeEditRange(handles, totalFrames);
const span = edit
? { start: edit.cutIn, end: edit.tailStart }
: (() => {
const { pictureStart, pictureEnd } = pictureBounds(handles ?? {}, totalFrames);
return { start: pictureStart, end: pictureEnd };
})();
return span.end > span.start ? span : { start: 0, end: totalFrames };
}
export type FrameRegion = "slate" | "head" | "edit" | "tail";