Files
vfxreview/actions/versions.ts
T
twotalesanimation 2e9fa45580
Deploy / deploy (push) Successful in 3m31s
feat(review): flag handles in the review player (metadata only)
Renders run 8 frames past the cut at each end. Directors were flagging
glitches in unfixed handles as shot problems, so the player now marks
them — without trimming or burning anything into the EXRs/MOV/MP4.

- Version.handleHead/handleTail/pictureStartFrame, Export.handleHead/
  handleTail; migration backfills pipeline exports and their preview
  versions from the manifest's handleFrames (else 8)
- AE panel: "Includes handles [8] frames each end" -> manifest.review;
  older manifests fall back to source.handleFrames
- finalize copies handles onto the Version; worker 1.6.4 reports the
  head slate length (pictureStartSeconds), else the player infers it
  as MP4 frames - EXR frameCount
- Player: hatched HANDLE / SLATE regions and cut marks on the timeline,
  "HEAD/TAIL HANDLE · NOT IN EDIT" overlay on handle frames, HANDLE badge
  on notes, "Edit range only" toggle (default on in the client portal)
- Staff Handles editor on the review page for manual uploads/corrections

Also fixes two player bugs found while testing: timeToFrame floored
float error (every seek landed a frame early), and J/reverse never ran
(the keyboard effect's cleanup stopped it on the next render).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 15:07:29 +02:00

294 lines
9.4 KiB
TypeScript

"use server";
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { recalcShotStatus, clearStaleShotApproval } from "@/lib/shot-status";
import { notifyNewVersionUploaded } from "@/lib/notifications";
import { slackNotifyVersionUploaded } from "@/lib/slack";
const createVersionSchema = z.object({
taskId: z.string().cuid(),
fileUrl: z.string().min(1),
fileName: z.string(),
fileSize: z.number().optional(),
mimeType: z.string().optional(),
thumbnailUrl: z.string().min(1).optional().or(z.literal("")),
fps: z.number().default(24),
duration: z.number().optional(),
frameCount: z.number().int().optional(),
width: z.number().int().optional(),
height: z.number().int().optional(),
notes: z.string().optional(),
});
export async function createVersion(data: z.infer<typeof createVersionSchema>) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
const parsed = createVersionSchema.parse(data);
// All versions must belong to a task
const task = await db.task.findUnique({
where: { id: parsed.taskId },
include: { project: true, shot: { select: { id: true, shotCode: true } } },
});
if (!task) throw new Error("Task not found");
const projectId = task.projectId;
const slackWebhook = task.project.slackWebhook ?? undefined;
const projectName = task.project.name;
const shotCode = task.shot?.shotCode;
// Mark all existing versions for this task as not latest
await db.version.updateMany({ where: { taskId: parsed.taskId }, data: { isLatest: false } });
// Move task to INTERNAL_REVIEW on upload
await db.task.update({
where: { id: parsed.taskId },
data: { status: "INTERNAL_REVIEW" },
});
// Recalculate shot status — clearing any stale sign-off first, so a new
// version does not land under a shot still reading COMPLETE.
if (task.shot) {
const shotId = task.shot.id;
await db
.$transaction(async (tx) => {
await clearStaleShotApproval(tx, shotId);
await recalcShotStatus(shotId, tx);
})
.catch(() => {});
}
// Get the next version number for this task
const lastVersion = await db.version.findFirst({
where: { taskId: parsed.taskId },
orderBy: { versionNumber: "desc" },
});
const versionNumber = (lastVersion?.versionNumber ?? 0) + 1;
// Create the new version
const version = await db.version.create({
data: {
versionNumber,
taskId: parsed.taskId,
artistId: session.user.id,
fileUrl: parsed.fileUrl,
fileName: parsed.fileName,
fileSize: parsed.fileSize ? BigInt(parsed.fileSize) : undefined,
mimeType: parsed.mimeType,
thumbnailUrl: parsed.thumbnailUrl || undefined,
fps: parsed.fps,
duration: parsed.duration,
frameCount: parsed.frameCount,
width: parsed.width,
height: parsed.height,
notes: parsed.notes,
isLatest: true,
},
});
// Send notifications
const user = await db.user.findUnique({ where: { id: session.user.id } });
const versionLabelStr = `v${String(versionNumber).padStart(3, "0")}`;
if (shotCode) {
await notifyNewVersionUploaded({
shotCode,
versionNumber,
projectId,
versionId: version.id,
artistName: user?.name ?? session.user.email ?? "Artist",
});
}
// Slack notification
if (slackWebhook && shotCode) {
const appUrl = process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000";
await slackNotifyVersionUploaded(slackWebhook, {
shotCode,
versionLabel: versionLabelStr,
artistName: user?.name ?? "Artist",
projectName,
reviewUrl: `${appUrl}/review/${version.id}`,
});
}
revalidatePath(`/projects/${projectId}`);
if (task.shot) revalidatePath(`/projects/${projectId}/shots/${task.shot.id}`);
revalidatePath(`/tasks/${parsed.taskId}`);
return { success: true, version, versionNumber };
}
/**
* Share a version with the client — marks it as client-visible,
* records who shared it, and moves the associated task to CLIENT_REVIEW.
*/
export async function shareVersionWithClient(versionId: string) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
if (!["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role)) {
throw new Error("Insufficient permissions");
}
const version = await db.version.findUnique({
where: { id: versionId },
select: { id: true, taskId: true, isClientVisible: true },
});
if (!version) throw new Error("Version not found");
await db.version.update({
where: { id: versionId },
data: {
isClientVisible: true,
sharedAt: new Date(),
sharedById: session.user.id,
},
});
// Move task to CLIENT_REVIEW if it is in INTERNAL_REVIEW; recalc shot status
if (version.taskId) {
const task = await db.task.findUnique({
where: { id: version.taskId },
select: { status: true, projectId: true, shotId: true },
});
if (task && task.status === "INTERNAL_REVIEW") {
await db.task.update({
where: { id: version.taskId },
data: { status: "CLIENT_REVIEW" },
});
if (task.shotId) {
await recalcShotStatus(task.shotId).catch(() => {});
}
revalidatePath(`/tasks/${version.taskId}`);
revalidatePath(`/projects/${task.projectId}`);
}
}
revalidatePath(`/review/${versionId}`);
return { success: true };
}
export async function deleteVersion(versionId: string) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
if (!["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role)) {
throw new Error("Insufficient permissions");
}
const version = await db.version.findUnique({
where: { id: versionId },
select: {
id: true,
isLatest: true,
taskId: true,
task: { select: { projectId: true, shotId: true } },
},
});
if (!version) throw new Error("Version not found");
// If this was the latest version, promote the next most recent one
if (version.isLatest && version.taskId) {
const next = await db.version.findFirst({
where: { taskId: version.taskId, id: { not: versionId } },
orderBy: { versionNumber: "desc" },
select: { id: true },
});
if (next) {
await db.version.update({ where: { id: next.id }, data: { isLatest: true } });
}
}
await db.version.delete({ where: { id: versionId } });
const projectId = version.task?.projectId;
const shotId = version.task?.shotId;
if (shotId) await recalcShotStatus(shotId).catch(() => {});
if (projectId) revalidatePath(`/projects/${projectId}`);
if (version.taskId) revalidatePath(`/tasks/${version.taskId}`);
if (shotId && projectId) revalidatePath(`/projects/${projectId}/shots/${shotId}`);
return { success: true };
}
export async function getVersionById(versionId: string) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
return db.version.findUnique({
where: { id: versionId },
include: {
task: {
include: {
project: { include: { client: true } },
shot: true,
asset: true,
versions: {
orderBy: { versionNumber: "desc" },
select: { id: true, versionNumber: true, approvalStatus: true, createdAt: true },
},
},
},
artist: { select: { id: true, name: true, email: true, image: true } },
approvals: {
include: { user: { select: { id: true, name: true, role: true } } },
orderBy: { createdAt: "desc" },
},
},
});
}
export async function getVersionComments(versionId: string) {
return db.comment.findMany({
where: { versionId },
include: {
author: { select: { id: true, name: true, email: true, image: true, role: true } },
replies: {
include: {
author: { select: { id: true, name: true, email: true, image: true, role: true } },
},
orderBy: { createdAt: "asc" },
},
annotations: {
select: { id: true, frameNumber: true, drawingData: true, color: true, isVisible: true, authorId: true },
},
},
orderBy: { frameNumber: "asc" },
});
}
const updateVersionHandlesSchema = z.object({
versionId: z.string().cuid(),
/** 0/0 = no handles (every frame is in the edit). */
handleHead: z.number().int().min(0).max(1000),
handleTail: z.number().int().min(0).max(1000),
/** Slate frames before the picture; null = infer from the media. */
pictureStartFrame: z.number().int().min(0).max(1000).nullable(),
});
/**
* Marks which frames of a version are handles (past the cut at each end), so
* the review player can flag them. Review metadata only — the media and any
* delivered files are untouched. Staff only: a client can't redefine the cut.
*/
export async function updateVersionHandles(data: z.infer<typeof updateVersionHandlesSchema>) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
if (session.user.role === "CLIENT") throw new Error("Forbidden");
const parsed = updateVersionHandlesSchema.parse(data);
const none = parsed.handleHead === 0 && parsed.handleTail === 0;
await db.version.update({
where: { id: parsed.versionId },
data: {
handleHead: none ? null : parsed.handleHead,
handleTail: none ? null : parsed.handleTail,
pictureStartFrame: parsed.pictureStartFrame,
},
});
revalidatePath(`/review/${parsed.versionId}`);
}