caaa333f02
Deploy / deploy (push) Failing after 19m11s
Two independent features landing together: Generated shot thumbnails — a shot can now carry a thumbnail promoted from a render export (Shot.generatedThumbnailUrl/generatedThumbnailFrom) alongside the existing brief-supplied thumbnailUrl, which always takes display precedence (lib/shot-thumbnail.ts) so a promoted frame can only ever fill a gap, never overwrite the brief. Shows with pipelineConfig.autoShotThumbnail promote automatically on every finished export (lib/render-pipeline/preview.ts); promoteExportThumbnail/ clearGeneratedThumbnail (actions/shots.ts) are the manual equivalent for everyone else. Worker-side seek logic for picking the promoted frame is covered by RenderWorker/VFXReviewWorker.Tests/ThumbnailSeekTests.cs. Bulk shot delete — the shot-status page's selection bar gets a "Bulk Actions" dropdown (alongside the existing Change Due Date) with Delete Shots. bulkDeleteShots() (actions/shots.ts) deletes one at a time rather than in a single statement: exports_shotId_fkey is ON DELETE RESTRICT by design, so a shot with renders or deliveries on record is protected and gets skipped and reported by name rather than aborting the whole selection or being silently destroyed along with its history. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1357 lines
45 KiB
TypeScript
1357 lines
45 KiB
TypeScript
"use server";
|
|
|
|
import { auth } from "@/auth";
|
|
import { db } from "@/lib/db";
|
|
import { revalidatePath } from "next/cache";
|
|
import { z } from "zod";
|
|
import { ShotStatus, ShotPriority, ShotApprovalStatus, Prisma } from "@prisma/client";
|
|
import { recalcShotStatus } from "@/lib/shot-status";
|
|
|
|
const createShotSchema = z.object({
|
|
scene: z.string().min(1).max(50).regex(/^[A-Z0-9_]+$/i, "Alphanumeric and underscore only"),
|
|
episode: z.string().max(50).optional(),
|
|
description: z.string().optional(),
|
|
projectId: z.string().cuid(),
|
|
artistId: z.string().cuid().optional().or(z.literal("")),
|
|
priority: z.nativeEnum(ShotPriority).default("NORMAL"),
|
|
fps: z.number().default(24),
|
|
frameStart: z.number().int().optional(),
|
|
frameEnd: z.number().int().optional(),
|
|
dueDate: z.string().optional(),
|
|
thumbnailUrl: z.string().optional(),
|
|
shotGroupName: z.string().max(100).optional(),
|
|
isKeyShot: z.boolean().default(false),
|
|
});
|
|
|
|
export async function createShot(data: z.infer<typeof createShotSchema>) {
|
|
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 parsed = createShotSchema.parse(data);
|
|
const scene = parsed.scene.toUpperCase();
|
|
const episode = parsed.episode?.toUpperCase() ?? null;
|
|
|
|
// Fetch project for showId and projectType
|
|
const project = await db.project.findUnique({
|
|
where: { id: parsed.projectId },
|
|
select: { showId: true, projectType: true },
|
|
});
|
|
if (!project) throw new Error("Project not found");
|
|
if (!project.showId) {
|
|
throw new Error("Project has no Show ID set. Please edit the project to add one.");
|
|
}
|
|
|
|
// For episodic projects, episode is required
|
|
if (project.projectType === "EPISODIC" && !episode) {
|
|
throw new Error("Episode is required for episodic projects.");
|
|
}
|
|
|
|
// Determine shot number scope: projectId + scene (+ episode for episodic)
|
|
const scopeWhere = {
|
|
projectId: parsed.projectId,
|
|
scene,
|
|
...(project.projectType === "EPISODIC" ? { episode } : {}),
|
|
};
|
|
|
|
const maxShot = await db.shot.findFirst({
|
|
where: scopeWhere,
|
|
orderBy: { shotNumber: "desc" },
|
|
select: { shotNumber: true },
|
|
});
|
|
|
|
const shotNumber = (maxShot?.shotNumber ?? 0) + 10;
|
|
const paddedNumber = shotNumber.toString().padStart(4, "0");
|
|
|
|
// Build shot code per naming convention
|
|
const shotCode =
|
|
project.projectType === "EPISODIC" && episode
|
|
? `${project.showId}_${episode}_${scene}_${paddedNumber}`
|
|
: `${project.showId}_${scene}_${paddedNumber}`;
|
|
|
|
const shot = await db.shot.create({
|
|
data: {
|
|
shotCode,
|
|
scene,
|
|
episode,
|
|
shotNumber,
|
|
description: parsed.description,
|
|
projectId: parsed.projectId,
|
|
artistId: parsed.artistId || undefined,
|
|
priority: parsed.priority,
|
|
fps: parsed.fps,
|
|
frameStart: parsed.frameStart,
|
|
frameEnd: parsed.frameEnd,
|
|
dueDate: parsed.dueDate ? new Date(parsed.dueDate) : undefined,
|
|
thumbnailUrl: parsed.thumbnailUrl,
|
|
isKeyShot: parsed.isKeyShot ?? false,
|
|
shotGroupId: parsed.shotGroupName?.trim()
|
|
? (await db.shotGroup.upsert({
|
|
where: { projectId_name: { projectId: parsed.projectId, name: parsed.shotGroupName.trim() } },
|
|
create: { projectId: parsed.projectId, name: parsed.shotGroupName.trim() },
|
|
update: {},
|
|
})).id
|
|
: undefined,
|
|
},
|
|
});
|
|
|
|
revalidatePath(`/projects/${parsed.projectId}`);
|
|
return { success: true, shot };
|
|
}
|
|
|
|
// ── Duplicate Shot ────────────────────────────────────────────────────────────
|
|
|
|
export async function duplicateShot(sourceShotId: 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");
|
|
}
|
|
|
|
// Load source shot with tasks
|
|
const source = await db.shot.findUnique({
|
|
where: { id: sourceShotId },
|
|
include: {
|
|
tasks: {
|
|
select: {
|
|
title: true,
|
|
description: true,
|
|
type: true,
|
|
priority: true,
|
|
estimatedHours: true,
|
|
sortOrder: true,
|
|
dueDate: true,
|
|
assignedArtistId: true,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
if (!source) throw new Error("Shot not found");
|
|
|
|
const project = await db.project.findUnique({
|
|
where: { id: source.projectId },
|
|
select: { showId: true, projectType: true },
|
|
});
|
|
if (!project?.showId) throw new Error("Project has no Show ID");
|
|
|
|
// Determine next shot number in same scene/episode scope
|
|
const scopeWhere = {
|
|
projectId: source.projectId,
|
|
scene: source.scene,
|
|
...(project.projectType === "EPISODIC" ? { episode: source.episode } : {}),
|
|
};
|
|
|
|
const maxShot = await db.shot.findFirst({
|
|
where: scopeWhere,
|
|
orderBy: { shotNumber: "desc" },
|
|
select: { shotNumber: true },
|
|
});
|
|
|
|
const shotNumber = (maxShot?.shotNumber ?? 0) + 10;
|
|
const paddedNumber = shotNumber.toString().padStart(4, "0");
|
|
|
|
const shotCode =
|
|
project.projectType === "EPISODIC" && source.episode
|
|
? `${project.showId}_${source.episode}_${source.scene}_${paddedNumber}`
|
|
: `${project.showId}_${source.scene}_${paddedNumber}`;
|
|
|
|
const newShot = await db.shot.create({
|
|
data: {
|
|
shotCode,
|
|
scene: source.scene,
|
|
episode: source.episode,
|
|
shotNumber,
|
|
sequence: source.sequence,
|
|
description: source.description,
|
|
projectId: source.projectId,
|
|
artistId: source.artistId,
|
|
priority: source.priority,
|
|
fps: source.fps,
|
|
frameStart: source.frameStart,
|
|
frameEnd: source.frameEnd,
|
|
dueDate: source.dueDate,
|
|
thumbnailUrl: source.thumbnailUrl,
|
|
shotGroupId: source.shotGroupId ?? undefined,
|
|
// Duplicate tasks — reset status and schedule fields
|
|
tasks: {
|
|
create: source.tasks.map((t) => ({
|
|
title: t.title,
|
|
description: t.description,
|
|
type: t.type,
|
|
priority: t.priority,
|
|
estimatedHours: t.estimatedHours,
|
|
sortOrder: t.sortOrder,
|
|
dueDate: t.dueDate,
|
|
assignedArtistId: t.assignedArtistId,
|
|
projectId: source.projectId,
|
|
status: "TODO" as const,
|
|
})),
|
|
},
|
|
},
|
|
});
|
|
|
|
revalidatePath(`/projects/${source.projectId}`);
|
|
return { success: true, shot: newShot };
|
|
}
|
|
|
|
// ── Update Shot ───────────────────────────────────────────────────────────────
|
|
|
|
const updateShotSchema = z.object({
|
|
shotId: z.string().cuid(),
|
|
shotCode: z.string().min(1).max(120).regex(/^[A-Z0-9_]+$/i, "Alphanumeric and underscores only").optional(),
|
|
description: z.string().optional(),
|
|
status: z.nativeEnum(ShotStatus).optional(),
|
|
priority: z.nativeEnum(ShotPriority).optional(),
|
|
shotApprovalStatus: z.nativeEnum(ShotApprovalStatus).optional(),
|
|
sharedWithClient: z.boolean().optional(),
|
|
fps: z.number().optional(),
|
|
frameStart: z.number().int().optional().nullable(),
|
|
frameEnd: z.number().int().optional().nullable(),
|
|
dueDate: z.string().optional().nullable(),
|
|
artistId: z.string().cuid().optional().nullable().or(z.literal("")),
|
|
thumbnailUrl: z.string().optional().nullable(),
|
|
originalFootageUrl: z.string().optional().nullable(),
|
|
originalFootageKey: z.string().optional().nullable(),
|
|
});
|
|
|
|
export async function updateShot(data: z.infer<typeof updateShotSchema>) {
|
|
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 parsed = updateShotSchema.parse(data);
|
|
const { shotId, dueDate, artistId, shotCode, ...rest } = parsed;
|
|
|
|
// Check uniqueness if shotCode is being changed
|
|
if (shotCode) {
|
|
const existing = await db.shot.findFirst({
|
|
where: { projectId: (await db.shot.findUnique({ where: { id: shotId }, select: { projectId: true } }))!.projectId, shotCode, id: { not: shotId } },
|
|
select: { id: true },
|
|
});
|
|
if (existing) throw new Error(`Shot code "${shotCode}" is already used in this project.`);
|
|
}
|
|
|
|
const shot = await db.shot.update({
|
|
where: { id: shotId },
|
|
data: {
|
|
...rest,
|
|
...(shotCode ? { shotCode: shotCode.toUpperCase() } : {}),
|
|
dueDate: dueDate ? new Date(dueDate) : dueDate === null ? null : undefined,
|
|
artistId: artistId === "" ? null : artistId,
|
|
},
|
|
});
|
|
|
|
revalidatePath(`/projects/${shot.projectId}`);
|
|
revalidatePath(`/projects/${shot.projectId}/shots/${shotId}`);
|
|
return { success: true, shot };
|
|
}
|
|
|
|
// ── CSV Import ────────────────────────────────────────────────────────────────
|
|
|
|
export async function importShotsFromCsv(
|
|
projectId: string,
|
|
rows: Array<{
|
|
scene: string;
|
|
episode?: string;
|
|
description?: string;
|
|
priority?: string;
|
|
fps?: number;
|
|
frameStart?: number;
|
|
frameEnd?: number;
|
|
}>
|
|
) {
|
|
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 project = await db.project.findUnique({
|
|
where: { id: projectId },
|
|
select: { showId: true, projectType: true },
|
|
});
|
|
if (!project) throw new Error("Project not found");
|
|
if (!project.showId) throw new Error("Project has no Show ID. Add one in Project Settings first.");
|
|
|
|
const VALID_PRIORITIES = ["LOW", "NORMAL", "HIGH", "CRITICAL"];
|
|
const created: string[] = [];
|
|
const errors: string[] = [];
|
|
|
|
for (const row of rows) {
|
|
try {
|
|
const scene = row.scene.trim().toUpperCase();
|
|
if (!scene) { errors.push("Empty scene name — skipped"); continue; }
|
|
|
|
const episode = row.episode?.trim().toUpperCase() || null;
|
|
|
|
if (project.projectType === "EPISODIC" && !episode) {
|
|
errors.push(`${scene}: episode required for episodic project — skipped`);
|
|
continue;
|
|
}
|
|
|
|
const rawPriority = row.priority?.trim().toUpperCase();
|
|
const priority = VALID_PRIORITIES.includes(rawPriority ?? "")
|
|
? (rawPriority as ShotPriority)
|
|
: ShotPriority.NORMAL;
|
|
|
|
const scopeWhere = {
|
|
projectId,
|
|
scene,
|
|
...(project.projectType === "EPISODIC" ? { episode } : {}),
|
|
};
|
|
|
|
const maxShot = await db.shot.findFirst({
|
|
where: scopeWhere,
|
|
orderBy: { shotNumber: "desc" },
|
|
select: { shotNumber: true },
|
|
});
|
|
|
|
const shotNumber = (maxShot?.shotNumber ?? 0) + 10;
|
|
const paddedNumber = shotNumber.toString().padStart(4, "0");
|
|
const shotCode =
|
|
project.projectType === "EPISODIC" && episode
|
|
? `${project.showId}_${episode}_${scene}_${paddedNumber}`
|
|
: `${project.showId}_${scene}_${paddedNumber}`;
|
|
|
|
await db.shot.create({
|
|
data: {
|
|
shotCode,
|
|
scene,
|
|
episode,
|
|
shotNumber,
|
|
description: row.description?.trim() || undefined,
|
|
projectId,
|
|
priority,
|
|
fps: row.fps ?? 24,
|
|
frameStart: row.frameStart ?? undefined,
|
|
frameEnd: row.frameEnd ?? undefined,
|
|
},
|
|
});
|
|
created.push(shotCode);
|
|
} catch (e: unknown) {
|
|
errors.push(`${row.scene}: ${e instanceof Error ? e.message : "Unknown error"}`);
|
|
}
|
|
}
|
|
|
|
revalidatePath(`/projects/${projectId}`);
|
|
return { success: true, created, errors };
|
|
}
|
|
|
|
export async function updateShotStatus(
|
|
shotId: string,
|
|
status: ShotStatus
|
|
) {
|
|
const session = await auth();
|
|
if (!session?.user) throw new Error("Unauthorized");
|
|
|
|
const shot = await db.shot.update({
|
|
where: { id: shotId },
|
|
data: { status },
|
|
include: { project: true },
|
|
});
|
|
|
|
revalidatePath(`/projects/${shot.projectId}`);
|
|
revalidatePath(`/projects/${shot.projectId}/shots/${shotId}`);
|
|
return { success: true };
|
|
}
|
|
|
|
export async function getShotById(shotId: string) {
|
|
const session = await auth();
|
|
if (!session?.user) throw new Error("Unauthorized");
|
|
|
|
return db.shot.findUnique({
|
|
where: { id: shotId },
|
|
include: {
|
|
project: { include: { client: true } },
|
|
artist: { select: { id: true, name: true, email: true, image: true } },
|
|
versions: {
|
|
include: {
|
|
artist: { select: { id: true, name: true, image: true } },
|
|
_count: { select: { comments: true } },
|
|
},
|
|
orderBy: { versionNumber: "desc" },
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
export async function getShotsByProject(projectId: string) {
|
|
const session = await auth();
|
|
if (!session?.user) throw new Error("Unauthorized");
|
|
|
|
return db.shot.findMany({
|
|
where: { projectId },
|
|
include: {
|
|
artist: { select: { id: true, name: true, image: true } },
|
|
versions: {
|
|
where: { isLatest: true },
|
|
take: 1,
|
|
include: { _count: { select: { comments: true } } },
|
|
},
|
|
_count: { select: { versions: true } },
|
|
},
|
|
orderBy: [{ sequence: "asc" }, { shotCode: "asc" }],
|
|
});
|
|
}
|
|
|
|
// ─── Footage Plates ────────────────────────────────────────────────────────────
|
|
|
|
const addFootagePlateSchema = z.object({
|
|
shotId: z.string().min(1),
|
|
fileUrl: z.string().min(1),
|
|
fileKey: z.string().default(""),
|
|
fileName: z.string().default(""),
|
|
fileSize: z.number().int().positive().optional(),
|
|
label: z.string().max(100).default(""),
|
|
});
|
|
|
|
export async function addFootagePlate(raw: unknown) {
|
|
const session = await auth();
|
|
if (!session?.user) throw new Error("Unauthorized");
|
|
|
|
const data = addFootagePlateSchema.parse(raw);
|
|
|
|
// Determine next sortOrder
|
|
const last = await db.footagePlate.findFirst({
|
|
where: { shotId: data.shotId },
|
|
orderBy: { sortOrder: "desc" },
|
|
select: { sortOrder: true },
|
|
});
|
|
|
|
const plate = await db.footagePlate.create({
|
|
data: {
|
|
shotId: data.shotId,
|
|
fileUrl: data.fileUrl,
|
|
fileKey: data.fileKey,
|
|
fileName: data.fileName,
|
|
fileSize: data.fileSize != null ? BigInt(data.fileSize) : null,
|
|
label: data.label,
|
|
sortOrder: (last?.sortOrder ?? -1) + 1,
|
|
},
|
|
});
|
|
|
|
const shot = await db.shot.findUnique({ where: { id: data.shotId }, select: { projectId: true } });
|
|
if (shot) {
|
|
revalidatePath(`/projects/${shot.projectId}`);
|
|
revalidatePath(`/projects/${shot.projectId}/shots/${data.shotId}`);
|
|
}
|
|
|
|
return { success: true, plate: { ...plate, fileSize: plate.fileSize?.toString() ?? null } };
|
|
}
|
|
|
|
export async function removeFootagePlate(plateId: string) {
|
|
const session = await auth();
|
|
if (!session?.user) throw new Error("Unauthorized");
|
|
|
|
const role = session.user.role as string;
|
|
if (!["ADMIN", "PRODUCER", "SUPERVISOR"].includes(role)) throw new Error("Insufficient permissions");
|
|
|
|
const plate = await db.footagePlate.findUnique({
|
|
where: { id: plateId },
|
|
include: { shot: { select: { projectId: true } } },
|
|
});
|
|
if (!plate) throw new Error("Plate not found");
|
|
|
|
await db.footagePlate.delete({ where: { id: plateId } });
|
|
|
|
revalidatePath(`/projects/${plate.shot.projectId}`);
|
|
revalidatePath(`/projects/${plate.shot.projectId}/shots/${plate.shotId}`);
|
|
|
|
return { success: true };
|
|
}
|
|
|
|
// ── Update Shot Notes ─────────────────────────────────────────────────────────
|
|
|
|
export async function updateShotNotes(shotId: string, notes: 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 shot = await db.shot.findUnique({
|
|
where: { id: shotId },
|
|
select: { projectId: true },
|
|
});
|
|
if (!shot) throw new Error("Shot not found");
|
|
|
|
await db.shot.update({
|
|
where: { id: shotId },
|
|
data: { notes: notes.trim() || null },
|
|
});
|
|
|
|
revalidatePath(`/projects/${shot.projectId}`);
|
|
revalidatePath(`/shot-status`);
|
|
return { success: true };
|
|
}
|
|
|
|
// ── Update Shot Slate Fields ──────────────────────────────────────────────────
|
|
|
|
export async function updateShotSlate(
|
|
shotId: string,
|
|
data: {
|
|
slateType?: string | null;
|
|
slateDescription?: string | null;
|
|
slateVfxScope?: string | null;
|
|
slateSubmissionNote?: string | null;
|
|
clientCode?: string | null;
|
|
}
|
|
) {
|
|
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 shot = await db.shot.findUnique({ where: { id: shotId }, select: { projectId: true } });
|
|
if (!shot) throw new Error("Shot not found");
|
|
|
|
await db.shot.update({
|
|
where: { id: shotId },
|
|
data: {
|
|
slateType: data.slateType !== undefined ? (data.slateType?.trim() || null) : undefined,
|
|
slateDescription: data.slateDescription !== undefined ? (data.slateDescription?.trim() || null) : undefined,
|
|
slateVfxScope: data.slateVfxScope !== undefined ? (data.slateVfxScope?.trim() || null) : undefined,
|
|
slateSubmissionNote: data.slateSubmissionNote !== undefined ? (data.slateSubmissionNote?.trim() || null) : undefined,
|
|
clientCode: data.clientCode !== undefined ? (data.clientCode?.trim() || null) : undefined,
|
|
},
|
|
});
|
|
|
|
revalidatePath(`/projects/${shot.projectId}`);
|
|
return { success: true };
|
|
}
|
|
|
|
// ── Toggle Key Shot ───────────────────────────────────────────────────────────
|
|
|
|
export async function toggleKeyShot(shotId: string, isKeyShot: boolean) {
|
|
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 shot = await db.shot.findUnique({
|
|
where: { id: shotId },
|
|
select: { projectId: true },
|
|
});
|
|
if (!shot) throw new Error("Shot not found");
|
|
|
|
await db.shot.update({
|
|
where: { id: shotId },
|
|
data: { isKeyShot },
|
|
});
|
|
|
|
revalidatePath(`/projects/${shot.projectId}`);
|
|
revalidatePath(`/shot-status`);
|
|
return { success: true };
|
|
}
|
|
|
|
export async function deleteShot(shotId: string) {
|
|
const session = await auth();
|
|
if (!session?.user) throw new Error("Unauthorized");
|
|
|
|
const role = session.user.role as string;
|
|
if (!["ADMIN", "PRODUCER", "SUPERVISOR"].includes(role)) throw new Error("Insufficient permissions");
|
|
|
|
const shot = await db.shot.findUnique({
|
|
where: { id: shotId },
|
|
select: { projectId: true },
|
|
});
|
|
if (!shot) throw new Error("Shot not found");
|
|
|
|
await db.shot.delete({ where: { id: shotId } });
|
|
|
|
revalidatePath(`/projects/${shot.projectId}`);
|
|
|
|
return { success: true, projectId: shot.projectId };
|
|
}
|
|
|
|
// ── Bulk Update Due Dates ─────────────────────────────────────────────────────
|
|
|
|
export async function bulkUpdateDueDates(
|
|
shotIds: string[],
|
|
dueDate: string,
|
|
includeTasks: boolean,
|
|
) {
|
|
const session = await auth();
|
|
if (!session?.user) throw new Error("Unauthorized");
|
|
if (!["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role)) {
|
|
throw new Error("Insufficient permissions");
|
|
}
|
|
|
|
if (!shotIds.length) return { success: true, updated: 0 };
|
|
|
|
const date = new Date(dueDate);
|
|
|
|
const shots = await db.shot.findMany({
|
|
where: { id: { in: shotIds } },
|
|
select: { id: true, projectId: true },
|
|
});
|
|
if (shots.length !== shotIds.length) throw new Error("Some shots not found");
|
|
|
|
const projectIds = [...new Set(shots.map((s) => s.projectId))];
|
|
|
|
await db.shot.updateMany({
|
|
where: { id: { in: shotIds } },
|
|
data: { dueDate: date },
|
|
});
|
|
|
|
if (includeTasks) {
|
|
await db.task.updateMany({
|
|
where: { shotId: { in: shotIds } },
|
|
data: { dueDate: date },
|
|
});
|
|
}
|
|
|
|
for (const pid of projectIds) {
|
|
revalidatePath(`/projects/${pid}`);
|
|
}
|
|
revalidatePath(`/shot-status`);
|
|
|
|
return { success: true, updated: shots.length };
|
|
}
|
|
|
|
// ── Bulk Delete Shots ─────────────────────────────────────────────────────────
|
|
|
|
export async function bulkDeleteShots(shotIds: 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");
|
|
}
|
|
|
|
if (!shotIds.length) return { success: true, deleted: 0, failed: [] as { shotId: string; shotCode: string; reason: string }[] };
|
|
|
|
const shots = await db.shot.findMany({
|
|
where: { id: { in: shotIds } },
|
|
select: { id: true, shotCode: true, projectId: true },
|
|
});
|
|
|
|
const projectIds = [...new Set(shots.map((s) => s.projectId))];
|
|
const failed: { shotId: string; shotCode: string; reason: string }[] = [];
|
|
let deleted = 0;
|
|
|
|
// One at a time, not deleteMany: a shot with renders or deliveries is
|
|
// protected at the database level (exports_shotId_fkey is ON DELETE
|
|
// RESTRICT, deliberately — delivery history must never be destroyable by
|
|
// deleting the shot underneath it). A single bulk statement would abort
|
|
// entirely on the first shot it hits; deleting individually means every
|
|
// shot that *can* go still does, and the ones that can't are reported by
|
|
// name instead of failing the whole selection.
|
|
for (const shot of shots) {
|
|
try {
|
|
await db.shot.delete({ where: { id: shot.id } });
|
|
deleted += 1;
|
|
} catch (e) {
|
|
failed.push({
|
|
shotId: shot.id,
|
|
shotCode: shot.shotCode,
|
|
reason:
|
|
e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2003"
|
|
? "has renders or deliveries — delete those first"
|
|
: e instanceof Error
|
|
? e.message
|
|
: "unknown error",
|
|
});
|
|
}
|
|
}
|
|
|
|
for (const pid of projectIds) {
|
|
revalidatePath(`/projects/${pid}`);
|
|
}
|
|
revalidatePath(`/shot-status`);
|
|
|
|
return { success: true, deleted, failed };
|
|
}
|
|
|
|
export async function renameFootagePlate(plateId: string, label: string) {
|
|
const session = await auth();
|
|
if (!session?.user) throw new Error("Unauthorized");
|
|
|
|
const role = session.user.role as string;
|
|
if (!["ADMIN", "PRODUCER", "SUPERVISOR"].includes(role)) throw new Error("Insufficient permissions");
|
|
|
|
const trimmed = label.trim().slice(0, 100);
|
|
|
|
const plate = await db.footagePlate.update({
|
|
where: { id: plateId },
|
|
data: { label: trimmed },
|
|
include: { shot: { select: { projectId: true } } },
|
|
});
|
|
|
|
revalidatePath(`/projects/${plate.shot.projectId}`);
|
|
revalidatePath(`/projects/${plate.shot.projectId}/shots/${plate.shotId}`);
|
|
|
|
return { success: true };
|
|
}
|
|
|
|
// ── Internal Approval ─────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Supervisor/Producer/Admin: mark a shot as internally approved.
|
|
* Sets shotApprovalStatus = INTERNALLY_APPROVED, sharedWithClient = false.
|
|
* Shot status becomes READY_FOR_CLIENT.
|
|
*/
|
|
export async function internallyApproveShot(shotId: 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 shot = await db.shot.findUnique({
|
|
where: { id: shotId },
|
|
select: { projectId: true },
|
|
});
|
|
if (!shot) throw new Error("Shot not found");
|
|
|
|
await db.shot.update({
|
|
where: { id: shotId },
|
|
data: {
|
|
shotApprovalStatus: "INTERNALLY_APPROVED",
|
|
sharedWithClient: false,
|
|
status: "READY_FOR_CLIENT",
|
|
},
|
|
});
|
|
|
|
revalidatePath(`/projects/${shot.projectId}`);
|
|
revalidatePath(`/projects/${shot.projectId}/shots/${shotId}`);
|
|
revalidatePath(`/shot-status`);
|
|
return { success: true };
|
|
}
|
|
|
|
// ── Share / Unshare With Client ───────────────────────────────────────────────
|
|
|
|
/**
|
|
* Producer/Supervisor/Admin: share an internally-approved shot with the client.
|
|
* Sets sharedWithClient = true → status becomes CLIENT_REVIEW.
|
|
* Also marks the latest version of every task on this shot as isClientVisible = true
|
|
* so the client portal can display them.
|
|
*/
|
|
export async function shareWithClient(shotId: 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 shot = await db.shot.findUnique({
|
|
where: { id: shotId },
|
|
select: {
|
|
projectId: true,
|
|
shotApprovalStatus: true,
|
|
tasks: { select: { id: true } },
|
|
},
|
|
});
|
|
if (!shot) throw new Error("Shot not found");
|
|
if (!(["INTERNALLY_APPROVED", "CLIENT_APPROVED"] as string[]).includes(shot.shotApprovalStatus)) {
|
|
throw new Error("Shot must be internally approved before sharing with client");
|
|
}
|
|
|
|
const now = new Date();
|
|
|
|
await db.$transaction(async (tx) => {
|
|
// Mark shot as shared
|
|
await tx.shot.update({
|
|
where: { id: shotId },
|
|
data: { sharedWithClient: true, status: "CLIENT_REVIEW" },
|
|
});
|
|
|
|
// For each task, make the latest version client-visible
|
|
for (const task of shot.tasks) {
|
|
const latest = await tx.version.findFirst({
|
|
where: { taskId: task.id, isLatest: true },
|
|
select: { id: true, isClientVisible: true },
|
|
});
|
|
if (latest && !latest.isClientVisible) {
|
|
await tx.version.update({
|
|
where: { id: latest.id },
|
|
data: {
|
|
isClientVisible: true,
|
|
sharedAt: now,
|
|
sharedById: session.user.id,
|
|
},
|
|
});
|
|
}
|
|
}
|
|
});
|
|
|
|
revalidatePath(`/projects/${shot.projectId}`);
|
|
revalidatePath(`/projects/${shot.projectId}/shots/${shotId}`);
|
|
revalidatePath(`/shot-status`);
|
|
return { success: true };
|
|
}
|
|
|
|
/**
|
|
* Producer/Supervisor/Admin: remove a shot from client review.
|
|
* Sets sharedWithClient = false → status becomes READY_FOR_CLIENT.
|
|
* Also hides all task versions that were made visible by shareWithClient.
|
|
*/
|
|
export async function unshareFromClient(shotId: 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 shot = await db.shot.findUnique({
|
|
where: { id: shotId },
|
|
select: {
|
|
projectId: true,
|
|
tasks: { select: { id: true } },
|
|
},
|
|
});
|
|
if (!shot) throw new Error("Shot not found");
|
|
|
|
await db.$transaction(async (tx) => {
|
|
await tx.shot.update({
|
|
where: { id: shotId },
|
|
data: { sharedWithClient: false, status: "READY_FOR_CLIENT" },
|
|
});
|
|
|
|
// Hide all client-visible versions on this shot's tasks
|
|
const taskIds = shot.tasks.map((t) => t.id);
|
|
if (taskIds.length > 0) {
|
|
await tx.version.updateMany({
|
|
where: { taskId: { in: taskIds }, isClientVisible: true },
|
|
data: { isClientVisible: false },
|
|
});
|
|
}
|
|
});
|
|
|
|
revalidatePath(`/projects/${shot.projectId}`);
|
|
revalidatePath(`/projects/${shot.projectId}/shots/${shotId}`);
|
|
revalidatePath(`/shot-status`);
|
|
return { success: true };
|
|
}
|
|
|
|
// ── Shot-Level Client Approval ────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Internal: handle client approving the shot.
|
|
* Sets shotApprovalStatus = CLIENT_APPROVED → status becomes COMPLETE.
|
|
*/
|
|
export async function clientApproveShot(shotId: string) {
|
|
const session = await auth();
|
|
if (!session?.user) throw new Error("Unauthorized");
|
|
if (!["ADMIN", "PRODUCER", "SUPERVISOR", "CLIENT"].includes(session.user.role)) {
|
|
throw new Error("Insufficient permissions");
|
|
}
|
|
|
|
const shot = await db.shot.findUnique({
|
|
where: { id: shotId },
|
|
select: { projectId: true, sharedWithClient: true },
|
|
});
|
|
if (!shot) throw new Error("Shot not found");
|
|
if (!shot.sharedWithClient) throw new Error("Shot is not shared with client");
|
|
|
|
await db.shot.update({
|
|
where: { id: shotId },
|
|
data: { shotApprovalStatus: "CLIENT_APPROVED", status: "COMPLETE" },
|
|
});
|
|
|
|
revalidatePath(`/projects/${shot.projectId}`);
|
|
revalidatePath(`/projects/${shot.projectId}/shots/${shotId}`);
|
|
revalidatePath(`/shot-status`);
|
|
return { success: true };
|
|
}
|
|
|
|
/**
|
|
* Admin/Producer/Supervisor: undo a client approval on a shot.
|
|
* Resets shotApprovalStatus from CLIENT_APPROVED → INTERNALLY_APPROVED.
|
|
* Shot status reverts to CLIENT_REVIEW (if still shared) or READY_FOR_CLIENT.
|
|
*/
|
|
export async function unapproveShot(shotId: 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 shot = await db.shot.findUnique({
|
|
where: { id: shotId },
|
|
select: { projectId: true, shotApprovalStatus: true, sharedWithClient: true },
|
|
});
|
|
if (!shot) throw new Error("Shot not found");
|
|
if (shot.shotApprovalStatus !== "CLIENT_APPROVED") {
|
|
throw new Error("Shot is not client-approved");
|
|
}
|
|
|
|
const newStatus = shot.sharedWithClient ? "CLIENT_REVIEW" : "READY_FOR_CLIENT";
|
|
|
|
await db.shot.update({
|
|
where: { id: shotId },
|
|
data: {
|
|
shotApprovalStatus: "INTERNALLY_APPROVED",
|
|
status: newStatus,
|
|
},
|
|
});
|
|
|
|
revalidatePath(`/projects/${shot.projectId}`);
|
|
revalidatePath(`/projects/${shot.projectId}/shots/${shotId}`);
|
|
revalidatePath(`/shot-status`);
|
|
return { success: true };
|
|
}
|
|
|
|
// ── EDL / Pull CSV Import ─────────────────────────────────────────────────────
|
|
|
|
import type { EdlImportRow } from "@/lib/edl-utils";
|
|
export type { EdlImportRow };
|
|
|
|
export async function importShotsFromEdl(
|
|
projectId: string,
|
|
rows: EdlImportRow[]
|
|
): Promise<{ created: string[]; updated: string[]; skipped: string[]; errors: 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 created: string[] = [];
|
|
const updated: string[] = [];
|
|
const skipped: string[] = [];
|
|
const errors: string[] = [];
|
|
|
|
for (const row of rows) {
|
|
if (row.action === "skip") { skipped.push(row.shotCode); continue; }
|
|
|
|
try {
|
|
if (row.action === "update" && row.existingShotId) {
|
|
await db.shot.update({
|
|
where: { id: row.existingShotId },
|
|
data: {
|
|
sourceClip: row.sourceClip || null,
|
|
timecodeStart: row.timecodeStart || null,
|
|
timecodeEnd: row.timecodeEnd || null,
|
|
clipDuration: row.clipDuration || null,
|
|
exrOutput: row.exrOutput || null,
|
|
},
|
|
});
|
|
updated.push(row.shotCode);
|
|
} else {
|
|
const existing = await db.shot.findFirst({
|
|
where: { projectId, shotCode: row.shotCode },
|
|
select: { id: true },
|
|
});
|
|
|
|
if (existing) {
|
|
await db.shot.update({
|
|
where: { id: existing.id },
|
|
data: {
|
|
sourceClip: row.sourceClip || null,
|
|
timecodeStart: row.timecodeStart || null,
|
|
timecodeEnd: row.timecodeEnd || null,
|
|
clipDuration: row.clipDuration || null,
|
|
exrOutput: row.exrOutput || null,
|
|
},
|
|
});
|
|
updated.push(row.shotCode);
|
|
} else {
|
|
const parts = row.shotCode.split("_");
|
|
const scene = parts[2] ?? parts[1] ?? "000";
|
|
const episode = parts[1] ?? null;
|
|
|
|
const maxNum = await db.shot.findFirst({
|
|
where: { projectId, scene, episode },
|
|
orderBy: { shotNumber: "desc" },
|
|
select: { shotNumber: true },
|
|
});
|
|
const shotNumber = (maxNum?.shotNumber ?? 0) + 10;
|
|
|
|
await db.shot.create({
|
|
data: {
|
|
projectId,
|
|
shotCode: row.shotCode,
|
|
scene,
|
|
episode,
|
|
shotNumber,
|
|
sourceClip: row.sourceClip || null,
|
|
timecodeStart: row.timecodeStart || null,
|
|
timecodeEnd: row.timecodeEnd || null,
|
|
clipDuration: row.clipDuration || null,
|
|
exrOutput: row.exrOutput || null,
|
|
},
|
|
});
|
|
created.push(row.shotCode);
|
|
}
|
|
}
|
|
} catch (e) {
|
|
errors.push(`${row.shotCode}: ${e instanceof Error ? e.message : "Unknown error"}`);
|
|
}
|
|
}
|
|
|
|
revalidatePath(`/projects/${projectId}`);
|
|
return { created, updated, skipped, errors };
|
|
}
|
|
|
|
// ── Picture Tracker / Sequence Timecode Update ────────────────────────────────
|
|
|
|
export interface SeqTimecodeRow {
|
|
shotCode: string;
|
|
seqTimecodeStart: string;
|
|
seqTimecodeEnd: string;
|
|
}
|
|
|
|
export async function updateShotsSeqTimecodes(
|
|
projectId: string,
|
|
rows: SeqTimecodeRow[]
|
|
): Promise<{ updated: string[]; skipped: string[]; errors: 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 updated: string[] = [];
|
|
const skipped: string[] = [];
|
|
const errors: string[] = [];
|
|
|
|
for (const row of rows) {
|
|
try {
|
|
const existing = await db.shot.findFirst({
|
|
where: { projectId, shotCode: row.shotCode },
|
|
select: { id: true },
|
|
});
|
|
|
|
if (!existing) {
|
|
skipped.push(row.shotCode);
|
|
continue;
|
|
}
|
|
|
|
await db.shot.update({
|
|
where: { id: existing.id },
|
|
data: {
|
|
seqTimecodeStart: row.seqTimecodeStart || null,
|
|
seqTimecodeEnd: row.seqTimecodeEnd || null,
|
|
},
|
|
});
|
|
updated.push(row.shotCode);
|
|
} catch (e) {
|
|
errors.push(`${row.shotCode}: ${e instanceof Error ? e.message : "Unknown error"}`);
|
|
}
|
|
}
|
|
|
|
revalidatePath(`/projects/${projectId}`);
|
|
return { updated, skipped, errors };
|
|
}
|
|
|
|
// ── Simple CSV shot import ────────────────────────────────────────────────────
|
|
|
|
export interface SimpleCsvRow {
|
|
shotCode: string;
|
|
seqTimecodeStart: string;
|
|
seqTimecodeEnd: string;
|
|
description: string;
|
|
group: string;
|
|
action: "create" | "update" | "skip";
|
|
}
|
|
|
|
export async function importShotsFromSimpleCsv(
|
|
projectId: string,
|
|
rows: SimpleCsvRow[]
|
|
): Promise<{ created: string[]; updated: string[]; skipped: string[]; errors: 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 created: string[] = [];
|
|
const updated: string[] = [];
|
|
const skipped: string[] = [];
|
|
const errors: string[] = [];
|
|
|
|
for (const row of rows) {
|
|
if (row.action === "skip") { skipped.push(row.shotCode); continue; }
|
|
try {
|
|
const existing = await db.shot.findFirst({
|
|
where: { projectId, shotCode: row.shotCode },
|
|
select: { id: true },
|
|
});
|
|
|
|
if (existing) {
|
|
const shotGroupId = row.group?.trim()
|
|
? (await db.shotGroup.upsert({
|
|
where: { projectId_name: { projectId, name: row.group.trim() } },
|
|
create: { projectId, name: row.group.trim() },
|
|
update: {},
|
|
})).id
|
|
: undefined;
|
|
await db.shot.update({
|
|
where: { id: existing.id },
|
|
data: {
|
|
description: row.description || null,
|
|
seqTimecodeStart: row.seqTimecodeStart || null,
|
|
seqTimecodeEnd: row.seqTimecodeEnd || null,
|
|
...(shotGroupId ? { shotGroupId } : {}),
|
|
},
|
|
});
|
|
updated.push(row.shotCode);
|
|
} else if (row.action === "create") {
|
|
const parts = row.shotCode.split("_");
|
|
const scene = parts.length >= 3 ? parts[2] : (parts[1] ?? "000");
|
|
const episode = parts.length >= 4 ? parts[1] : null;
|
|
const maxNum = await db.shot.findFirst({
|
|
where: { projectId, scene, episode },
|
|
orderBy: { shotNumber: "desc" },
|
|
select: { shotNumber: true },
|
|
});
|
|
const shotNumber = (maxNum?.shotNumber ?? 0) + 10;
|
|
const shotGroupId = row.group?.trim()
|
|
? (await db.shotGroup.upsert({
|
|
where: { projectId_name: { projectId, name: row.group.trim() } },
|
|
create: { projectId, name: row.group.trim() },
|
|
update: {},
|
|
})).id
|
|
: undefined;
|
|
|
|
await db.shot.create({
|
|
data: {
|
|
projectId,
|
|
shotCode: row.shotCode,
|
|
scene,
|
|
episode,
|
|
shotNumber,
|
|
description: row.description || null,
|
|
seqTimecodeStart: row.seqTimecodeStart || null,
|
|
seqTimecodeEnd: row.seqTimecodeEnd || null,
|
|
shotGroupId,
|
|
},
|
|
});
|
|
created.push(row.shotCode);
|
|
} else {
|
|
// action === "update" but shot not found
|
|
skipped.push(row.shotCode);
|
|
}
|
|
} catch (e) {
|
|
errors.push(`${row.shotCode}: ${e instanceof Error ? e.message : "Unknown error"}`);
|
|
}
|
|
}
|
|
|
|
revalidatePath(`/projects/${projectId}`);
|
|
return { created, updated, skipped, errors };
|
|
}
|
|
|
|
// ── Group Assignment ──────────────────────────────────────────────────────────────────
|
|
|
|
export interface GroupAssignRow {
|
|
shotCode: string;
|
|
groupName: string;
|
|
exists: boolean;
|
|
}
|
|
|
|
export async function assignShotGroups(
|
|
projectId: string,
|
|
rows: GroupAssignRow[]
|
|
): Promise<{ assigned: string[]; skipped: string[]; errors: 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 assigned: string[] = [];
|
|
const skipped: string[] = [];
|
|
const errors: string[] = [];
|
|
|
|
for (const row of rows) {
|
|
if (!row.exists) { skipped.push(row.shotCode); continue; }
|
|
try {
|
|
const shot = await db.shot.findFirst({
|
|
where: { projectId, shotCode: row.shotCode },
|
|
select: { id: true },
|
|
});
|
|
if (!shot) { skipped.push(row.shotCode); continue; }
|
|
|
|
const group = await db.shotGroup.upsert({
|
|
where: { projectId_name: { projectId, name: row.groupName.trim() } },
|
|
create: { projectId, name: row.groupName.trim() },
|
|
update: {},
|
|
});
|
|
|
|
await db.shot.update({
|
|
where: { id: shot.id },
|
|
data: { shotGroupId: group.id },
|
|
});
|
|
assigned.push(row.shotCode);
|
|
} catch (e) {
|
|
errors.push(`${row.shotCode}: ${e instanceof Error ? e.message : "Unknown error"}`);
|
|
}
|
|
}
|
|
|
|
revalidatePath(`/projects/${projectId}`);
|
|
return { assigned, skipped, errors };
|
|
}
|
|
|
|
/**
|
|
* Internal: handle client requesting changes on a shot.
|
|
* Resets shotApprovalStatus = PENDING, sharedWithClient = false.
|
|
* Recalculates shot status (will become REVISIONS if tasks set to CHANGES).
|
|
*/
|
|
export async function clientRequestShotChanges(shotId: string, taskId?: string) {
|
|
const session = await auth();
|
|
if (!session?.user) throw new Error("Unauthorized");
|
|
if (!["ADMIN", "PRODUCER", "SUPERVISOR", "CLIENT"].includes(session.user.role)) {
|
|
throw new Error("Insufficient permissions");
|
|
}
|
|
|
|
const shot = await db.shot.findUnique({
|
|
where: { id: shotId },
|
|
select: { projectId: true },
|
|
});
|
|
if (!shot) throw new Error("Shot not found");
|
|
|
|
await db.$transaction(async (tx) => {
|
|
// Reset shot approval
|
|
await tx.shot.update({
|
|
where: { id: shotId },
|
|
data: { shotApprovalStatus: "PENDING", sharedWithClient: false },
|
|
});
|
|
|
|
// If a specific task is called out, mark it CHANGES; otherwise mark all non-DONE tasks
|
|
if (taskId) {
|
|
await tx.task.update({
|
|
where: { id: taskId },
|
|
data: { status: "CHANGES" },
|
|
});
|
|
} else {
|
|
await tx.task.updateMany({
|
|
where: { shotId, status: { not: "DONE" } },
|
|
data: { status: "CHANGES" },
|
|
});
|
|
}
|
|
|
|
await recalcShotStatus(shotId, tx);
|
|
});
|
|
|
|
revalidatePath(`/projects/${shot.projectId}`);
|
|
revalidatePath(`/projects/${shot.projectId}/shots/${shotId}`);
|
|
revalidatePath(`/shot-status`);
|
|
return { success: true };
|
|
}
|
|
|
|
// ── Shot Version ──────────────────────────────────────────────────────────────
|
|
|
|
const VERSION_RE = /^v\d{3}$/;
|
|
|
|
/**
|
|
* Set or manually override the shot's version string (format: v###).
|
|
*/
|
|
export async function updateShotVersion(shotId: string, version: 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");
|
|
}
|
|
if (!VERSION_RE.test(version)) {
|
|
throw new Error("Version must be in format v### (e.g. v001, v012)");
|
|
}
|
|
|
|
const shot = await db.shot.findUnique({
|
|
where: { id: shotId },
|
|
select: { projectId: true },
|
|
});
|
|
if (!shot) throw new Error("Shot not found");
|
|
|
|
await db.shot.update({
|
|
where: { id: shotId },
|
|
data: { shotVersion: version },
|
|
});
|
|
|
|
revalidatePath(`/projects/${shot.projectId}/shots/${shotId}`);
|
|
return { success: true, shotVersion: version };
|
|
}
|
|
|
|
// ── Generated (render-derived) shot thumbnails ───────────────────────────────
|
|
//
|
|
// A shot can carry two stills: `thumbnailUrl`, the reference frame that came
|
|
// with the brief, and `generatedThumbnailUrl`, a frame promoted from a render
|
|
// export. The reference always wins on display (lib/shot-thumbnail.ts), so
|
|
// these actions can only ever fill a gap — a promotion never overwrites the
|
|
// brief, and clearing one always falls back to it.
|
|
//
|
|
// Shows with `pipelineConfig.autoShotThumbnail` promote on every finished
|
|
// export automatically (lib/render-pipeline/preview.ts). These actions are the
|
|
// manual equivalent, and work regardless of that flag: a supervisor can pull a
|
|
// frame onto one shot on a project that is otherwise hands-off.
|
|
|
|
/**
|
|
* Promote a render export's thumbnail onto its shot.
|
|
*
|
|
* With no `exportId`, uses the newest export of that shot which actually has a
|
|
* thumbnail — "use the latest render frame".
|
|
*/
|
|
export async function promoteExportThumbnail(shotId: string, exportId?: 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 shot = await db.shot.findUnique({
|
|
where: { id: shotId },
|
|
select: { id: true, projectId: true },
|
|
});
|
|
if (!shot) throw new Error("Shot not found");
|
|
|
|
const exportRow = await db.export.findFirst({
|
|
where: {
|
|
shotId,
|
|
thumbnailKey: { not: null },
|
|
...(exportId ? { id: exportId } : {}),
|
|
},
|
|
orderBy: { versionNumber: "desc" },
|
|
select: { id: true, thumbnailKey: true, versionString: true },
|
|
});
|
|
if (!exportRow?.thumbnailKey) {
|
|
throw new Error(
|
|
exportId
|
|
? "That export has no thumbnail — its preview may have been skipped, or the worker had no ffmpeg configured"
|
|
: "No render of this shot has produced a thumbnail yet"
|
|
);
|
|
}
|
|
|
|
const generatedThumbnailUrl = `/api/files/${exportRow.thumbnailKey}`;
|
|
await db.shot.update({
|
|
where: { id: shotId },
|
|
data: { generatedThumbnailUrl, generatedThumbnailFrom: exportRow.versionString },
|
|
});
|
|
|
|
revalidatePath(`/projects/${shot.projectId}`);
|
|
revalidatePath(`/projects/${shot.projectId}/shots/${shotId}`);
|
|
return {
|
|
success: true,
|
|
generatedThumbnailUrl,
|
|
generatedThumbnailFrom: exportRow.versionString,
|
|
};
|
|
}
|
|
|
|
/** Drop the render-derived still. The reference frame, if any, comes back. */
|
|
export async function clearGeneratedThumbnail(shotId: 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 shot = await db.shot.findUnique({
|
|
where: { id: shotId },
|
|
select: { id: true, projectId: true },
|
|
});
|
|
if (!shot) throw new Error("Shot not found");
|
|
|
|
await db.shot.update({
|
|
where: { id: shotId },
|
|
data: { generatedThumbnailUrl: null, generatedThumbnailFrom: null },
|
|
});
|
|
|
|
revalidatePath(`/projects/${shot.projectId}`);
|
|
revalidatePath(`/projects/${shot.projectId}/shots/${shotId}`);
|
|
return { success: true };
|
|
}
|