feat(review): High quality playback switch and per-version ProRes download
Deploy / deploy (push) Successful in 3m23s

Worker 1.7.0 (preview.highQuality, default on) now, after the AE render:
- encodes a High quality MP4 from the delivery MOV (full-res H.264 High,
  CRF 14 capped at 100 Mbps) -> Version.hqFileUrl
- uploads the MOV itself -> Version.highResKey/Filename/Size
Both best-effort (the preview never fails over them), with a lease
keep-alive during the long encode/uploads; the export timeline records
what was attached.

Player: HQ switch when a High quality source exists; keeps frame, play
state, speed and zoom, remembered per browser, falls back to Standard if
the file won't load. ProRes itself stays download-only (browser support).

Downloads: the client portal's High Res button serves the version's
ProRes (client-code filename), falling back to the shot's manual file;
staff get a ProRes button on the review page.

Housekeeping (lib/render-pipeline/high-res.ts): HQ MP4 + ProRes are kept
while a version is latest, client-shared, approved or younger than
preview.highResRetentionDays (14); otherwise deleted from storage. Runs
per task after finalize and hourly from the reaper; deleting a version or
a reslate removes its files. The Standard MP4 is never touched.

Also: ProRes/Handles top-bar buttons go icon-only below lg so they don't
overlap the version selector.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
twotalesanimation
2026-09-22 16:32:18 +02:00
parent bae46c2968
commit 9660cdc21c
27 changed files with 793 additions and 18 deletions
+11
View File
@@ -1189,6 +1189,17 @@ Renders run past the cut at each end (8 + 8 by house convention). Handles ship w
- **Staff override:** the review page's Handles button edits a version's head/tail/slate — for manual uploads, or to correct a render. - **Staff override:** the review page's Handles button edits a version's head/tail/slate — for manual uploads, or to correct a render.
- **Backfill** (`20260922000000_version_handles`): every existing export takes its manifest's `source.handleFrames` (else 8), and the Versions they produced take the same. - **Backfill** (`20260922000000_version_handles`): every existing export takes its manifest's `source.handleFrames` (else 8), and the Versions they produced take the same.
### 9.6 High quality playback and the ProRes download
The Standard review MP4 is AE's H.264 (UHD, ~35 Mbps). ProRes itself can't be the player's source — only Safari on a Mac decodes it in a web page, and UHD ProRes LT is ~280 Mbps — so the worker (≥ 1.7.0, when `preview.highQuality` is on) adds two things after the AE render, both **best-effort** (a failure costs the extras, never the preview):
- **High quality MP4:** ffmpeg re-encodes the delivery MOV as full-res H.264 High, CRF 14 capped at 100 Mbps, yuv420p, faststart → `Version.hqFileUrl`. The player shows an **HQ** switch when present; switching keeps frame, play state, speed and zoom, is remembered per browser (`review:quality`), and falls back to Standard if the file won't load. Same comp as the Standard MP4, so slate and handles line up frame for frame.
- **ProRes download:** the MOV itself is uploaded (presign kind `highres`) → `Version.highResKey/highResFilename/highResSize`. It is the version's **High Res** download in the client portal (named with the client code, `clientHighResFilename`) and a **ProRes** button on the staff review page. The shot's manually uploaded High Res remains the fallback for versions without one.
A lease keep-alive runs during the encode and uploads (they can outlast the 300 s lease). The export timeline records what was attached.
**Housekeeping** (`lib/render-pipeline/high-res.ts`): a version keeps its HQ MP4 and ProRes while it is the task's latest, shared with the client, approved, or younger than `preview.highResRetentionDays` (14). Otherwise both are deleted from storage and the row gets `highResPrunedAt`; the Standard MP4 is never touched. It runs for the task after every finalize and studio-wide hourly from the reaper; deleting a version deletes its files. A reslate replaces (and deletes) the version's previous HQ/ProRes.
--- ---
## 10. QC Workflow ## 10. QC Workflow
+5
View File
@@ -175,6 +175,11 @@ public sealed class PreviewManifest
public string Mp4Output { get; set; } = ""; public string Mp4Output { get; set; } = "";
public string? SlateScopeProp { get; set; } public string? SlateScopeProp { get; set; }
public string? SlateSubmissionProp { get; set; } public string? SlateSubmissionProp { get; set; }
/// <summary>
/// Also encode the High quality MP4 from the MOV and upload the MOV itself
/// as the version's High Res file (server "preview.highQuality").
/// </summary>
public bool HighQuality { get; set; }
public SlateInfo Slate { get; set; } = new(); public SlateInfo Slate { get; set; } = new();
public int TotalFrames => FrameEnd - FrameStart + 1; public int TotalFrames => FrameEnd - FrameStart + 1;
@@ -441,6 +441,32 @@ public sealed class JobExecutor
job.Id, machineId, "thumbnail", result.ThumbnailPath, "image/jpeg", shutdown); job.Id, machineId, "thumbnail", result.ThumbnailPath, "image/jpeg", shutdown);
} }
// High quality MP4 + the MOV as the version's High Res download
// (§9.6). Both optional: a failure here costs the extras, never the
// preview, which is already safely uploaded.
string? hqKey = null;
string? highResKey = null;
long? highResSize = null;
if (manifest.HighQuality)
{
await using var keepAlive = new LeaseKeepAlive(_api, job.Id, machineId, TimeSpan.FromSeconds(60), shutdown);
var hqPath = await _preview.TryMakeHighQualityAsync(job.Id, result.MovPath!, result.Mp4Path!, shutdown);
if (hqPath is not null)
{
hqKey = await _api.UploadArtifactAsync(job.Id, machineId, "preview", hqPath, "video/mp4", shutdown);
if (hqKey is null) _log.LogWarning("High quality MP4 upload failed for job {JobId}", job.Id);
}
if (result.MovPath is not null && File.Exists(result.MovPath))
{
highResSize = new FileInfo(result.MovPath).Length;
highResKey = await _api.UploadArtifactAsync(
job.Id, machineId, "highres", result.MovPath, "video/quicktime", shutdown);
if (highResKey is null) _log.LogWarning("ProRes upload failed for job {JobId}", job.Id);
}
}
_spooler.Enqueue("finalize", job.Id, new Dictionary<string, object?> _spooler.Enqueue("finalize", job.Id, new Dictionary<string, object?>
{ {
["machineId"] = machineId, ["machineId"] = machineId,
@@ -449,6 +475,10 @@ public sealed class JobExecutor
["deliveryMovPath"] = manifest.MovOutput, ["deliveryMovPath"] = manifest.MovOutput,
["previewMovKey"] = previewKey, ["previewMovKey"] = previewKey,
["thumbnailKey"] = thumbKey, ["thumbnailKey"] = thumbKey,
["hqPreviewKey"] = hqKey,
["highResKey"] = highResKey,
["highResFileName"] = highResKey is null ? null : Path.GetFileName(result.MovPath),
["highResSize"] = highResKey is null ? null : highResSize,
}, },
["renderStats"] = new Dictionary<string, object?> ["renderStats"] = new Dictionary<string, object?>
{ {
@@ -494,6 +524,12 @@ public sealed class JobExecutor
PreviewStage.TryDelete(Path.Combine(WorkerOptions.ScratchDir, $"job_{jobId}_run.jsx")); PreviewStage.TryDelete(Path.Combine(WorkerOptions.ScratchDir, $"job_{jobId}_run.jsx"));
PreviewStage.TryDelete(Path.Combine(WorkerOptions.ScratchDir, $"job_{jobId}_thumb.jpg")); PreviewStage.TryDelete(Path.Combine(WorkerOptions.ScratchDir, $"job_{jobId}_thumb.jpg"));
if (tempAep is not null) PreviewStage.TryDelete(tempAep); if (tempAep is not null) PreviewStage.TryDelete(tempAep);
try
{
var hqDir = PreviewStage.HighQualityDir(jobId);
if (Directory.Exists(hqDir)) Directory.Delete(hqDir, recursive: true);
}
catch { /* scratch only */ }
} }
/// <summary> /// <summary>
@@ -0,0 +1,43 @@
namespace VFXReviewWorker;
/// <summary>
/// Renews a job's lease with progress reports while the worker does something
/// long that reports nothing on its own — the High quality encode and the
/// ProRes upload can each outlast a 300 s lease, and an expired lease lets the
/// reaper hand the job to another machine mid-upload.
/// </summary>
internal sealed class LeaseKeepAlive : IAsyncDisposable
{
private readonly CancellationTokenSource _cts;
private readonly Task _loop;
public LeaseKeepAlive(ApiClient api, string jobId, string machineId, TimeSpan interval, CancellationToken shutdown)
{
_cts = CancellationTokenSource.CreateLinkedTokenSource(shutdown);
var token = _cts.Token;
_loop = Task.Run(async () =>
{
try
{
while (!token.IsCancellationRequested)
{
await Task.Delay(interval, token);
// Best-effort by contract (ApiClient.ProgressAsync never throws
// on transport errors); progress stays at 1.0 — the render is done.
await api.ProgressAsync(jobId, machineId, 1.0, null, null, null, null, token);
}
}
catch (OperationCanceledException)
{
// stopped
}
});
}
public async ValueTask DisposeAsync()
{
_cts.Cancel();
try { await _loop; } catch { /* already reported */ }
_cts.Dispose();
}
}
@@ -302,6 +302,107 @@ public sealed class PreviewStage
return null; return null;
} }
/// <summary>Scratch folder for a job's High quality encode.</summary>
internal static string HighQualityDir(string jobId) =>
Path.Combine(WorkerOptions.ScratchDir, $"job_{jobId}_hq");
/// <summary>
/// High quality review MP4 (§9.6): the delivery MOV re-encoded as
/// full-resolution, high-bitrate H.264 High — the player's "High" quality.
/// ProRes itself only plays in Safari on a Mac, so the MOV is offered as a
/// download and this is what streams. Never fatal: without it the version
/// simply has Standard quality only.
/// </summary>
public async Task<string?> TryMakeHighQualityAsync(
string jobId, string movPath, string mp4Path, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(_options.FfmpegPath) || !File.Exists(_options.FfmpegPath))
{
_log.LogWarning("No High quality MP4 for job {JobId}: ffmpegPath is not set or missing.", jobId);
return null;
}
if (!File.Exists(movPath))
{
_log.LogWarning("No High quality MP4 for job {JobId}: MOV not found at {Path}.", jobId, movPath);
return null;
}
var dir = HighQualityDir(jobId);
Directory.CreateDirectory(dir);
// Named after the review MP4 so the stored object reads as its sibling.
var outPath = Path.Combine(dir, Path.GetFileNameWithoutExtension(mp4Path) + "_HQ.mp4");
TryDelete(outPath);
var args = new List<string>
{
"-y", "-i", movPath,
"-map", "0:v:0", "-map", "0:a?",
// Visually lossless for review, capped so a busy shot can't balloon
// past what a good connection streams. yuv420p 8-bit High profile
// is the combination every browser decodes.
"-c:v", "libx264", "-profile:v", "high", "-preset", "medium", "-crf", "14",
"-maxrate", "100M", "-bufsize", "200M", "-pix_fmt", "yuv420p",
"-c:a", "aac", "-b:a", "256k",
// Index at the front so the player can start before it's all loaded
"-movflags", "+faststart",
outPath,
};
var psi = new ProcessStartInfo
{
FileName = _options.FfmpegPath,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
};
foreach (var a in args) psi.ArgumentList.Add(a);
var sw = Stopwatch.StartNew();
try
{
using var process = Process.Start(psi);
if (process is null)
{
_log.LogWarning("Could not start ffmpeg for the High quality MP4 (job {JobId})", jobId);
return null;
}
// Drain both pipes (see RunFfmpegThumbnailAsync — an unread pipe deadlocks ffmpeg).
var stderrTask = process.StandardError.ReadToEndAsync();
var stdoutTask = process.StandardOutput.ReadToEndAsync();
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeout.CancelAfter(TimeSpan.FromMinutes(30));
try
{
await process.WaitForExitAsync(timeout.Token);
}
catch (OperationCanceledException)
{
_log.LogWarning("High quality encode for job {JobId} timed out or was cancelled — killing ffmpeg", jobId);
try { process.Kill(entireProcessTree: true); } catch { }
TryDelete(outPath);
return null;
}
var stderr = await stderrTask;
await stdoutTask;
if (process.ExitCode == 0 && File.Exists(outPath) && new FileInfo(outPath).Length > 0)
{
_log.LogInformation("High quality MP4 for job {JobId} encoded in {Sec:F0}s ({MB:F0} MB)",
jobId, sw.Elapsed.TotalSeconds, new FileInfo(outPath).Length / 1e6);
return outPath;
}
_log.LogWarning("High quality encode failed for job {JobId} (exit {Exit}): {Stderr}",
jobId, process.ExitCode, Tail(stderr, 1200));
TryDelete(outPath);
return null;
}
catch (Exception ex)
{
_log.LogWarning(ex, "High quality encode failed for job {JobId} (non-fatal)", jobId);
return null;
}
}
/// <summary>How one ffmpeg attempt ended.</summary> /// <summary>How one ffmpeg attempt ended.</summary>
private enum ThumbAttempt private enum ThumbAttempt
{ {
@@ -15,7 +15,7 @@
every change that gets published. publish.ps1 appends the git SHA via every change that gets published. publish.ps1 appends the git SHA via
SourceRevisionId, giving "1.1.0+feb1c7b". SourceRevisionId, giving "1.1.0+feb1c7b".
--> -->
<Version>1.6.4</Version> <Version>1.7.0</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
+5
View File
@@ -7,6 +7,7 @@ import { z } from "zod";
import { recalcShotStatus, clearStaleShotApproval } from "@/lib/shot-status"; import { recalcShotStatus, clearStaleShotApproval } from "@/lib/shot-status";
import { notifyNewVersionUploaded } from "@/lib/notifications"; import { notifyNewVersionUploaded } from "@/lib/notifications";
import { slackNotifyVersionUploaded } from "@/lib/slack"; import { slackNotifyVersionUploaded } from "@/lib/slack";
import { deleteStorageKeys, highResStorageKeys } from "@/lib/render-pipeline/high-res";
const createVersionSchema = z.object({ const createVersionSchema = z.object({
taskId: z.string().cuid(), taskId: z.string().cuid(),
@@ -185,6 +186,8 @@ export async function deleteVersion(versionId: string) {
id: true, id: true,
isLatest: true, isLatest: true,
taskId: true, taskId: true,
hqFileUrl: true,
highResKey: true,
task: { select: { projectId: true, shotId: true } }, task: { select: { projectId: true, shotId: true } },
}, },
}); });
@@ -203,6 +206,8 @@ export async function deleteVersion(versionId: string) {
} }
await db.version.delete({ where: { id: versionId } }); await db.version.delete({ where: { id: versionId } });
// Its High quality MP4 and ProRes are large and referenced by nothing else.
await deleteStorageKeys(highResStorageKeys(version));
const projectId = version.task?.projectId; const projectId = version.task?.projectId;
const shotId = version.task?.shotId; const shotId = version.task?.shotId;
@@ -43,6 +43,7 @@ interface PlaylistVersion {
posterUrl: string | null; posterUrl: string | null;
fps: number; fps: number;
frameCount?: number | null; frameCount?: number | null;
hqFileUrl?: string | null;
handleHead?: number | null; handleHead?: number | null;
handleTail?: number | null; handleTail?: number | null;
pictureStartFrame?: number | null; pictureStartFrame?: number | null;
@@ -281,6 +282,7 @@ export function PlaylistClient({ projects, userRole }: PlaylistClientProps) {
comments={[]} comments={[]}
annotations={[]} annotations={[]}
handles={activeShot.latestVersion} handles={activeShot.latestVersion}
hqVideoUrl={activeShot.latestVersion.hqFileUrl}
/> />
) : ( ) : (
<div className="flex-1 flex flex-col items-center justify-center gap-3 text-zinc-600"> <div className="flex-1 flex flex-col items-center justify-center gap-3 text-zinc-600">
@@ -0,0 +1,57 @@
import { NextRequest, NextResponse } from "next/server";
import { db } from "@/lib/db";
import { validateReviewToken } from "@/lib/review-auth";
import { generateHetznerDownloadUrl } from "@/lib/storage";
import { clientHighResFilename } from "@/lib/shot-code";
/**
* GET /api/client/[token]/versions/[versionId]/highres/download
* The version's ProRes, for a client reviewer: same visibility rule as the
* version itself (GET ../route.ts), and named with the client's shot code.
*/
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ token: string; versionId: string }> }
) {
const { token, versionId } = await params;
const result = await validateReviewToken(token, req);
if (result.type !== "ok") {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const version = await db.version.findUnique({
where: { id: versionId },
select: {
highResKey: true,
highResFilename: true,
isClientVisible: true,
shot: { select: { projectId: true, shotCode: true, clientCode: true } },
task: {
select: {
projectId: true,
shot: { select: { shotCode: true, clientCode: true } },
},
},
},
});
const projectId = version?.shot?.projectId ?? version?.task?.projectId;
if (!version || projectId !== result.session.projectId) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
// Task-only versions need explicit sharing, as in the version route
if (!version.shot && !version.isClientVisible) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
if (!version.highResKey) {
return NextResponse.json({ error: "No high-res file available" }, { status: 404 });
}
const shot = version.shot ?? version.task?.shot ?? null;
const url = await generateHetznerDownloadUrl(
version.highResKey,
clientHighResFilename(version.highResFilename ?? "high-res.mov", shot)
);
return NextResponse.json({ url });
}
@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { validateReviewToken, findReviewUser, ownsComment } from "@/lib/review-auth"; import { validateReviewToken, findReviewUser, ownsComment } from "@/lib/review-auth";
import { clientShotCode, clientTaskTitle } from "@/lib/shot-code"; import { clientHighResFilename, clientShotCode, clientTaskTitle } from "@/lib/shot-code";
/** GET /api/client/[token]/versions/[versionId] — returns version + comments for client portal */ /** GET /api/client/[token]/versions/[versionId] — returns version + comments for client portal */
export async function GET( export async function GET(
@@ -95,13 +95,21 @@ export async function GET(
// The shot the title and breadcrumb are drawn from — either relation. // The shot the title and breadcrumb are drawn from — either relation.
const namingShot = version.shot ?? ((version.task as any)?.shot ?? null); const namingShot = version.shot ?? ((version.task as any)?.shot ?? null);
// High Res: this version's own ProRes (uploaded by the render worker) wins;
// the shot's manually uploaded file is the fallback for older versions.
const versionHighRes = !!version.highResKey;
const serializedVersion = { const serializedVersion = {
...version, ...version,
fileSize: version.fileSize?.toString() ?? null, fileSize: version.fileSize?.toString() ?? null,
highResKey: undefined, // never expose a storage key
highResSize: version.highResSize?.toString() ?? null,
// Top-level high-res fields so the client works for both shot and task versions // Top-level high-res fields so the client works for both shot and task versions
hasHighRes: !!(highResShot?.highResKey), hasHighRes: versionHighRes || !!(highResShot?.highResKey),
highResShotId: highResShot?.highResKey ? (highResShot.id ?? null) : null, highResSource: versionHighRes ? "version" : highResShot?.highResKey ? "shot" : null,
highResFilename: highResShot?.highResFilename ?? null, highResShotId: !versionHighRes && highResShot?.highResKey ? (highResShot.id ?? null) : null,
highResFilename: versionHighRes
? clientHighResFilename(version.highResFilename ?? "high-res.mov", namingShot)
: highResShot?.highResFilename ?? null,
// Expose only whether a high-res file exists on the shot relation, never the storage key // Expose only whether a high-res file exists on the shot relation, never the storage key
shot: version.shot shot: version.shot
? { ? {
@@ -9,13 +9,15 @@ import { generateHetznerPresignedUploadUrl, sanitizeFileName } from "@/lib/stora
// //
// Presigned upload URL for worker artifacts. Kinds map to the existing object // Presigned upload URL for worker artifacts. Kinds map to the existing object
// storage folder layout: preview → videos/, thumbnail → image/, // storage folder layout: preview → videos/, thumbnail → image/,
// metadata/log → renders/. // metadata/log → renders/, highres (the delivery MOV) → highres/ beside the
// manually uploaded High Res files.
const KIND_FOLDERS: Record<string, string> = { const KIND_FOLDERS: Record<string, string> = {
preview: "videos", preview: "videos",
thumbnail: "image", thumbnail: "image",
metadata: "renders", metadata: "renders",
log: "renders", log: "renders",
highres: "highres",
}; };
export async function POST( export async function POST(
+2
View File
@@ -51,6 +51,7 @@ export async function GET(req: NextRequest) {
posterUrl: true, posterUrl: true,
fps: true, fps: true,
frameCount: true, frameCount: true,
hqFileUrl: true,
handleHead: true, handleHead: true,
handleTail: true, handleTail: true,
pictureStartFrame: true, pictureStartFrame: true,
@@ -90,6 +91,7 @@ export async function GET(req: NextRequest) {
posterUrl: latest.posterUrl, posterUrl: latest.posterUrl,
fps: latest.fps, fps: latest.fps,
frameCount: latest.frameCount, frameCount: latest.frameCount,
hqFileUrl: latest.hqFileUrl,
handleHead: latest.handleHead, handleHead: latest.handleHead,
handleTail: latest.handleTail, handleTail: latest.handleTail,
pictureStartFrame: latest.pictureStartFrame, pictureStartFrame: latest.pictureStartFrame,
@@ -0,0 +1,34 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { generateHetznerDownloadUrl } from "@/lib/storage";
/**
* GET /api/versions/[versionId]/highres/download — short-lived presigned URL
* for this version's ProRes (the delivery MOV the render worker uploaded).
*/
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ versionId: string }> }
) {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { versionId } = await params;
const version = await db.version.findUnique({
where: { id: versionId },
select: { highResKey: true, highResFilename: true },
});
if (!version?.highResKey) {
return NextResponse.json({ error: "No high-res file available" }, { status: 404 });
}
const url = await generateHetznerDownloadUrl(
version.highResKey,
version.highResFilename ?? "high-res.mov"
);
return NextResponse.json({ url });
}
+14 -2
View File
@@ -60,6 +60,10 @@ interface Version {
fps: number; fps: number;
duration: number | null; duration: number | null;
frameCount?: number | null; frameCount?: number | null;
/** High quality review MP4 — the player's quality switch. */
hqFileUrl?: string | null;
/** Where the High Res download comes from: this version's ProRes, or the shot's legacy file. */
highResSource?: "version" | "shot" | null;
handleHead?: number | null; handleHead?: number | null;
handleTail?: number | null; handleTail?: number | null;
pictureStartFrame?: number | null; pictureStartFrame?: number | null;
@@ -281,10 +285,17 @@ export default function ClientReviewPage({
const handleDownloadHighRes = async () => { const handleDownloadHighRes = async () => {
const shotId = version?.highResShotId ?? version?.shot?.id; const shotId = version?.highResShotId ?? version?.shot?.id;
if (!shotId || !token) return; if (!version || !token) return;
const endpoint =
version.highResSource === "version"
? `/api/client/${token}/versions/${version.id}/highres/download`
: shotId
? `/api/client/${token}/shots/${shotId}/highres/download`
: null;
if (!endpoint) return;
setDownloadingHighRes(true); setDownloadingHighRes(true);
try { try {
const res = await fetch(`/api/client/${token}/shots/${shotId}/highres/download`); const res = await fetch(endpoint);
if (!res.ok) throw new Error("Could not get download link"); if (!res.ok) throw new Error("Could not get download link");
const { url } = await res.json(); const { url } = await res.json();
// Open the presigned URL — the Content-Disposition header forces a download // Open the presigned URL — the Content-Disposition header forces a download
@@ -519,6 +530,7 @@ export default function ClientReviewPage({
versionId={version.id} versionId={version.id}
onAddComment={handlePlayerAddComment} onAddComment={handlePlayerAddComment}
handles={version} handles={version}
hqVideoUrl={version.hqFileUrl}
// The director watches the cut as cut; handles are still one // The director watches the cut as cut; handles are still one
// toggle away, and flagged whenever the playhead is in one. // toggle away, and flagged whenever the playhead is in one.
defaultEditRangeOnly defaultEditRangeOnly
+45 -1
View File
@@ -41,6 +41,7 @@ import {
Film, Film,
ShieldCheck, ShieldCheck,
MessageSquare, MessageSquare,
Download,
} from "lucide-react"; } from "lucide-react";
interface ReviewPageClientProps { interface ReviewPageClientProps {
@@ -54,6 +55,10 @@ interface ReviewPageClientProps {
handleHead: number | null; handleHead: number | null;
handleTail: number | null; handleTail: number | null;
pictureStartFrame: number | null; pictureStartFrame: number | null;
hqFileUrl: string | null;
highResKey: string | null;
highResFilename: string | null;
highResSize: bigint | number | null;
approvalStatus: string; approvalStatus: string;
notes: string | null; notes: string | null;
isClientVisible: boolean; isClientVisible: boolean;
@@ -103,6 +108,11 @@ interface ReviewPageClientProps {
userRole?: string; userRole?: string;
} }
function formatBytes(bytes: number): string {
if (bytes >= 1e9) return `${(bytes / 1e9).toFixed(1)} GB`;
return `${Math.max(1, Math.round(bytes / 1e6))} MB`;
}
const APPROVAL_STATUS_STYLES: Record<string, string> = { const APPROVAL_STATUS_STYLES: Record<string, string> = {
PENDING_REVIEW: "bg-amber-500/10 text-amber-400 border-amber-500/20", PENDING_REVIEW: "bg-amber-500/10 text-amber-400 border-amber-500/20",
APPROVED: "bg-emerald-500/10 text-emerald-400 border-emerald-500/20", APPROVED: "bg-emerald-500/10 text-emerald-400 border-emerald-500/20",
@@ -131,6 +141,7 @@ export function ReviewPageClient({
const [isInternallyApproving, setIsInternallyApproving] = useState(false); const [isInternallyApproving, setIsInternallyApproving] = useState(false);
const [showVersions, setShowVersions] = useState(false); const [showVersions, setShowVersions] = useState(false);
const [commentsOpen, setCommentsOpen] = useState(true); const [commentsOpen, setCommentsOpen] = useState(true);
const [downloadingProRes, setDownloadingProRes] = useState(false);
const playerRef = useRef<ReviewPlayerRef>(null); const playerRef = useRef<ReviewPlayerRef>(null);
@@ -141,6 +152,20 @@ export function ReviewPageClient({
const { toast } = useToast(); const { toast } = useToast();
const router = useRouter(); const router = useRouter();
const handleDownloadProRes = async () => {
setDownloadingProRes(true);
try {
const res = await fetch(`/api/versions/${version.id}/highres/download`);
if (!res.ok) throw new Error("Could not get download link");
const { url } = await res.json();
window.open(url, "_blank", "noopener,noreferrer");
} catch {
toast({ title: "Download failed", variant: "destructive" });
} finally {
setDownloadingProRes(false);
}
};
const handleAddComment = useCallback(() => { const handleAddComment = useCallback(() => {
// Pause player first // Pause player first
playerRef.current?.pause(); playerRef.current?.pause();
@@ -253,7 +278,7 @@ export function ReviewPageClient({
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4" />
</Link> </Link>
<div className="flex items-center gap-2 flex-1 min-w-0"> <div className="flex items-center gap-2 flex-1 min-w-0 overflow-hidden">
<span className="text-xs text-muted-foreground hidden sm:block"> <span className="text-xs text-muted-foreground hidden sm:block">
{project?.code} {project?.code}
</span> </span>
@@ -287,6 +312,24 @@ export function ReviewPageClient({
</DropdownMenu> </DropdownMenu>
</div> </div>
{/* ProRes — this version's delivery MOV, uploaded by the render worker */}
{version.highResKey && (
<Button
size="sm"
variant="ghost"
className="h-7 shrink-0 gap-1 text-xs text-sky-400"
disabled={downloadingProRes}
onClick={handleDownloadProRes}
title={
(version.highResFilename ?? "ProRes") +
(version.highResSize ? ` · ${formatBytes(Number(version.highResSize))}` : "")
}
>
<Download className="h-3 w-3" />
<span className="hidden lg:inline">{downloadingProRes ? "Getting link…" : "ProRes"}</span>
</Button>
)}
{/* Handles — outside the breadcrumb so it never gets squeezed */} {/* Handles — outside the breadcrumb so it never gets squeezed */}
{userRole !== "CLIENT" && ( {userRole !== "CLIENT" && (
<div className="shrink-0"> <div className="shrink-0">
@@ -380,6 +423,7 @@ export function ReviewPageClient({
onAddComment={handleAddComment} onAddComment={handleAddComment}
onAnnotationSaved={handleAnnotationSaved} onAnnotationSaved={handleAnnotationSaved}
handles={version} handles={version}
hqVideoUrl={version.hqFileUrl}
/> />
</div> </div>
+33
View File
@@ -47,6 +47,9 @@ interface PlaybackControlsProps {
onTogglePlay: () => void; onTogglePlay: () => void;
onToggleFullscreen: () => void; onToggleFullscreen: () => void;
onAddComment: () => void; onAddComment: () => void;
/** Current quality, or null when the version has no High quality source. */
quality?: "standard" | "high" | null;
onQualityChange?: (quality: "standard" | "high") => void;
} }
export function PlaybackControls({ export function PlaybackControls({
@@ -59,6 +62,8 @@ export function PlaybackControls({
onTogglePlay, onTogglePlay,
onToggleFullscreen, onToggleFullscreen,
onAddComment, onAddComment,
quality = null,
onQualityChange,
}: PlaybackControlsProps) { }: PlaybackControlsProps) {
const { const {
isPlaying, isPlaying,
@@ -297,6 +302,34 @@ export function PlaybackControls({
)} )}
</div> </div>
{/* Quality — only when the version has a High quality source */}
{quality && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className={cn(
"h-7 px-1.5 font-mono text-[11px] font-bold tracking-wide",
quality === "high"
? "text-sky-400 bg-sky-400/10 hover:text-sky-300"
: "text-zinc-500 hover:text-white"
)}
onClick={() => onQualityChange?.(quality === "high" ? "standard" : "high")}
aria-pressed={quality === "high"}
aria-label="High quality"
>
HQ
</Button>
</TooltipTrigger>
<TooltipContent>
{quality === "high"
? "High quality — click for Standard (smaller, loads faster)"
: "Switch to High quality (full resolution, needs a fast connection)"}
</TooltipContent>
</Tooltip>
)}
{/* Edit range only — only for versions with handles marked */} {/* Edit range only — only for versions with handles marked */}
{editRange && ( {editRange && (
<Tooltip> <Tooltip>
+70 -1
View File
@@ -46,8 +46,13 @@ interface ReviewPlayerProps {
handles?: VersionHandles | null; handles?: VersionHandles | null;
/** Start with playback kept to the edit (the client portal's default). */ /** Start with playback kept to the edit (the client portal's default). */
defaultEditRangeOnly?: boolean; defaultEditRangeOnly?: boolean;
/** High quality source (full res, high bitrate); enables the quality switch. */
hqVideoUrl?: string | null;
} }
export type PlaybackQuality = "standard" | "high";
const QUALITY_KEY = "review:quality";
export const ReviewPlayer = forwardRef<ReviewPlayerRef, ReviewPlayerProps>( export const ReviewPlayer = forwardRef<ReviewPlayerRef, ReviewPlayerProps>(
function ReviewPlayer( function ReviewPlayer(
{ {
@@ -63,6 +68,7 @@ export const ReviewPlayer = forwardRef<ReviewPlayerRef, ReviewPlayerProps>(
addCommentFn, addCommentFn,
handles, handles,
defaultEditRangeOnly = false, defaultEditRangeOnly = false,
hqVideoUrl,
}, },
ref ref
) { ) {
@@ -75,6 +81,41 @@ export const ReviewPlayer = forwardRef<ReviewPlayerRef, ReviewPlayerProps>(
const zoomLayerRef = useRef<HTMLDivElement>(null); const zoomLayerRef = useRef<HTMLDivElement>(null);
const reverseIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null); const reverseIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const openedAtCut = useRef(false); const openedAtCut = useRef(false);
// ── Quality ──────────────────────────────────────────────────────────────
// Standard is the review MP4; High is the full-res, high-bitrate encode of
// the delivery MOV. The choice is a per-browser preference, read after
// mount so the server render and the first client render agree.
const [quality, setQuality] = useState<PlaybackQuality>("standard");
const [switchingQuality, setSwitchingQuality] = useState(false);
// Where to pick up once the other source has loaded.
const resumeRef = useRef<{ time: number; playing: boolean } | null>(null);
useEffect(() => {
try {
if (localStorage.getItem(QUALITY_KEY) === "high") setQuality("high");
} catch {
// storage blocked — Standard it is
}
}, []);
const activeSrc = quality === "high" && hqVideoUrl ? hqVideoUrl : videoUrl;
const changeQuality = useCallback(
(next: PlaybackQuality) => {
if (next === quality) return;
const video = videoRef.current;
if (video && video.readyState > 0 && hqVideoUrl) {
resumeRef.current = { time: video.currentTime, playing: !video.paused };
setSwitchingQuality(true);
}
setQuality(next);
try {
localStorage.setItem(QUALITY_KEY, next);
} catch {
// preference just isn't remembered
}
},
[quality, hqVideoUrl]
);
const [isReversing, setIsReversing] = useState(false); const [isReversing, setIsReversing] = useState(false);
const { const {
@@ -142,6 +183,8 @@ export const ReviewPlayer = forwardRef<ReviewPlayerRef, ReviewPlayerProps>(
setTotalFrames(0); setTotalFrames(0);
resetZoom(); resetZoom();
openedAtCut.current = false; openedAtCut.current = false;
resumeRef.current = null;
setSwitchingQuality(false);
}, [videoUrl, setCurrentFrame, setCurrentTime, setDuration, setTotalFrames, resetZoom]); }, [videoUrl, setCurrentFrame, setCurrentTime, setDuration, setTotalFrames, resetZoom]);
// ── Playback state sync ────────────────────────────────────────────────── // ── Playback state sync ──────────────────────────────────────────────────
@@ -159,8 +202,24 @@ export const ReviewPlayer = forwardRef<ReviewPlayerRef, ReviewPlayerProps>(
const frames = durationToFrameCount(video.duration, fps); const frames = durationToFrameCount(video.duration, fps);
setDuration(video.duration); setDuration(video.duration);
setTotalFrames(frames); setTotalFrames(frames);
// A new source starts at 1x; keep the speed the reviewer chose.
video.playbackRate = useReviewStore.getState().playbackRate;
// Quality switch: carry on from the same frame, playing or not.
const resume = resumeRef.current;
resumeRef.current = null;
if (resume) {
video.currentTime = resume.time;
if (resume.playing) video.play().catch(() => {});
}
}, [fps, setDuration, setTotalFrames]); }, [fps, setDuration, setTotalFrames]);
// High quality failed to load (pruned, or a network hiccup): fall back to
// Standard rather than leave a black player. Not remembered as a choice.
const handleVideoError = useCallback(() => {
if (quality !== "high" || !hqVideoUrl) return;
setQuality("standard");
}, [quality, hqVideoUrl]);
/** /**
* Play — when keeping to the edit, from the cut in-point if the playhead * Play — when keeping to the edit, from the cut in-point if the playhead
* is outside the cut (slate, a handle, or parked on the last cut frame). * is outside the cut (slate, a handle, or parked on the last cut frame).
@@ -413,12 +472,14 @@ export const ReviewPlayer = forwardRef<ReviewPlayerRef, ReviewPlayerProps>(
> >
<video <video
ref={videoRef} ref={videoRef}
src={videoUrl} src={activeSrc}
className="block w-full h-full object-contain" className="block w-full h-full object-contain"
onTimeUpdate={handleTimeUpdate} onTimeUpdate={handleTimeUpdate}
onLoadedMetadata={handleLoadedMetadata} onLoadedMetadata={handleLoadedMetadata}
onPlay={handlePlay} onPlay={handlePlay}
onPause={handlePause} onPause={handlePause}
onCanPlay={() => setSwitchingQuality(false)}
onError={handleVideoError}
preload="metadata" preload="metadata"
playsInline playsInline
/> />
@@ -485,6 +546,12 @@ export const ReviewPlayer = forwardRef<ReviewPlayerRef, ReviewPlayerProps>(
</div> </div>
)} )}
{switchingQuality && (
<div className="pointer-events-none absolute bottom-3 left-3 rounded bg-black/70 px-2 py-1 text-xs text-white">
Loading {quality === "high" ? "high" : "standard"} quality…
</div>
)}
{/* JKL hint overlay — shown briefly when reversing */} {/* JKL hint overlay — shown briefly when reversing */}
{isReversing && ( {isReversing && (
<div className="absolute top-3 left-3 rounded bg-black/60 px-2 py-1 text-xs font-mono text-white"> <div className="absolute top-3 left-3 rounded bg-black/60 px-2 py-1 text-xs font-mono text-white">
@@ -516,6 +583,8 @@ export const ReviewPlayer = forwardRef<ReviewPlayerRef, ReviewPlayerProps>(
onTogglePlay={togglePlayback} onTogglePlay={togglePlayback}
onToggleFullscreen={toggleFullscreen} onToggleFullscreen={toggleFullscreen}
onAddComment={handleAddComment} onAddComment={handleAddComment}
quality={hqVideoUrl ? quality : null}
onQualityChange={changeQuality}
/> />
</div> </div>
); );
+2 -2
View File
@@ -100,10 +100,10 @@ export function VersionHandlesButton({
size="sm" size="sm"
className={cn("h-7 gap-1 text-xs", marked ? "text-amber-400" : "text-muted-foreground")} className={cn("h-7 gap-1 text-xs", marked ? "text-amber-400" : "text-muted-foreground")}
onClick={openDialog} onClick={openDialog}
title="Frames past the cut that aren't in the edit" title={`${label} — frames past the cut that aren't in the edit`}
> >
<Scissors className="h-3 w-3" /> <Scissors className="h-3 w-3" />
<span className="hidden sm:inline">{label}</span> <span className="hidden lg:inline">{label}</span>
</Button> </Button>
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
+15 -2
View File
@@ -48,6 +48,18 @@ export const PIPELINE_CONFIG_DEFAULTS = {
// names as a warning, so it is a config fix rather than a code change. // names as a warning, so it is a config fix rather than a code change.
"preview.slateScopeProp": "Scope", "preview.slateScopeProp": "Scope",
"preview.slateSubmissionProp": "Notes", "preview.slateSubmissionProp": "Notes",
/**
* After the AE render, the worker also encodes a high-bitrate full-res MP4
* from the delivery MOV (the player's "High" quality) and uploads the MOV
* itself as the version's High Res download. Off = Standard MP4 only.
*/
"preview.highQuality": "true",
/**
* Housekeeping (lib/render-pipeline/high-res.ts): a version's High quality
* MP4 and ProRes are removed from storage once it is this many days old and
* nothing still needs them — not the latest, not shared, not approved.
*/
"preview.highResRetentionDays": 14,
/** /**
* When the dashboard starts calling something stuck. An export waiting on QC * When the dashboard starts calling something stuck. An export waiting on QC
* is normal for a day and suspicious after two; an export in a live render * is normal for a day and suspicious after two; an export in a live render
@@ -79,7 +91,7 @@ export async function getConfigBoolean(key: PipelineConfigKey): Promise<boolean>
export async function getPreviewConfig() { export async function getPreviewConfig() {
const [ const [
enabled, templateAep, templateComp, overlayComp, lutComp, movTemplate, mp4Template, enabled, templateAep, templateComp, overlayComp, lutComp, movTemplate, mp4Template,
slateScopeProp, slateSubmissionProp, slateScopeProp, slateSubmissionProp, highQuality,
] = await Promise.all([ ] = await Promise.all([
getConfigBoolean("preview.enabled"), getConfigBoolean("preview.enabled"),
getConfigValue("preview.templateAep"), getConfigValue("preview.templateAep"),
@@ -90,10 +102,11 @@ export async function getPreviewConfig() {
getConfigValue("preview.mp4Template"), getConfigValue("preview.mp4Template"),
getConfigValue("preview.slateScopeProp"), getConfigValue("preview.slateScopeProp"),
getConfigValue("preview.slateSubmissionProp"), getConfigValue("preview.slateSubmissionProp"),
getConfigBoolean("preview.highQuality"),
]); ]);
return { return {
enabled, templateAep, templateComp, overlayComp, lutComp, movTemplate, mp4Template, enabled, templateAep, templateComp, overlayComp, lutComp, movTemplate, mp4Template,
slateScopeProp, slateSubmissionProp, slateScopeProp, slateSubmissionProp, highQuality,
}; };
} }
+54
View File
@@ -0,0 +1,54 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("@/lib/db", () => ({ db: {} }));
vi.mock("@/lib/storage", () => ({ deleteFromHetzner: vi.fn() }));
import { highResStorageKeys, shouldKeepHighRes } from "./high-res";
const now = new Date("2026-09-22T12:00:00Z");
const daysAgo = (d: number) => new Date(now.getTime() - d * 24 * 3600 * 1000);
const old = { isLatest: false, isClientVisible: false, approvalStatus: "PENDING_REVIEW", createdAt: daysAgo(30) };
describe("shouldKeepHighRes", () => {
it("prunes an old, superseded, unshared, unapproved version", () => {
expect(shouldKeepHighRes(old, now, 14)).toBe(false);
});
it("keeps the latest version however old", () => {
expect(shouldKeepHighRes({ ...old, isLatest: true }, now, 14)).toBe(true);
});
it("keeps anything shared with the client — they may download it", () => {
expect(shouldKeepHighRes({ ...old, isClientVisible: true }, now, 14)).toBe(true);
});
it("keeps the approved version of record", () => {
expect(shouldKeepHighRes({ ...old, approvalStatus: "APPROVED" }, now, 14)).toBe(true);
});
it("keeps recent superseded versions inside the retention window", () => {
expect(shouldKeepHighRes({ ...old, createdAt: daysAgo(13) }, now, 14)).toBe(true);
expect(shouldKeepHighRes({ ...old, createdAt: daysAgo(15) }, now, 14)).toBe(false);
});
it("treats needs-changes and rejected versions as prunable", () => {
expect(shouldKeepHighRes({ ...old, approvalStatus: "NEEDS_CHANGES" }, now, 14)).toBe(false);
expect(shouldKeepHighRes({ ...old, approvalStatus: "REJECTED" }, now, 14)).toBe(false);
});
it("prunes superseded versions straight away with a zero-day window", () => {
expect(shouldKeepHighRes({ ...old, createdAt: daysAgo(0.01) }, now, 0)).toBe(false);
});
});
describe("highResStorageKeys", () => {
it("returns the ProRes key and the HQ MP4's storage key", () => {
expect(
highResStorageKeys({ highResKey: "highres/a.mov", hqFileUrl: "/api/files/videos/b_HQ.mp4" })
).toEqual(["highres/a.mov", "videos/b_HQ.mp4"]);
});
it("is empty when there is nothing to delete", () => {
expect(highResStorageKeys({ highResKey: null, hqFileUrl: null })).toEqual([]);
});
});
+124
View File
@@ -0,0 +1,124 @@
import { db } from "@/lib/db";
import { deleteFromHetzner } from "@/lib/storage";
import { getConfigNumber } from "./config";
/**
* Housekeeping for per-version high-quality media — the High quality review
* MP4 (`hqFileUrl`) and the ProRes delivery MOV (`highResKey`). The worker
* uploads both for every version, and at UHD they are 5–10× the Standard MP4,
* so versions nobody can need any more have them removed. The Standard MP4 is
* never touched: every version stays reviewable.
*/
export interface HighResCandidate {
isLatest: boolean;
isClientVisible: boolean;
approvalStatus: string;
createdAt: Date;
}
/**
* Whether a version still needs its high-quality media. Kept while it is:
* - the latest version of its task — what everyone is looking at;
* - shared with the client — they may download it;
* - approved — the version of record;
* - younger than the retention window — supervisors compare recent takes.
*/
export function shouldKeepHighRes(v: HighResCandidate, now: Date, retentionDays: number): boolean {
if (v.isLatest || v.isClientVisible || v.approvalStatus === "APPROVED") return true;
const ageMs = now.getTime() - v.createdAt.getTime();
return ageMs < Math.max(0, retentionDays) * 24 * 3600 * 1000;
}
/** Object-storage keys behind a version's high-quality media. */
export function highResStorageKeys(v: { hqFileUrl?: string | null; highResKey?: string | null }): string[] {
const keys: string[] = [];
if (v.highResKey) keys.push(v.highResKey);
if (v.hqFileUrl?.startsWith("/api/files/")) keys.push(v.hqFileUrl.slice("/api/files/".length));
return keys;
}
/** Best-effort deletes; a failure only leaves an orphan, never a broken link. */
export async function deleteStorageKeys(keys: string[]): Promise<void> {
for (const key of keys) {
await deleteFromHetzner(key).catch((err) =>
console.error(`[high-res] could not delete ${key} from storage`, err)
);
}
}
const candidateSelect = {
id: true,
isLatest: true,
isClientVisible: true,
approvalStatus: true,
createdAt: true,
hqFileUrl: true,
highResKey: true,
} as const;
type Candidate = HighResCandidate & { id: string; hqFileUrl: string | null; highResKey: string | null };
/**
* Clears the row first, then deletes the files: if the delete fails the file
* is merely orphaned, whereas the other order could leave a download pointing
* at nothing.
*/
async function prune(candidates: Candidate[], now: Date, retentionDays: number): Promise<number> {
let pruned = 0;
for (const v of candidates) {
if (shouldKeepHighRes(v, now, retentionDays)) continue;
const keys = highResStorageKeys(v);
await db.version.update({
where: { id: v.id },
data: {
hqFileUrl: null,
highResKey: null,
highResFilename: null,
highResSize: null,
highResPrunedAt: now,
},
});
await deleteStorageKeys(keys);
pruned++;
}
return pruned;
}
const hasHighRes = { OR: [{ hqFileUrl: { not: null } }, { highResKey: { not: null } }] };
/** Run after a new version lands: its predecessors may now be redundant. */
export async function pruneHighResForTask(taskId: string): Promise<number> {
const retentionDays = await getConfigNumber("preview.highResRetentionDays");
const candidates = await db.version.findMany({
where: { taskId, ...hasHighRes },
select: candidateSelect,
});
return prune(candidates, new Date(), retentionDays);
}
/**
* Studio-wide pass, for versions that age out of the retention window or are
* unshared/unapproved after the fact. Bounded per run; the reaper calls it
* hourly, so a backlog drains over a few runs.
*/
export async function pruneHighResSweep(batch = 200): Promise<number> {
const retentionDays = await getConfigNumber("preview.highResRetentionDays");
const now = new Date();
const cutoff = new Date(now.getTime() - Math.max(0, retentionDays) * 24 * 3600 * 1000);
const candidates = await db.version.findMany({
where: {
...hasHighRes,
isLatest: false,
isClientVisible: false,
approvalStatus: { not: "APPROVED" },
createdAt: { lt: cutoff },
},
select: candidateSelect,
orderBy: { createdAt: "asc" },
take: batch,
});
const pruned = await prune(candidates, now, retentionDays);
if (pruned > 0) console.log(`[high-res] housekeeping removed high-quality media from ${pruned} version(s)`);
return pruned;
}
+59 -2
View File
@@ -5,6 +5,7 @@ import { applyTransition } from "./transitions";
import { getConfigNumber, getPreviewConfig } from "./config"; import { getConfigNumber, getPreviewConfig } from "./config";
import { resolveProjectTemplates, autoShotThumbnailEnabled } from "./project-templates"; import { resolveProjectTemplates, autoShotThumbnailEnabled } from "./project-templates";
import { clientShotCode } from "@/lib/shot-code"; import { clientShotCode } from "@/lib/shot-code";
import { deleteStorageKeys, highResStorageKeys, pruneHighResForTask } from "./high-res";
import { recalcShotStatus, clearStaleShotApproval } from "@/lib/shot-status"; import { recalcShotStatus, clearStaleShotApproval } from "@/lib/shot-status";
import { renderOutputBase } from "./exports"; import { renderOutputBase } from "./exports";
@@ -35,6 +36,8 @@ export interface PreviewManifest {
mp4Output: string; mp4Output: string;
slateScopeProp: string; slateScopeProp: string;
slateSubmissionProp: string; slateSubmissionProp: string;
/** Also encode the High quality MP4 and upload the MOV (worker >= 1.7.0). */
highQuality: boolean;
slate: { slate: {
versionName: string; versionName: string;
date: string; date: string;
@@ -122,6 +125,7 @@ export async function buildPreviewManifest(exportRow: ExportForPreview): Promise
mp4Output: `${exportRow.outputDir}/${base}.mp4`, mp4Output: `${exportRow.outputDir}/${base}.mp4`,
slateScopeProp: cfg.slateScopeProp, slateScopeProp: cfg.slateScopeProp,
slateSubmissionProp: cfg.slateSubmissionProp, slateSubmissionProp: cfg.slateSubmissionProp,
highQuality: cfg.highQuality,
slate: { slate: {
versionName: clientBase, versionName: clientBase,
date: slateDate(), date: slateDate(),
@@ -295,6 +299,12 @@ export interface FinalizeInput {
thumbnailKey?: string; thumbnailKey?: string;
metadataKey?: string; metadataKey?: string;
logFileKey?: string; logFileKey?: string;
/** High-bitrate full-res MP4 encoded from the MOV (worker >= 1.7.0). */
hqPreviewKey?: string | null;
/** The delivery MOV itself, uploaded as the version's High Res file. */
highResKey?: string | null;
highResFileName?: string | null;
highResSize?: number | null;
}; };
renderStats?: { renderSeconds?: number; previewSeconds?: number }; renderStats?: { renderSeconds?: number; previewSeconds?: number };
media?: { media?: {
@@ -318,6 +328,19 @@ export interface FinalizeInput {
* resurface in the review flow or the client portal. * resurface in the review flow or the client portal.
*/ */
export async function finalizePreview(jobId: string, machineId: string, body: FinalizeInput) { export async function finalizePreview(jobId: string, machineId: string, body: FinalizeInput) {
const { replacedHighRes, housekeepTaskId, ...outcome } = await finalizePreviewTx(jobId, machineId, body);
// Storage is only touched once the transaction has committed: a rollback
// must never leave a Version pointing at a deleted file.
await deleteStorageKeys(replacedHighRes);
if (housekeepTaskId) {
await pruneHighResForTask(housekeepTaskId).catch((err) =>
console.error("[high-res] housekeeping after finalize failed", err)
);
}
return outcome;
}
async function finalizePreviewTx(jobId: string, machineId: string, body: FinalizeInput) {
return db.$transaction(async (tx) => { return db.$transaction(async (tx) => {
const job = await tx.renderJob.findUnique({ const job = await tx.renderJob.findUnique({
where: { id: jobId }, where: { id: jobId },
@@ -336,6 +359,8 @@ export async function finalizePreview(jobId: string, machineId: string, body: Fi
version: { id: exportRow.versionId }, version: { id: exportRow.versionId },
task: null, task: null,
alreadyApplied: true, alreadyApplied: true,
replacedHighRes: [] as string[],
housekeepTaskId: null as string | null,
}; };
} }
@@ -354,6 +379,7 @@ export async function finalizePreview(jobId: string, machineId: string, body: Fi
}); });
let versionId: string | null = null; let versionId: string | null = null;
let replacedHighRes: string[] = [];
let versionReplaced = false; let versionReplaced = false;
let versionCreated = false; let versionCreated = false;
let inheritedApproval: string | null = null; let inheritedApproval: string | null = null;
@@ -377,6 +403,16 @@ export async function finalizePreview(jobId: string, machineId: string, body: Fi
typeof media.pictureStartSeconds === "number" && media.pictureStartSeconds >= 0 typeof media.pictureStartSeconds === "number" && media.pictureStartSeconds >= 0
? Math.round(media.pictureStartSeconds * fps) ? Math.round(media.pictureStartSeconds * fps)
: null, : null,
// High quality + ProRes: whatever this build produced. Absent means
// the worker skipped or failed them (never fatal) — Standard only.
hqFileUrl: artifacts.hqPreviewKey ? `/api/files/${artifacts.hqPreviewKey}` : null,
highResKey: artifacts.highResKey ?? null,
highResFilename: artifacts.highResKey ? (artifacts.highResFileName ?? null) : null,
highResSize:
artifacts.highResKey && typeof artifacts.highResSize === "number"
? BigInt(Math.round(artifacts.highResSize))
: null,
highResPrunedAt: null,
}; };
// Regenerating a preview (T11b) re-delivers the *same* export version, so // Regenerating a preview (T11b) re-delivers the *same* export version, so
@@ -384,10 +420,18 @@ export async function finalizePreview(jobId: string, machineId: string, body: Fi
// stacking a second row: the version number stays put and any notes or // stacking a second row: the version number stays put and any notes or
// annotations reviewers left on it survive the reslate. // annotations reviewers left on it survive the reslate.
const existing = exportRow.versionId const existing = exportRow.versionId
? await tx.version.findUnique({ where: { id: exportRow.versionId }, select: { id: true } }) ? await tx.version.findUnique({
where: { id: exportRow.versionId },
select: { id: true, hqFileUrl: true, highResKey: true },
})
: null; : null;
if (existing) { if (existing) {
// The reslate replaces the high-quality files too; the old ones would
// otherwise sit in storage with nothing pointing at them.
replacedHighRes = highResStorageKeys(existing).filter(
(k) => k !== artifacts.highResKey && k !== artifacts.hqPreviewKey
);
await tx.version.update({ await tx.version.update({
where: { id: existing.id }, where: { id: existing.id },
data: { ...mediaFields, isLatest: true }, data: { ...mediaFields, isLatest: true },
@@ -555,7 +599,8 @@ export async function finalizePreview(jobId: string, machineId: string, body: Fi
: " — no preview media reported") + : " — no preview media reported") +
(taskRenamed ? ` — task renamed to ${taskTitle}` : "") + (taskRenamed ? ` — task renamed to ${taskTitle}` : "") +
(taskStatusMoved ? " — task moved to INTERNAL_REVIEW" : "") + (taskStatusMoved ? " — task moved to INTERNAL_REVIEW" : "") +
thumbnailNote, thumbnailNote +
(versionId ? highResNote(artifacts) : ""),
}, },
{ {
...(versionId ? { versionId } : {}), ...(versionId ? { versionId } : {}),
@@ -571,10 +616,22 @@ export async function finalizePreview(jobId: string, machineId: string, body: Fi
version: versionId ? { id: versionId, replaced: versionReplaced } : null, version: versionId ? { id: versionId, replaced: versionReplaced } : null,
task: exportRow.taskId ? { id: exportRow.taskId, title: taskTitle, renamed: taskRenamed } : null, task: exportRow.taskId ? { id: exportRow.taskId, title: taskTitle, renamed: taskRenamed } : null,
alreadyApplied: false, alreadyApplied: false,
replacedHighRes,
housekeepTaskId: versionId ? exportRow.taskId : null,
}; };
}); });
} }
/** Timeline wording for what high-quality media this build attached. */
function highResNote(artifacts: NonNullable<FinalizeInput["artifacts"]>): string {
const hq = !!artifacts.hqPreviewKey;
const mov = !!artifacts.highResKey;
if (hq && mov) return " — High quality MP4 and ProRes attached";
if (hq) return " — High quality MP4 attached (ProRes upload failed or skipped)";
if (mov) return " — ProRes attached (High quality encode failed or skipped)";
return " — no High quality/ProRes (disabled, worker < 1.7.0, or both failed — see the worker log)";
}
/** T11 — retry the preview stage only; validated EXRs are left alone. */ /** T11 — retry the preview stage only; validated EXRs are left alone. */
export async function retryPreview(exportId: string, actorId?: string) { export async function retryPreview(exportId: string, actorId?: string) {
const maxAttempts = await getConfigNumber("render.maxAttempts"); const maxAttempts = await getConfigNumber("render.maxAttempts");
+14 -1
View File
@@ -1,4 +1,5 @@
import { Prisma } from "@prisma/client"; import { Prisma } from "@prisma/client";
import { pruneHighResSweep } from "./high-res";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { applyTransition } from "./transitions"; import { applyTransition } from "./transitions";
import { getConfigNumber } from "./config"; import { getConfigNumber } from "./config";
@@ -96,9 +97,21 @@ export async function reaperSweep() {
await db.workerHeartbeat.deleteMany({ await db.workerHeartbeat.deleteMany({
where: { createdAt: { lt: new Date(Date.now() - 7 * 24 * 3600 * 1000) } }, where: { createdAt: { lt: new Date(Date.now() - 7 * 24 * 3600 * 1000) } },
}); });
// High-quality media housekeeping: hourly is plenty, and it can touch
// object storage, so it stays off the once-a-minute lease path.
if (Date.now() - (globalForReaper.lastHighResSweep ?? 0) >= HIGH_RES_SWEEP_MS) {
globalForReaper.lastHighResSweep = Date.now();
await pruneHighResSweep().catch((err) => console.error("[high-res] housekeeping sweep failed", err));
}
} }
const globalForReaper = globalThis as unknown as { renderPipelineReaper?: ReturnType<typeof setInterval> }; const HIGH_RES_SWEEP_MS = 60 * 60 * 1000;
const globalForReaper = globalThis as unknown as {
renderPipelineReaper?: ReturnType<typeof setInterval>;
lastHighResSweep?: number;
};
/** Started once from instrumentation.ts (Next.js server startup hook). */ /** Started once from instrumentation.ts (Next.js server startup hook). */
export function startReaper(intervalMs = 60_000) { export function startReaper(intervalMs = 60_000) {
+19 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { clientShotCode, clientTaskTitle } from "./shot-code"; import { clientHighResFilename, clientShotCode, clientTaskTitle } from "./shot-code";
describe("clientShotCode", () => { describe("clientShotCode", () => {
it("prefers the client's alias", () => { it("prefers the client's alias", () => {
@@ -43,3 +43,21 @@ describe("clientTaskTitle", () => {
expect(clientTaskTitle("UNG_106_010_020_cmp", same)).toBe("UNG_106_010_020_cmp"); expect(clientTaskTitle("UNG_106_010_020_cmp", same)).toBe("UNG_106_010_020_cmp");
}); });
}); });
describe("clientHighResFilename", () => {
const shot = { shotCode: "UNG_104_051_040", clientCode: "EP4_0510" };
it("uses the slate's client naming when the shot has an alias", () => {
expect(clientHighResFilename("UNG_104_051_040_cmp_TT_v006.mov", shot)).toBe("EP4_0510_v006.mov");
});
it("keeps our own name when there is no alias", () => {
expect(clientHighResFilename("UNG_104_051_040_cmp_TT_v006.mov", { shotCode: "UNG_104_051_040" })).toBe(
"UNG_104_051_040_cmp_TT_v006.mov"
);
});
it("swaps the code in a name that doesn't follow the convention", () => {
expect(clientHighResFilename("final_UNG_104_051_040.mov", shot)).toBe("final_EP4_0510.mov");
});
});
+13
View File
@@ -39,3 +39,16 @@ export function clientTaskTitle(title: string, shot: ShotCodeFields): string {
// otherwise need escaping. // otherwise need escaping.
return title.split(shot.shotCode).join(alias); return title.split(shot.shotCode).join(alias);
} }
/**
* The High Res (ProRes) download name for a client. With an alias it follows
* the slate's client naming, `{clientCode}_{version}` — no internal code and
* no "_cmp_TT_" studio suffix; without one the file keeps its own name.
*/
export function clientHighResFilename(fileName: string, shot: ShotCodeFields | null): string {
const alias = shot?.clientCode?.trim();
if (!shot || !alias || alias === shot.shotCode) return fileName;
const m = /_(v\d+)(\.[a-z0-9]+)$/i.exec(fileName);
if (m && fileName.startsWith(shot.shotCode)) return `${alias}_${m[1]}${m[2]}`;
return fileName.split(shot.shotCode).join(alias);
}
@@ -0,0 +1,9 @@
-- Per-version high-quality media, uploaded by the render worker: a high
-- bitrate review MP4 for the player's quality switch, and the delivery MOV
-- (ProRes) as the version's High Res download. Housekeeping prunes both from
-- versions nobody can need any more (see lib/render-pipeline/high-res.ts).
ALTER TABLE "versions" ADD COLUMN "hqFileUrl" TEXT;
ALTER TABLE "versions" ADD COLUMN "highResKey" TEXT;
ALTER TABLE "versions" ADD COLUMN "highResFilename" TEXT;
ALTER TABLE "versions" ADD COLUMN "highResSize" BIGINT;
ALTER TABLE "versions" ADD COLUMN "highResPrunedAt" TIMESTAMP(3);
+10
View File
@@ -548,6 +548,16 @@ model Version {
/// Frames of slate before the picture in this media. Null = not recorded /// Frames of slate before the picture in this media. Null = not recorded
/// (the player infers it from the media length vs frameCount). /// (the player infers it from the media length vs frameCount).
pictureStartFrame Int? pictureStartFrame Int?
/// High-quality review MP4 (full res, high bitrate) encoded from the
/// delivery MOV — the player's "High" quality. Null = Standard only.
hqFileUrl String?
/// The delivery MOV (ProRes) itself, downloadable as this version's High
/// Res file. Supersedes the shot-level manual highResKey where present.
highResKey String?
highResFilename String?
highResSize BigInt?
/// Set when housekeeping removed hqFileUrl/highResKey from storage.
highResPrunedAt DateTime?
notes String? @db.Text notes String? @db.Text
approvalStatus ApprovalStatus @default(PENDING_REVIEW) approvalStatus ApprovalStatus @default(PENDING_REVIEW)
reviewStatus ReviewStatus @default(PENDING) reviewStatus ReviewStatus @default(PENDING)