Files
vfxreview/lib/render-pipeline/reaper.ts
T
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

126 lines
5.0 KiB
TypeScript

import { Prisma } from "@prisma/client";
import { pruneHighResSweep } from "./high-res";
import { db } from "@/lib/db";
import { applyTransition } from "./transitions";
import { getConfigNumber } from "./config";
/**
* Server-side reaper (RenderPipeline2 §7.7) — the only background job the web
* app gains. Covers worker power loss, BSOD and network partition with no
* worker cooperation:
* 1. expired leases → job EXPIRED; requeue (T5) or Export → RENDER_FAILED (T4)
* 2. silent machines → OFFLINE
* 3. prune heartbeats older than 7 days
*/
export async function reapExpiredLeases() {
const expired = await db.renderJob.findMany({
where: { status: { in: ["CLAIMED", "RUNNING"] }, leaseExpiresAt: { lt: new Date() } },
include: { export: { select: { id: true, status: true } } },
});
for (const job of expired) {
try {
await db.$transaction(async (tx) => {
// Re-check inside the transaction — the worker may have reported in between
const fresh = await tx.renderJob.findUnique({ where: { id: job.id }, select: { status: true, leaseExpiresAt: true } });
if (!fresh || !["CLAIMED", "RUNNING"].includes(fresh.status) || !fresh.leaseExpiresAt || fresh.leaseExpiresAt >= new Date()) {
return;
}
await tx.renderJob.update({
where: { id: job.id },
data: { status: "EXPIRED", finishedAt: new Date(), errorMessage: "Lease expired (worker unreachable)" },
});
// A delivery build that died leaves a half-written folder, so it is
// failed rather than silently retried: someone should look at what
// landed on the delivery volume before it is rebuilt.
if (job.deliveryId) {
await tx.deliveryPackage.updateMany({
where: { id: job.deliveryId, status: "BUILDING" },
data: {
status: "FAILED",
errorMessage: "Worker became unreachable mid-build — check the package folder before rebuilding",
},
});
return;
}
if (!job.export) return;
if (job.attempt < job.maxAttempts) {
const next = await tx.renderJob.create({
data: {
type: job.type,
exportId: job.exportId,
attempt: job.attempt + 1,
maxAttempts: job.maxAttempts,
priority: job.priority,
manifest: job.manifest as Prisma.InputJsonValue,
},
});
// VALIDATING counts too: the worker holds the same job through the
// validation stage, so dying there means the render must be redone —
// nothing was established about the EXRs either way.
if (["RENDERING", "VALIDATING"].includes(job.export.status)) {
await applyTransition(tx, job.export, "QUEUED", {
type: "SYSTEM",
note: `Lease expired on attempt ${job.attempt} during ${job.export.status} — requeued as attempt ${next.attempt}`,
});
}
} else if (["RENDERING", "VALIDATING"].includes(job.export.status)) {
await applyTransition(tx, job.export, "RENDER_FAILED", {
type: "SYSTEM",
note: `Lease expired on attempt ${job.attempt}/${job.maxAttempts} during ${job.export.status} — no retries remain`,
});
}
});
} catch (err) {
console.error(`[render-pipeline] reaper failed for job ${job.id}`, err);
}
}
return expired.length;
}
export async function reaperSweep() {
const heartbeatSeconds = await getConfigNumber("render.heartbeatSeconds");
await reapExpiredLeases();
await db.machine.updateMany({
where: {
status: "ONLINE",
OR: [{ lastSeenAt: null }, { lastSeenAt: { lt: new Date(Date.now() - heartbeatSeconds * 3 * 1000) } }],
},
data: { status: "OFFLINE" },
});
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 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). */
export function startReaper(intervalMs = 60_000) {
if (globalForReaper.renderPipelineReaper) return;
globalForReaper.renderPipelineReaper = setInterval(() => {
reaperSweep().catch((err) => console.error("[render-pipeline] reaper sweep failed", err));
}, intervalMs);
// Don't hold the process open on shutdown
globalForReaper.renderPipelineReaper.unref?.();
console.log("[render-pipeline] reaper started (interval", intervalMs, "ms)");
}