9660cdc21c
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>
126 lines
5.0 KiB
TypeScript
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)");
|
|
}
|