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>
455 lines
18 KiB
TypeScript
455 lines
18 KiB
TypeScript
import { NextRequest, NextResponse } from "next/server";
|
||
import { auth } from "@/auth";
|
||
import { db } from "@/lib/db";
|
||
import { shotThumbnail } from "@/lib/shot-thumbnail";
|
||
|
||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||
|
||
function esc(s: string | null | undefined): string {
|
||
if (!s) return "";
|
||
return s
|
||
.replace(/&/g, "&")
|
||
.replace(/</g, "<")
|
||
.replace(/>/g, ">")
|
||
.replace(/"/g, """)
|
||
.replace(/'/g, "'");
|
||
}
|
||
|
||
const STATUS_LABELS: Record<string, string> = {
|
||
WAITING: "Waiting",
|
||
IN_PROGRESS: "In Progress",
|
||
INTERNAL_REVIEW: "Internal Review",
|
||
READY_FOR_CLIENT: "Ready for Client",
|
||
CLIENT_REVIEW: "Client Review",
|
||
REVISIONS: "Revisions",
|
||
COMPLETE: "Complete",
|
||
};
|
||
|
||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||
|
||
type Shot = {
|
||
id: string;
|
||
shotCode: string;
|
||
scene: string;
|
||
episode: string | null;
|
||
shotNumber: number;
|
||
description: string | null;
|
||
notes: string | null;
|
||
status: string;
|
||
thumbnailUrl: string | null;
|
||
frameStart: number | null;
|
||
frameEnd: number | null;
|
||
fps: number;
|
||
isKeyShot: boolean;
|
||
shotVersion: string;
|
||
shotGroup: { id: string; name: string } | null;
|
||
};
|
||
|
||
type Opts = {
|
||
showDesc: boolean;
|
||
showNotes: boolean;
|
||
showFrameRange: boolean;
|
||
showStatus: boolean;
|
||
showVersion: boolean;
|
||
};
|
||
|
||
type Group = { label: string; shots: Shot[] };
|
||
|
||
// ─── Shot card HTML (standard layout) ────────────────────────────────────────
|
||
|
||
function shotCardHtml(shot: Shot, opts: Opts): string {
|
||
const fc =
|
||
shot.frameStart != null && shot.frameEnd != null
|
||
? shot.frameEnd - shot.frameStart + 1
|
||
: null;
|
||
|
||
return `<div class="shot-card">
|
||
<div class="thumb-wrap">
|
||
${
|
||
shot.thumbnailUrl
|
||
? `<img src="${esc(shot.thumbnailUrl)}" alt="${esc(shot.shotCode)}" loading="eager" />`
|
||
: `<div class="no-thumb">No image</div>`
|
||
}
|
||
${shot.isKeyShot ? `<span class="key-badge">KEY</span>` : ""}
|
||
</div>
|
||
<div class="shot-info">
|
||
<div class="code-row">
|
||
<span class="shot-code">${esc(shot.shotCode)}</span>
|
||
${opts.showVersion ? `<span class="version">${esc(shot.shotVersion)}</span>` : ""}
|
||
</div>
|
||
${opts.showFrameRange && fc != null ? `<div class="frame-range">${shot.frameStart}–${shot.frameEnd} (${fc}fr)</div>` : ""}
|
||
${opts.showDesc && shot.description ? `<div class="description">${esc(shot.description)}</div>` : ""}
|
||
${opts.showNotes && shot.notes ? `<div class="notes">${esc(shot.notes)}</div>` : ""}
|
||
${opts.showStatus ? `<div class="status-label">${esc(STATUS_LABELS[shot.status] ?? shot.status)}</div>` : ""}
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
// ─── Standard layout HTML ─────────────────────────────────────────────────────
|
||
|
||
function buildStandardHtml(
|
||
project: { name: string; code: string },
|
||
groups: Group[],
|
||
opts: Opts,
|
||
columns: number,
|
||
pageBreaks: boolean
|
||
): string {
|
||
const totalShots = groups.reduce((n, g) => n + g.shots.length, 0);
|
||
|
||
const groupsHtml = groups
|
||
.map((g, i) => {
|
||
const breakClass = pageBreaks && i > 0 ? " group-break" : "";
|
||
return `<div class="group-section${breakClass}">
|
||
${
|
||
g.label
|
||
? `<div class="group-header">
|
||
<div class="group-title">${esc(g.label)}</div>
|
||
<div class="group-meta">${esc(project.name)} · ${g.shots.length} shot${g.shots.length !== 1 ? "s" : ""}</div>
|
||
</div>`
|
||
: ""
|
||
}
|
||
<div class="grid cols-${columns}">
|
||
${g.shots.map((s) => shotCardHtml(s, opts)).join("\n ")}
|
||
</div>
|
||
</div>`;
|
||
})
|
||
.join("\n");
|
||
|
||
return `<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<title>${esc(project.code)} — Storyboard</title>
|
||
<style>
|
||
@page { size: A4 portrait; margin: 14mm 12mm 12mm 12mm; }
|
||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||
body {
|
||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif;
|
||
background: #fff;
|
||
color: #111;
|
||
font-size: 10px;
|
||
}
|
||
|
||
/* ── Print bar (hidden on print) ── */
|
||
.print-bar {
|
||
position: fixed; top: 0; left: 0; right: 0; z-index: 100;
|
||
background: #18181b;
|
||
color: #fff;
|
||
padding: 10px 20px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
box-shadow: 0 2px 8px rgba(0,0,0,.4);
|
||
}
|
||
.print-bar-info h1 { font-size: 14px; font-weight: 600; }
|
||
.print-bar-info p { font-size: 11px; color: #a1a1aa; margin-top: 1px; }
|
||
.print-btn {
|
||
background: #f59e0b; color: #000; border: none;
|
||
padding: 8px 22px; border-radius: 6px;
|
||
font-size: 13px; font-weight: 700; cursor: pointer;
|
||
white-space: nowrap; flex-shrink: 0;
|
||
}
|
||
.print-btn:hover { background: #d97706; }
|
||
@media print { .print-bar { display: none !important; } }
|
||
|
||
/* ── Content offset ── */
|
||
.content { padding-top: 56px; }
|
||
@media print { .content { padding-top: 0; } }
|
||
|
||
/* ── Group ── */
|
||
.group-break { break-before: page; page-break-before: always; }
|
||
.group-header { margin-bottom: 8px; padding-bottom: 6px; border-bottom: 1.5px solid #d1d5db; }
|
||
.group-title { font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: .09em; color: #374151; }
|
||
.group-meta { font-size: 9px; color: #9ca3af; margin-top: 2px; }
|
||
.group-section { margin-bottom: 16px; }
|
||
|
||
/* ── Grid ── */
|
||
.grid { display: grid; gap: 8px; margin-bottom: 12px; }
|
||
.cols-2 { grid-template-columns: repeat(2, 1fr); }
|
||
.cols-3 { grid-template-columns: repeat(3, 1fr); }
|
||
.cols-4 { grid-template-columns: repeat(4, 1fr); }
|
||
|
||
/* ── Shot card ── */
|
||
.shot-card {
|
||
break-inside: avoid; page-break-inside: avoid;
|
||
border: 1px solid #e5e7eb; border-radius: 2px; overflow: hidden;
|
||
background: #fff;
|
||
}
|
||
.thumb-wrap {
|
||
position: relative; width: 100%;
|
||
aspect-ratio: 2.39 / 1; background: #f3f4f6; overflow: hidden;
|
||
}
|
||
.thumb-wrap img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||
.no-thumb {
|
||
display: flex; align-items: center; justify-content: center;
|
||
height: 100%; color: #d1d5db; font-size: 9px;
|
||
}
|
||
.key-badge {
|
||
position: absolute; top: 3px; right: 3px;
|
||
background: #f59e0b; color: #fff;
|
||
font-size: 7px; font-weight: 700; padding: 1px 4px; border-radius: 2px;
|
||
}
|
||
.shot-info { padding: 4px 6px 5px; }
|
||
.code-row { display: flex; align-items: baseline; justify-content: space-between; gap: 4px; }
|
||
.shot-code { font-family: 'SFMono-Regular', Consolas, monospace; font-size: 9.5px; font-weight: 700; color: #111; }
|
||
.version { font-family: monospace; font-size: 8px; color: #9ca3af; }
|
||
.frame-range { font-size: 8px; color: #6b7280; margin-top: 1px; }
|
||
.description { font-size: 9px; color: #374151; line-height: 1.35; margin-top: 3px; }
|
||
.notes { font-size: 8px; color: #6b7280; font-style: italic; line-height: 1.3; margin-top: 2px; }
|
||
.status-label { font-size: 7.5px; color: #9ca3af; text-transform: uppercase; letter-spacing: .05em; margin-top: 2px; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="print-bar">
|
||
<div class="print-bar-info">
|
||
<h1>${esc(project.name)} — Storyboard</h1>
|
||
<p>${totalShots} shot${totalShots !== 1 ? "s" : ""} · ${columns} columns${groups[0]?.label ? ` · ${groups.length} group${groups.length !== 1 ? "s" : ""}` : ""}</p>
|
||
</div>
|
||
<button class="print-btn" onclick="window.print()">🖨 Print / Save as PDF</button>
|
||
</div>
|
||
<div class="content">
|
||
${groupsHtml}
|
||
</div>
|
||
<script>
|
||
var imgs = Array.from(document.querySelectorAll('img'));
|
||
var done = 0;
|
||
function check() { if (++done >= imgs.length) setTimeout(function(){ window.print(); }, 200); }
|
||
if (!imgs.length) { setTimeout(function(){ window.print(); }, 200); }
|
||
else imgs.forEach(function(img) { if (img.complete) check(); else { img.onload = check; img.onerror = check; } });
|
||
</script>
|
||
</body>
|
||
</html>`;
|
||
}
|
||
|
||
// ─── Fullscreen layout HTML ────────────────────────────────────────────────────
|
||
|
||
function buildFullscreenHtml(
|
||
project: { name: string; code: string },
|
||
shots: Shot[],
|
||
opts: Opts
|
||
): string {
|
||
const pages = shots
|
||
.map((shot, idx) => {
|
||
const fc =
|
||
shot.frameStart != null && shot.frameEnd != null
|
||
? shot.frameEnd - shot.frameStart + 1
|
||
: null;
|
||
const isLast = idx === shots.length - 1;
|
||
|
||
const metaParts: string[] = [];
|
||
if (shot.episode) metaParts.push(`Ep ${esc(shot.episode)}`);
|
||
metaParts.push(`Scene ${esc(shot.scene)}`);
|
||
if (shot.shotGroup) metaParts.push(esc(shot.shotGroup.name));
|
||
if (opts.showFrameRange && fc != null) metaParts.push(`${shot.frameStart}–${shot.frameEnd} (${fc} fr)`);
|
||
if (opts.showStatus) metaParts.push(esc(STATUS_LABELS[shot.status] ?? shot.status));
|
||
|
||
return `<div class="shot-page${isLast ? " last-page" : ""}">
|
||
<div class="image-area">
|
||
${
|
||
shot.thumbnailUrl
|
||
? `<img src="${esc(shot.thumbnailUrl)}" alt="${esc(shot.shotCode)}" loading="eager" />`
|
||
: `<div class="no-image">No image</div>`
|
||
}
|
||
<div class="watermark">${esc(project.code)}</div>
|
||
<div class="counter">${idx + 1} / ${shots.length}</div>
|
||
</div>
|
||
<div class="info-bar">
|
||
<div class="shot-header">
|
||
<span class="shot-code-fs">${esc(shot.shotCode)}</span>
|
||
${shot.isKeyShot ? `<span class="key-label">★ Key Shot</span>` : ""}
|
||
${opts.showVersion ? `<span class="version-fs">${esc(shot.shotVersion)}</span>` : ""}
|
||
</div>
|
||
${metaParts.length ? `<div class="meta-row">${metaParts.join(" · ")}</div>` : ""}
|
||
${opts.showDesc && shot.description ? `<div class="desc-fs">${esc(shot.description)}</div>` : ""}
|
||
${opts.showNotes && shot.notes ? `<div class="notes-fs">${esc(shot.notes)}</div>` : ""}
|
||
</div>
|
||
</div>`;
|
||
})
|
||
.join("\n");
|
||
|
||
return `<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<title>${esc(project.code)} — Storyboard (Fullscreen)</title>
|
||
<style>
|
||
@page { size: A4 landscape; margin: 0; }
|
||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||
body { background: #000; color: #fff; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif; }
|
||
|
||
/* ── Print bar ── */
|
||
.print-bar {
|
||
position: fixed; top: 0; left: 0; right: 0; z-index: 100;
|
||
background: #18181b; color: #fff;
|
||
padding: 10px 20px;
|
||
display: flex; align-items: center; justify-content: space-between; gap: 12px;
|
||
box-shadow: 0 2px 8px rgba(0,0,0,.6);
|
||
}
|
||
.print-bar-info h1 { font-size: 14px; font-weight: 600; }
|
||
.print-bar-info p { font-size: 11px; color: #a1a1aa; margin-top: 1px; }
|
||
.print-btn {
|
||
background: #f59e0b; color: #000; border: none;
|
||
padding: 8px 22px; border-radius: 6px;
|
||
font-size: 13px; font-weight: 700; cursor: pointer; white-space: nowrap;
|
||
}
|
||
.print-btn:hover { background: #d97706; }
|
||
@media print { .print-bar { display: none !important; } }
|
||
|
||
/* ── Shot page ── */
|
||
.shot-page {
|
||
display: flex; flex-direction: column;
|
||
height: 100vh;
|
||
break-after: page; page-break-after: always;
|
||
overflow: hidden;
|
||
background: #000;
|
||
}
|
||
.shot-page.last-page { break-after: auto; page-break-after: auto; }
|
||
@media print {
|
||
.shot-page { margin-top: 0; }
|
||
/* Offset for print bar gone on print — first page may have extra space in preview */
|
||
}
|
||
|
||
/* ── Image area ── */
|
||
.image-area {
|
||
flex: 1; overflow: hidden; background: #000; position: relative;
|
||
display: flex; align-items: center; justify-content: center;
|
||
min-height: 0;
|
||
}
|
||
.image-area img { width: 100%; height: 100%; object-fit: contain; display: block; }
|
||
.no-image { color: #333; font-size: 13px; }
|
||
.watermark {
|
||
position: absolute; top: 12px; left: 16px;
|
||
font-family: monospace; font-size: 9px; font-weight: 500;
|
||
color: rgba(255,255,255,.2); letter-spacing: .15em; text-transform: uppercase;
|
||
}
|
||
.counter {
|
||
position: absolute; top: 12px; right: 16px;
|
||
font-family: monospace; font-size: 9px;
|
||
color: rgba(255,255,255,.2);
|
||
}
|
||
|
||
/* ── Info bar ── */
|
||
.info-bar {
|
||
flex-shrink: 0;
|
||
background: #111; border-top: 1px solid #2a2a2a;
|
||
padding: 10px 24px;
|
||
display: flex; flex-direction: column; gap: 4px;
|
||
}
|
||
.shot-header { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; }
|
||
.shot-code-fs { font-family: monospace; font-size: 20px; font-weight: 700; color: #fff; letter-spacing: -.01em; }
|
||
.key-label { font-size: 10px; color: #f59e0b; font-weight: 600; }
|
||
.version-fs { font-family: monospace; font-size: 11px; color: #6b7280; }
|
||
.meta-row { font-size: 10px; color: #6b7280; margin-top: 1px; }
|
||
.desc-fs { font-size: 12px; color: #d1d5db; line-height: 1.5; margin-top: 4px; }
|
||
.notes-fs { font-size: 10px; color: #6b7280; font-style: italic; margin-top: 2px; }
|
||
|
||
/* ── Preview offset ── */
|
||
.pages-wrap { padding-top: 52px; }
|
||
@media print { .pages-wrap { padding-top: 0; } }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="print-bar">
|
||
<div class="print-bar-info">
|
||
<h1>${esc(project.name)} — Storyboard</h1>
|
||
<p>${shots.length} shot${shots.length !== 1 ? "s" : ""} · Fullscreen · A4 Landscape</p>
|
||
</div>
|
||
<button class="print-btn" onclick="window.print()">🖨 Print / Save as PDF</button>
|
||
</div>
|
||
<div class="pages-wrap">
|
||
${pages}
|
||
</div>
|
||
<script>
|
||
var imgs = Array.from(document.querySelectorAll('img'));
|
||
var done = 0;
|
||
function check() { if (++done >= imgs.length) setTimeout(function(){ window.print(); }, 200); }
|
||
if (!imgs.length) { setTimeout(function(){ window.print(); }, 200); }
|
||
else imgs.forEach(function(img) { if (img.complete) check(); else { img.onload = check; img.onerror = check; } });
|
||
</script>
|
||
</body>
|
||
</html>`;
|
||
}
|
||
|
||
// ─── Route handler ────────────────────────────────────────────────────────────
|
||
|
||
export async function GET(req: NextRequest) {
|
||
const session = await auth();
|
||
if (!session?.user) return new NextResponse("Unauthorized", { status: 401 });
|
||
if (!["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role)) {
|
||
return new NextResponse("Forbidden", { status: 403 });
|
||
}
|
||
|
||
const p = req.nextUrl.searchParams;
|
||
const projectId = p.get("projectId");
|
||
if (!projectId) return new NextResponse("projectId required", { status: 400 });
|
||
|
||
const layout = p.get("layout") === "fullscreen" ? "fullscreen" : "standard";
|
||
const columns = Math.min(4, Math.max(2, parseInt(p.get("columns") ?? "3", 10)));
|
||
const groupBy = (p.get("groupBy") ?? "episode") as "episode" | "scene" | "group" | "none";
|
||
const epFilter = p.get("episodes")?.split(",").filter(Boolean) ?? [];
|
||
const grpFilter = p.get("groups")?.split(",").filter(Boolean) ?? [];
|
||
|
||
const opts: Opts = {
|
||
showDesc: p.get("desc") !== "0",
|
||
showNotes: p.get("notes") === "1",
|
||
showFrameRange: p.get("frameRange") !== "0",
|
||
showStatus: p.get("status") === "1",
|
||
showVersion: p.get("version") === "1",
|
||
};
|
||
const pageBreaks = p.get("pageBreaks") !== "0";
|
||
const onlyThumbs = p.get("onlyThumbs") === "1";
|
||
|
||
const project = await db.project.findUnique({
|
||
where: { id: projectId },
|
||
select: { name: true, code: true },
|
||
});
|
||
if (!project) return new NextResponse("Project not found", { status: 404 });
|
||
|
||
let shots = await db.shot.findMany({
|
||
where: { projectId },
|
||
select: {
|
||
id: true, shotCode: true, scene: true, episode: true, shotNumber: true,
|
||
description: true, notes: true, status: true,
|
||
thumbnailUrl: true, generatedThumbnailUrl: true,
|
||
frameStart: true, frameEnd: true, fps: true, isKeyShot: true, shotVersion: true,
|
||
shotGroup: { select: { id: true, name: true } },
|
||
},
|
||
orderBy: [{ episode: "asc" }, { scene: "asc" }, { shotNumber: "asc" }],
|
||
});
|
||
|
||
// Collapse the two columns before anything reads them, so the "only shots
|
||
// with thumbnails" filter and the page renderer agree on what counts as one.
|
||
shots = shots.map((s) => ({ ...s, thumbnailUrl: shotThumbnail(s) }));
|
||
|
||
if (onlyThumbs) shots = shots.filter((s) => !!s.thumbnailUrl);
|
||
if (epFilter.length) shots = shots.filter((s) => epFilter.includes(s.episode ?? ""));
|
||
if (grpFilter.length) shots = shots.filter((s) => grpFilter.includes(s.shotGroup?.id ?? ""));
|
||
|
||
// Group shots
|
||
const groups: Group[] = [];
|
||
if (groupBy === "none") {
|
||
groups.push({ label: "", shots });
|
||
} else {
|
||
const map = new Map<string, Shot[]>();
|
||
for (const shot of shots) {
|
||
let key = "";
|
||
if (groupBy === "episode") key = shot.episode ? `Episode ${shot.episode}` : "No Episode";
|
||
else if (groupBy === "scene") key = `Scene ${shot.scene}`;
|
||
else if (groupBy === "group") key = shot.shotGroup?.name ?? "No Group";
|
||
if (!map.has(key)) map.set(key, []);
|
||
map.get(key)!.push(shot);
|
||
}
|
||
map.forEach((sh, label) => groups.push({ label, shots: sh }));
|
||
}
|
||
|
||
const html =
|
||
layout === "fullscreen"
|
||
? buildFullscreenHtml(project, groups.flatMap((g) => g.shots), opts)
|
||
: buildStandardHtml(project, groups, opts, columns, pageBreaks);
|
||
|
||
return new NextResponse(html, {
|
||
headers: { "Content-Type": "text/html; charset=utf-8" },
|
||
});
|
||
}
|