Files
twotalesanimation caaa333f02
Deploy / deploy (push) Failing after 19m11s
feat(pipeline): render-derived shot thumbnails, and bulk shot delete
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>
2026-08-27 17:06:55 +02:00

122 lines
4.1 KiB
TypeScript

import { NextRequest, NextResponse } from "next/server";
import { db } from "@/lib/db";
import { shotThumbnail } from "@/lib/shot-thumbnail";
// ── Auth ─────────────────────────────────────────────────────────────────────
function isAuthorized(req: NextRequest): boolean {
const apiKey = process.env.API_SECRET_KEY;
if (!apiKey) return false;
const authHeader = req.headers.get("authorization") ?? "";
if (authHeader.startsWith("Bearer ")) return authHeader.slice(7) === apiKey;
return (req.headers.get("x-api-key") ?? "") === apiKey;
}
// ── GET /api/ext/projects/[projectCode]/shots ─────────────────────────────────
//
// Returns all shots for a project, optionally filtered by episode and/or sequence.
// Uses the human-readable project code (e.g. UNG_108) not the DB id.
//
// Query params (all optional):
// episode e.g. 108
// scene e.g. 022
// sequence e.g. 004
// status e.g. IN_PROGRESS
// page (default 1)
// limit (default 200, max 500)
//
// Example:
// GET /api/ext/projects/UNG_108/shots?episode=108&sequence=004
// GET /api/ext/projects/UNG_108/shots
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ projectCode: string }> }
) {
if (!isAuthorized(req)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { projectCode } = await params;
const { searchParams } = new URL(req.url);
const episode = searchParams.get("episode")?.trim() || undefined;
const scene = searchParams.get("scene")?.trim() || undefined;
const sequence = searchParams.get("sequence")?.trim() || undefined;
const status = searchParams.get("status")?.trim() || undefined;
const page = Math.max(1, parseInt(searchParams.get("page") ?? "1", 10));
const limit = Math.min(500, Math.max(1, parseInt(searchParams.get("limit") ?? "200", 10)));
const skip = (page - 1) * limit;
const project = await db.project.findUnique({
where: { code: projectCode },
select: { id: true, name: true, code: true, showId: true },
});
if (!project) {
return NextResponse.json({ error: "Project not found" }, { status: 404 });
}
const [shots, total] = await Promise.all([
db.shot.findMany({
where: {
projectId: project.id,
...(episode ? { episode } : {}),
...(scene ? { scene } : {}),
...(sequence ? { sequence } : {}),
...(status ? { status: status as never } : {}),
},
orderBy: [{ episode: "asc" }, { shotCode: "asc" }],
skip,
take: limit,
select: {
id: true,
shotCode: true,
scene: true,
episode: true,
sequence: true,
shotNumber: true,
description: true,
status: true,
priority: true,
frameStart: true,
frameEnd: true,
fps: true,
dueDate: true,
// EDL / pull CSV fields
sourceClip: true,
timecodeStart: true,
timecodeEnd: true,
clipDuration: true,
exrOutput: true,
seqTimecodeStart: true,
seqTimecodeEnd: true,
thumbnailUrl: true,
generatedThumbnailUrl: true,
artist: {
select: { id: true, name: true, email: true },
},
_count: { select: { versions: true, tasks: true } },
},
}),
db.shot.count({
where: {
projectId: project.id,
...(episode ? { episode } : {}),
...(scene ? { scene } : {}),
...(sequence ? { sequence } : {}),
...(status ? { status: status as never } : {}),
},
}),
]);
return NextResponse.json({
project,
pagination: { page, limit, total, pages: Math.ceil(total / limit) },
shots: shots.map(({ generatedThumbnailUrl, ...shot }) => ({
...shot,
thumbnailUrl: shotThumbnail({ ...shot, generatedThumbnailUrl }),
})),
});
}