From 9660cdc21ceb34a037fbb74451677d11c32dab24 Mon Sep 17 00:00:00 2001 From: twotalesanimation <80506065+twotalesanimation@users.noreply.github.com> Date: Tue, 22 Sep 2026 16:32:18 +0200 Subject: [PATCH] feat(review): High quality playback switch and per-version ProRes download 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 --- RenderPipeline2.md | 11 ++ RenderWorker/VFXReviewWorker/Dtos.cs | 5 + RenderWorker/VFXReviewWorker/JobExecutor.cs | 36 +++++ .../VFXReviewWorker/LeaseKeepAlive.cs | 43 ++++++ RenderWorker/VFXReviewWorker/PreviewStage.cs | 101 ++++++++++++++ .../VFXReviewWorker/VFXReviewWorker.csproj | 2 +- actions/versions.ts | 5 + app/(dashboard)/playlist/PlaylistClient.tsx | 2 + .../[versionId]/highres/download/route.ts | 57 ++++++++ .../[token]/versions/[versionId]/route.ts | 16 ++- .../jobs/[jobId]/artifact-presign/route.ts | 4 +- app/api/playlist/route.ts | 2 + .../[versionId]/highres/download/route.ts | 34 +++++ .../[token]/review/[versionId]/page.tsx | 16 ++- app/review/[versionId]/ReviewPageClient.tsx | 46 ++++++- components/player/PlaybackControls.tsx | 33 +++++ components/player/ReviewPlayer.tsx | 71 +++++++++- components/versions/VersionHandlesButton.tsx | 4 +- lib/render-pipeline/config.ts | 17 ++- lib/render-pipeline/high-res.test.ts | 54 ++++++++ lib/render-pipeline/high-res.ts | 124 ++++++++++++++++++ lib/render-pipeline/preview.ts | 61 ++++++++- lib/render-pipeline/reaper.ts | 15 ++- lib/shot-code.test.ts | 20 ++- lib/shot-code.ts | 13 ++ .../migration.sql | 9 ++ prisma/schema.prisma | 10 ++ 27 files changed, 793 insertions(+), 18 deletions(-) create mode 100644 RenderWorker/VFXReviewWorker/LeaseKeepAlive.cs create mode 100644 app/api/client/[token]/versions/[versionId]/highres/download/route.ts create mode 100644 app/api/versions/[versionId]/highres/download/route.ts create mode 100644 lib/render-pipeline/high-res.test.ts create mode 100644 lib/render-pipeline/high-res.ts create mode 100644 prisma/migrations/20260922010000_version_high_res/migration.sql 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" && (
@@ -380,6 +423,7 @@ export function ReviewPageClient({ onAddComment={handleAddComment} onAnnotationSaved={handleAnnotationSaved} handles={version} + hqVideoUrl={version.hqFileUrl} />
diff --git a/components/player/PlaybackControls.tsx b/components/player/PlaybackControls.tsx index 41015cc..10741fd 100644 --- a/components/player/PlaybackControls.tsx +++ b/components/player/PlaybackControls.tsx @@ -47,6 +47,9 @@ interface PlaybackControlsProps { onTogglePlay: () => void; onToggleFullscreen: () => 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({ @@ -59,6 +62,8 @@ export function PlaybackControls({ onTogglePlay, onToggleFullscreen, onAddComment, + quality = null, + onQualityChange, }: PlaybackControlsProps) { const { isPlaying, @@ -297,6 +302,34 @@ export function PlaybackControls({ )}
+ {/* Quality — only when the version has a High quality source */} + {quality && ( + + + + + + {quality === "high" + ? "High quality — click for Standard (smaller, loads faster)" + : "Switch to High quality (full resolution, needs a fast connection)"} + + + )} + {/* Edit range only — only for versions with handles marked */} {editRange && ( diff --git a/components/player/ReviewPlayer.tsx b/components/player/ReviewPlayer.tsx index 3dac56a..1eee9a1 100644 --- a/components/player/ReviewPlayer.tsx +++ b/components/player/ReviewPlayer.tsx @@ -46,8 +46,13 @@ interface ReviewPlayerProps { handles?: VersionHandles | null; /** Start with playback kept to the edit (the client portal's default). */ 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( function ReviewPlayer( { @@ -63,6 +68,7 @@ export const ReviewPlayer = forwardRef( addCommentFn, handles, defaultEditRangeOnly = false, + hqVideoUrl, }, ref ) { @@ -75,6 +81,41 @@ export const ReviewPlayer = forwardRef( const zoomLayerRef = useRef(null); const reverseIntervalRef = useRef | null>(null); 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("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 { @@ -142,6 +183,8 @@ export const ReviewPlayer = forwardRef( setTotalFrames(0); resetZoom(); openedAtCut.current = false; + resumeRef.current = null; + setSwitchingQuality(false); }, [videoUrl, setCurrentFrame, setCurrentTime, setDuration, setTotalFrames, resetZoom]); // ── Playback state sync ────────────────────────────────────────────────── @@ -159,8 +202,24 @@ export const ReviewPlayer = forwardRef( const frames = durationToFrameCount(video.duration, fps); setDuration(video.duration); 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]); + // 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 * is outside the cut (slate, a handle, or parked on the last cut frame). @@ -413,12 +472,14 @@ export const ReviewPlayer = forwardRef( >
)} + {switchingQuality && ( +
+ Loading {quality === "high" ? "high" : "standard"} quality… +
+ )} + {/* JKL hint overlay — shown briefly when reversing */} {isReversing && (
@@ -516,6 +583,8 @@ export const ReviewPlayer = forwardRef( onTogglePlay={togglePlayback} onToggleFullscreen={toggleFullscreen} onAddComment={handleAddComment} + quality={hqVideoUrl ? quality : null} + onQualityChange={changeQuality} />
); 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} diff --git a/lib/render-pipeline/config.ts b/lib/render-pipeline/config.ts index 79544f5..74dddba 100644 --- a/lib/render-pipeline/config.ts +++ b/lib/render-pipeline/config.ts @@ -48,6 +48,18 @@ export const PIPELINE_CONFIG_DEFAULTS = { // names as a warning, so it is a config fix rather than a code change. "preview.slateScopeProp": "Scope", "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 * 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 export async function getPreviewConfig() { const [ enabled, templateAep, templateComp, overlayComp, lutComp, movTemplate, mp4Template, - slateScopeProp, slateSubmissionProp, + slateScopeProp, slateSubmissionProp, highQuality, ] = await Promise.all([ getConfigBoolean("preview.enabled"), getConfigValue("preview.templateAep"), @@ -90,10 +102,11 @@ export async function getPreviewConfig() { getConfigValue("preview.mp4Template"), getConfigValue("preview.slateScopeProp"), getConfigValue("preview.slateSubmissionProp"), + getConfigBoolean("preview.highQuality"), ]); return { enabled, templateAep, templateComp, overlayComp, lutComp, movTemplate, mp4Template, - slateScopeProp, slateSubmissionProp, + slateScopeProp, slateSubmissionProp, highQuality, }; } diff --git a/lib/render-pipeline/high-res.test.ts b/lib/render-pipeline/high-res.test.ts new file mode 100644 index 0000000..dd95e56 --- /dev/null +++ b/lib/render-pipeline/high-res.test.ts @@ -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([]); + }); +}); diff --git a/lib/render-pipeline/high-res.ts b/lib/render-pipeline/high-res.ts new file mode 100644 index 0000000..b81ad25 --- /dev/null +++ b/lib/render-pipeline/high-res.ts @@ -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 { + 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 { + 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 { + 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 { + 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; +} diff --git a/lib/render-pipeline/preview.ts b/lib/render-pipeline/preview.ts index 1398cfc..737ff9a 100644 --- a/lib/render-pipeline/preview.ts +++ b/lib/render-pipeline/preview.ts @@ -5,6 +5,7 @@ import { applyTransition } from "./transitions"; import { getConfigNumber, getPreviewConfig } from "./config"; import { resolveProjectTemplates, autoShotThumbnailEnabled } from "./project-templates"; import { clientShotCode } from "@/lib/shot-code"; +import { deleteStorageKeys, highResStorageKeys, pruneHighResForTask } from "./high-res"; import { recalcShotStatus, clearStaleShotApproval } from "@/lib/shot-status"; import { renderOutputBase } from "./exports"; @@ -35,6 +36,8 @@ export interface PreviewManifest { mp4Output: string; slateScopeProp: string; slateSubmissionProp: string; + /** Also encode the High quality MP4 and upload the MOV (worker >= 1.7.0). */ + highQuality: boolean; slate: { versionName: string; date: string; @@ -122,6 +125,7 @@ export async function buildPreviewManifest(exportRow: ExportForPreview): Promise mp4Output: `${exportRow.outputDir}/${base}.mp4`, slateScopeProp: cfg.slateScopeProp, slateSubmissionProp: cfg.slateSubmissionProp, + highQuality: cfg.highQuality, slate: { versionName: clientBase, date: slateDate(), @@ -295,6 +299,12 @@ export interface FinalizeInput { thumbnailKey?: string; metadataKey?: 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 }; media?: { @@ -318,6 +328,19 @@ export interface FinalizeInput { * resurface in the review flow or the client portal. */ 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) => { const job = await tx.renderJob.findUnique({ where: { id: jobId }, @@ -336,6 +359,8 @@ export async function finalizePreview(jobId: string, machineId: string, body: Fi version: { id: exportRow.versionId }, task: null, 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 replacedHighRes: string[] = []; let versionReplaced = false; let versionCreated = false; 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 ? Math.round(media.pictureStartSeconds * fps) : 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 @@ -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 // annotations reviewers left on it survive the reslate. 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; 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({ where: { id: existing.id }, data: { ...mediaFields, isLatest: true }, @@ -555,7 +599,8 @@ export async function finalizePreview(jobId: string, machineId: string, body: Fi : " — no preview media reported") + (taskRenamed ? ` — task renamed to ${taskTitle}` : "") + (taskStatusMoved ? " — task moved to INTERNAL_REVIEW" : "") + - thumbnailNote, + thumbnailNote + + (versionId ? highResNote(artifacts) : ""), }, { ...(versionId ? { versionId } : {}), @@ -571,10 +616,22 @@ export async function finalizePreview(jobId: string, machineId: string, body: Fi version: versionId ? { id: versionId, replaced: versionReplaced } : null, task: exportRow.taskId ? { id: exportRow.taskId, title: taskTitle, renamed: taskRenamed } : null, alreadyApplied: false, + replacedHighRes, + housekeepTaskId: versionId ? exportRow.taskId : null, }; }); } +/** Timeline wording for what high-quality media this build attached. */ +function highResNote(artifacts: NonNullable): 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. */ export async function retryPreview(exportId: string, actorId?: string) { const maxAttempts = await getConfigNumber("render.maxAttempts"); diff --git a/lib/render-pipeline/reaper.ts b/lib/render-pipeline/reaper.ts index 6678ef3..bc4f6d9 100644 --- a/lib/render-pipeline/reaper.ts +++ b/lib/render-pipeline/reaper.ts @@ -1,4 +1,5 @@ import { Prisma } from "@prisma/client"; +import { pruneHighResSweep } from "./high-res"; import { db } from "@/lib/db"; import { applyTransition } from "./transitions"; import { getConfigNumber } from "./config"; @@ -96,9 +97,21 @@ export async function reaperSweep() { await db.workerHeartbeat.deleteMany({ 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 }; +const HIGH_RES_SWEEP_MS = 60 * 60 * 1000; + +const globalForReaper = globalThis as unknown as { + renderPipelineReaper?: ReturnType; + lastHighResSweep?: number; +}; /** Started once from instrumentation.ts (Next.js server startup hook). */ export function startReaper(intervalMs = 60_000) { diff --git a/lib/shot-code.test.ts b/lib/shot-code.test.ts index 6906dc6..c446bbb 100644 --- a/lib/shot-code.test.ts +++ b/lib/shot-code.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { clientShotCode, clientTaskTitle } from "./shot-code"; +import { clientHighResFilename, clientShotCode, clientTaskTitle } from "./shot-code"; describe("clientShotCode", () => { 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"); }); }); + +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"); + }); +}); diff --git a/lib/shot-code.ts b/lib/shot-code.ts index ce9ac5c..44c98aa 100644 --- a/lib/shot-code.ts +++ b/lib/shot-code.ts @@ -39,3 +39,16 @@ export function clientTaskTitle(title: string, shot: ShotCodeFields): string { // otherwise need escaping. 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); +} diff --git a/prisma/migrations/20260922010000_version_high_res/migration.sql b/prisma/migrations/20260922010000_version_high_res/migration.sql new file mode 100644 index 0000000..2027ffa --- /dev/null +++ b/prisma/migrations/20260922010000_version_high_res/migration.sql @@ -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); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index a969792..6a8c253 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -548,6 +548,16 @@ model Version { /// Frames of slate before the picture in this media. Null = not recorded /// (the player infers it from the media length vs frameCount). 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 approvalStatus ApprovalStatus @default(PENDING_REVIEW) reviewStatus ReviewStatus @default(PENDING)