diff --git a/RenderPipeline2.md b/RenderPipeline2.md
index a18c8d6..5ff24b6 100644
--- a/RenderPipeline2.md
+++ b/RenderPipeline2.md
@@ -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.
- **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
diff --git a/RenderWorker/VFXReviewWorker/Dtos.cs b/RenderWorker/VFXReviewWorker/Dtos.cs
index 79c077a..8974ab1 100644
--- a/RenderWorker/VFXReviewWorker/Dtos.cs
+++ b/RenderWorker/VFXReviewWorker/Dtos.cs
@@ -175,6 +175,11 @@ public sealed class PreviewManifest
public string Mp4Output { get; set; } = "";
public string? SlateScopeProp { get; set; }
public string? SlateSubmissionProp { get; set; }
+ ///
+ /// Also encode the High quality MP4 from the MOV and upload the MOV itself
+ /// as the version's High Res file (server "preview.highQuality").
+ ///
+ public bool HighQuality { get; set; }
public SlateInfo Slate { get; set; } = new();
public int TotalFrames => FrameEnd - FrameStart + 1;
diff --git a/RenderWorker/VFXReviewWorker/JobExecutor.cs b/RenderWorker/VFXReviewWorker/JobExecutor.cs
index 4e320f9..cc0030d 100644
--- a/RenderWorker/VFXReviewWorker/JobExecutor.cs
+++ b/RenderWorker/VFXReviewWorker/JobExecutor.cs
@@ -441,6 +441,32 @@ public sealed class JobExecutor
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
{
["machineId"] = machineId,
@@ -449,6 +475,10 @@ public sealed class JobExecutor
["deliveryMovPath"] = manifest.MovOutput,
["previewMovKey"] = previewKey,
["thumbnailKey"] = thumbKey,
+ ["hqPreviewKey"] = hqKey,
+ ["highResKey"] = highResKey,
+ ["highResFileName"] = highResKey is null ? null : Path.GetFileName(result.MovPath),
+ ["highResSize"] = highResKey is null ? null : highResSize,
},
["renderStats"] = new Dictionary
{
@@ -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}_thumb.jpg"));
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 */ }
}
///
diff --git a/RenderWorker/VFXReviewWorker/LeaseKeepAlive.cs b/RenderWorker/VFXReviewWorker/LeaseKeepAlive.cs
new file mode 100644
index 0000000..a88d0ef
--- /dev/null
+++ b/RenderWorker/VFXReviewWorker/LeaseKeepAlive.cs
@@ -0,0 +1,43 @@
+namespace VFXReviewWorker;
+
+///
+/// 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.
+///
+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();
+ }
+}
diff --git a/RenderWorker/VFXReviewWorker/PreviewStage.cs b/RenderWorker/VFXReviewWorker/PreviewStage.cs
index e39dea0..274346a 100644
--- a/RenderWorker/VFXReviewWorker/PreviewStage.cs
+++ b/RenderWorker/VFXReviewWorker/PreviewStage.cs
@@ -302,6 +302,107 @@ public sealed class PreviewStage
return null;
}
+ /// Scratch folder for a job's High quality encode.
+ internal static string HighQualityDir(string jobId) =>
+ Path.Combine(WorkerOptions.ScratchDir, $"job_{jobId}_hq");
+
+ ///
+ /// 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.
+ ///
+ public async Task 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
+ {
+ "-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;
+ }
+ }
+
/// How one ffmpeg attempt ended.
private enum ThumbAttempt
{
diff --git a/RenderWorker/VFXReviewWorker/VFXReviewWorker.csproj b/RenderWorker/VFXReviewWorker/VFXReviewWorker.csproj
index bae8a92..8d4243a 100644
--- a/RenderWorker/VFXReviewWorker/VFXReviewWorker.csproj
+++ b/RenderWorker/VFXReviewWorker/VFXReviewWorker.csproj
@@ -15,7 +15,7 @@
every change that gets published. publish.ps1 appends the git SHA via
SourceRevisionId, giving "1.1.0+feb1c7b".
-->
- 1.6.4
+ 1.7.0
diff --git a/actions/versions.ts b/actions/versions.ts
index a065259..5031fff 100644
--- a/actions/versions.ts
+++ b/actions/versions.ts
@@ -7,6 +7,7 @@ import { z } from "zod";
import { recalcShotStatus, clearStaleShotApproval } from "@/lib/shot-status";
import { notifyNewVersionUploaded } from "@/lib/notifications";
import { slackNotifyVersionUploaded } from "@/lib/slack";
+import { deleteStorageKeys, highResStorageKeys } from "@/lib/render-pipeline/high-res";
const createVersionSchema = z.object({
taskId: z.string().cuid(),
@@ -185,6 +186,8 @@ export async function deleteVersion(versionId: string) {
id: true,
isLatest: true,
taskId: true,
+ hqFileUrl: true,
+ highResKey: true,
task: { select: { projectId: true, shotId: true } },
},
});
@@ -203,6 +206,8 @@ export async function deleteVersion(versionId: string) {
}
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 shotId = version.task?.shotId;
diff --git a/app/(dashboard)/playlist/PlaylistClient.tsx b/app/(dashboard)/playlist/PlaylistClient.tsx
index 1528d9d..207b3c9 100644
--- a/app/(dashboard)/playlist/PlaylistClient.tsx
+++ b/app/(dashboard)/playlist/PlaylistClient.tsx
@@ -43,6 +43,7 @@ interface PlaylistVersion {
posterUrl: string | null;
fps: number;
frameCount?: number | null;
+ hqFileUrl?: string | null;
handleHead?: number | null;
handleTail?: number | null;
pictureStartFrame?: number | null;
@@ -281,6 +282,7 @@ export function PlaylistClient({ projects, userRole }: PlaylistClientProps) {
comments={[]}
annotations={[]}
handles={activeShot.latestVersion}
+ hqVideoUrl={activeShot.latestVersion.hqFileUrl}
/>
) : (
diff --git a/app/api/client/[token]/versions/[versionId]/highres/download/route.ts b/app/api/client/[token]/versions/[versionId]/highres/download/route.ts
new file mode 100644
index 0000000..4338a7a
--- /dev/null
+++ b/app/api/client/[token]/versions/[versionId]/highres/download/route.ts
@@ -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 });
+}
diff --git a/app/api/client/[token]/versions/[versionId]/route.ts b/app/api/client/[token]/versions/[versionId]/route.ts
index bf72b58..76a6ea7 100644
--- a/app/api/client/[token]/versions/[versionId]/route.ts
+++ b/app/api/client/[token]/versions/[versionId]/route.ts
@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { db } from "@/lib/db";
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 */
export async function GET(
@@ -95,13 +95,21 @@ export async function GET(
// The shot the title and breadcrumb are drawn from — either relation.
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 = {
...version,
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
- hasHighRes: !!(highResShot?.highResKey),
- highResShotId: highResShot?.highResKey ? (highResShot.id ?? null) : null,
- highResFilename: highResShot?.highResFilename ?? null,
+ hasHighRes: versionHighRes || !!(highResShot?.highResKey),
+ highResSource: versionHighRes ? "version" : highResShot?.highResKey ? "shot" : 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
shot: version.shot
? {
diff --git a/app/api/ext/render/jobs/[jobId]/artifact-presign/route.ts b/app/api/ext/render/jobs/[jobId]/artifact-presign/route.ts
index 4fc2c0e..fd326cd 100644
--- a/app/api/ext/render/jobs/[jobId]/artifact-presign/route.ts
+++ b/app/api/ext/render/jobs/[jobId]/artifact-presign/route.ts
@@ -9,13 +9,15 @@ import { generateHetznerPresignedUploadUrl, sanitizeFileName } from "@/lib/stora
//
// Presigned upload URL for worker artifacts. Kinds map to the existing object
// 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 = {
preview: "videos",
thumbnail: "image",
metadata: "renders",
log: "renders",
+ highres: "highres",
};
export async function POST(
diff --git a/app/api/playlist/route.ts b/app/api/playlist/route.ts
index 1f9caf7..a84e5df 100644
--- a/app/api/playlist/route.ts
+++ b/app/api/playlist/route.ts
@@ -51,6 +51,7 @@ export async function GET(req: NextRequest) {
posterUrl: true,
fps: true,
frameCount: true,
+ hqFileUrl: true,
handleHead: true,
handleTail: true,
pictureStartFrame: true,
@@ -90,6 +91,7 @@ export async function GET(req: NextRequest) {
posterUrl: latest.posterUrl,
fps: latest.fps,
frameCount: latest.frameCount,
+ hqFileUrl: latest.hqFileUrl,
handleHead: latest.handleHead,
handleTail: latest.handleTail,
pictureStartFrame: latest.pictureStartFrame,
diff --git a/app/api/versions/[versionId]/highres/download/route.ts b/app/api/versions/[versionId]/highres/download/route.ts
new file mode 100644
index 0000000..d9d6624
--- /dev/null
+++ b/app/api/versions/[versionId]/highres/download/route.ts
@@ -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 });
+}
diff --git a/app/client/[token]/review/[versionId]/page.tsx b/app/client/[token]/review/[versionId]/page.tsx
index 1c234d5..1307551 100644
--- a/app/client/[token]/review/[versionId]/page.tsx
+++ b/app/client/[token]/review/[versionId]/page.tsx
@@ -60,6 +60,10 @@ interface Version {
fps: number;
duration: 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;
handleTail?: number | null;
pictureStartFrame?: number | null;
@@ -281,10 +285,17 @@ export default function ClientReviewPage({
const handleDownloadHighRes = async () => {
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);
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");
const { url } = await res.json();
// Open the presigned URL — the Content-Disposition header forces a download
@@ -519,6 +530,7 @@ export default function ClientReviewPage({
versionId={version.id}
onAddComment={handlePlayerAddComment}
handles={version}
+ hqVideoUrl={version.hqFileUrl}
// The director watches the cut as cut; handles are still one
// toggle away, and flagged whenever the playhead is in one.
defaultEditRangeOnly
diff --git a/app/review/[versionId]/ReviewPageClient.tsx b/app/review/[versionId]/ReviewPageClient.tsx
index f96d875..236ba27 100644
--- a/app/review/[versionId]/ReviewPageClient.tsx
+++ b/app/review/[versionId]/ReviewPageClient.tsx
@@ -41,6 +41,7 @@ import {
Film,
ShieldCheck,
MessageSquare,
+ Download,
} from "lucide-react";
interface ReviewPageClientProps {
@@ -54,6 +55,10 @@ interface ReviewPageClientProps {
handleHead: number | null;
handleTail: number | null;
pictureStartFrame: number | null;
+ hqFileUrl: string | null;
+ highResKey: string | null;
+ highResFilename: string | null;
+ highResSize: bigint | number | null;
approvalStatus: string;
notes: string | null;
isClientVisible: boolean;
@@ -103,6 +108,11 @@ interface ReviewPageClientProps {
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 = {
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",
@@ -131,6 +141,7 @@ export function ReviewPageClient({
const [isInternallyApproving, setIsInternallyApproving] = useState(false);
const [showVersions, setShowVersions] = useState(false);
const [commentsOpen, setCommentsOpen] = useState(true);
+ const [downloadingProRes, setDownloadingProRes] = useState(false);
const playerRef = useRef(null);
@@ -141,6 +152,20 @@ export function ReviewPageClient({
const { toast } = useToast();
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(() => {
// Pause player first
playerRef.current?.pause();
@@ -253,7 +278,7 @@ export function ReviewPageClient({
-
+
{project?.code}
@@ -287,6 +312,24 @@ export function ReviewPageClient({
+ {/* ProRes — this version's delivery MOV, uploaded by the render worker */}
+ {version.highResKey && (
+
+ )}
+
{/* Handles — outside the breadcrumb so it never gets squeezed */}
{userRole !== "CLIENT" && (
);
diff --git a/components/versions/VersionHandlesButton.tsx b/components/versions/VersionHandlesButton.tsx
index 0ebb70d..c881ff4 100644
--- a/components/versions/VersionHandlesButton.tsx
+++ b/components/versions/VersionHandlesButton.tsx
@@ -100,10 +100,10 @@ export function VersionHandlesButton({
size="sm"
className={cn("h-7 gap-1 text-xs", marked ? "text-amber-400" : "text-muted-foreground")}
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`}
>
- {label}
+ {label}