feat(review): High quality playback switch and per-version ProRes download
Deploy / deploy (push) Successful in 3m23s
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:
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
|||||||
@@ -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
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user