Files
vfxreview/app/api/playlist/route.ts
T
twotalesanimation b596c97faf
Deploy / deploy (push) Successful in 3m12s
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 <noreply@anthropic.com>
2026-09-26 08:55:46 +02:00

109 lines
3.2 KiB
TypeScript

import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { shotThumbnail } from "@/lib/shot-thumbnail";
export async function GET(req: NextRequest) {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const projectId = req.nextUrl.searchParams.get("projectId");
if (!projectId) {
return NextResponse.json({ error: "projectId required" }, { status: 400 });
}
// All versions are linked to tasks (taskId), not directly to shots.
// We query shots with their tasks, and for each task get the latest version.
const shots = await db.shot.findMany({
where: {
projectId,
tasks: {
some: {
versions: { some: { isLatest: true } },
},
},
},
orderBy: [{ episode: "asc" }, { scene: "asc" }, { shotNumber: "asc" }],
select: {
id: true,
shotCode: true,
episode: true,
status: true,
shotApprovalStatus: true,
sharedWithClient: true,
thumbnailUrl: true,
generatedThumbnailUrl: true,
tasks: {
select: {
id: true,
title: true,
versions: {
where: { isLatest: true },
take: 1,
orderBy: { createdAt: "desc" },
select: {
id: true,
versionNumber: true,
fileUrl: true,
thumbnailUrl: true,
posterUrl: true,
fps: true,
duration: true,
frameCount: true,
hqFileUrl: true,
handleHead: true,
handleTail: true,
pictureStartFrame: true,
approvalStatus: true,
mimeType: true,
createdAt: true,
},
},
},
},
},
});
// For each shot, pick the most recently uploaded latest version across all tasks
const playlist = shots
.map((shot) => {
const versions = shot.tasks
.flatMap((t) => t.versions.map((v) => ({ ...v, taskTitle: t.title })))
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
const latest = versions[0] ?? null;
if (!latest) return null;
return {
id: shot.id,
shotCode: shot.shotCode,
episode: shot.episode,
status: shot.status,
shotApprovalStatus: shot.shotApprovalStatus,
sharedWithClient: shot.sharedWithClient,
thumbnailUrl: shotThumbnail(shot),
latestVersion: {
id: latest.id,
versionNumber: latest.versionNumber,
fileUrl: latest.fileUrl,
thumbnailUrl: latest.thumbnailUrl,
posterUrl: latest.posterUrl,
fps: latest.fps,
duration: latest.duration,
frameCount: latest.frameCount,
hqFileUrl: latest.hqFileUrl,
handleHead: latest.handleHead,
handleTail: latest.handleTail,
pictureStartFrame: latest.pictureStartFrame,
approvalStatus: latest.approvalStatus,
taskTitle: latest.taskTitle,
},
};
})
.filter((s): s is NonNullable<typeof s> => s !== null);
return NextResponse.json({ shots: playlist });
}