Files
twotalesanimation 9660cdc21c
Deploy / deploy (push) Successful in 3m23s
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 <noreply@anthropic.com>
2026-09-22 16:32:18 +02:00

125 lines
4.3 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { 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;
}