From b596c97fafd52f843f05079f79de070558e7995c Mon Sep 17 00:00:00 2001 From: twotalesanimation <80506065+twotalesanimation@users.noreply.github.com> Date: Sat, 26 Sep 2026 08:55:46 +0200 Subject: [PATCH] feat(playlist): seamless playback of selected shots, cut only 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 --- app/(dashboard)/playlist/PlaylistClient.tsx | 197 ++++- app/api/playlist/route.ts | 2 + components/player/SequencePlayer.tsx | 822 ++++++++++++++++++++ lib/handles.test.ts | 34 +- lib/handles.ts | 44 +- 5 files changed, 1087 insertions(+), 12 deletions(-) create mode 100644 components/player/SequencePlayer.tsx diff --git a/app/(dashboard)/playlist/PlaylistClient.tsx b/app/(dashboard)/playlist/PlaylistClient.tsx index 207b3c9..86216fa 100644 --- a/app/(dashboard)/playlist/PlaylistClient.tsx +++ b/app/(dashboard)/playlist/PlaylistClient.tsx @@ -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(null); const [isLoading, setIsLoading] = useState(false); const [isActioning, setIsActioning] = useState(false); + /** Shots checked for the seamless playlist, by shot id. */ + const [selected, setSelected] = useState>(new Set()); + const [sequenceMode, setSequenceMode] = useState(false); + const lastChecked = useRef(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) => { + 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( + () => + 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) { + {selected.size > 0 && ( + sequenceMode ? ( + + ) : ( + + ) + )} + {activeShot && (
{activeShot.shotCode} @@ -272,7 +379,9 @@ export function PlaylistClient({ projects, userRole }: PlaylistClientProps) {
{/* Player */}
- {activeShot ? ( + {sequenceMode && clips.length > 0 ? ( + + ) : activeShot ? ( Shots {shots.length > 0 && ( - {shots.length} +
+ {selected.size > 0 ? ( + + ) : ( + + )} + + {selected.size > 0 ? `${selected.size}/${shots.length}` : shots.length} + +
)}
@@ -327,16 +455,25 @@ export function PlaylistClient({ projects, userRole }: PlaylistClientProps) {
{episodeGroups.map(([episode, episodeShots]) => (
-
+
+ {episodeShots.map((shot) => ( 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 ( -
- +
); } + +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 { + 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) { + try { + localStorage.setItem(selectionKey(projectId), JSON.stringify([...selected])); + } catch { + // Storage unavailable: the selection just isn't remembered. + } +} diff --git a/app/api/playlist/route.ts b/app/api/playlist/route.ts index a84e5df..a31e460 100644 --- a/app/api/playlist/route.ts +++ b/app/api/playlist/route.ts @@ -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, diff --git a/components/player/SequencePlayer.tsx b/components/player/SequencePlayer.tsx new file mode 100644 index 0000000..a5c507e --- /dev/null +++ b/components/player/SequencePlayer.tsx @@ -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