Files
twotalesanimation 3ae0a6e32f
Deploy / deploy (push) Failing after 1m53s
Storyboard Feature
2026-07-22 12:38:26 +02:00

449 lines
18 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/auth";
import { db } from "@/lib/db";
// ─── Helpers ──────────────────────────────────────────────────────────────────
function esc(s: string | null | undefined): string {
if (!s) return "";
return s
.replace(/&/g, "&")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
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} &nbsp;(${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)} &nbsp;·&nbsp; ${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" : ""} &nbsp;·&nbsp; ${columns} columns${groups[0]?.label ? ` &nbsp;·&nbsp; ${groups.length} group${groups.length !== 1 ? "s" : ""}` : ""}</p>
</div>
<button class="print-btn" onclick="window.print()">&#128424; 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&nbsp;${esc(shot.episode)}`);
metaParts.push(`Scene&nbsp;${esc(shot.scene)}`);
if (shot.shotGroup) metaParts.push(esc(shot.shotGroup.name));
if (opts.showFrameRange && fc != null) metaParts.push(`${shot.frameStart}${shot.frameEnd}&nbsp;(${fc}&nbsp;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}&nbsp;/&nbsp;${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">&#9733; Key Shot</span>` : ""}
${opts.showVersion ? `<span class="version-fs">${esc(shot.shotVersion)}</span>` : ""}
</div>
${metaParts.length ? `<div class="meta-row">${metaParts.join("&ensp;·&ensp;")}</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" : ""} &nbsp;·&nbsp; Fullscreen &nbsp;·&nbsp; A4 Landscape</p>
</div>
<button class="print-btn" onclick="window.print()">&#128424; 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,
frameStart: true, frameEnd: true, fps: true, isKeyShot: true, shotVersion: true,
shotGroup: { select: { id: true, name: true } },
},
orderBy: [{ episode: "asc" }, { scene: "asc" }, { shotNumber: "asc" }],
});
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" },
});
}