248 lines
6.9 KiB
TypeScript
248 lines
6.9 KiB
TypeScript
import { readFile } from "node:fs/promises";
|
|
import path from "node:path";
|
|
|
|
import { createClient } from "@supabase/supabase-js";
|
|
|
|
import { buildNexrenderJob } from "./nexrender-job-builder";
|
|
import type { RenderScene, RenderSettings } from "../src/lib/render-schemas";
|
|
|
|
function getSupabase() {
|
|
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
|
const key = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
|
if (!url || !key) {
|
|
throw new Error("Supabase env vars required for render worker");
|
|
}
|
|
return createClient(url, key, {
|
|
auth: { autoRefreshToken: false, persistSession: false },
|
|
});
|
|
}
|
|
|
|
async function updateJob(
|
|
jobId: string,
|
|
updates: Record<string, unknown>
|
|
): Promise<void> {
|
|
const supabase = getSupabase();
|
|
await supabase.from("render_jobs").update(updates).eq("id", jobId);
|
|
}
|
|
|
|
async function uploadToStorage(
|
|
jobId: string,
|
|
filePath: string
|
|
): Promise<string> {
|
|
const supabase = getSupabase();
|
|
const buffer = await readFile(filePath);
|
|
const storagePath = `${jobId}/output.mp4`;
|
|
|
|
const { error } = await supabase.storage
|
|
.from("renders")
|
|
.upload(storagePath, buffer, {
|
|
contentType: "video/mp4",
|
|
upsert: true,
|
|
});
|
|
|
|
if (error) throw error;
|
|
|
|
const { data } = supabase.storage.from("renders").getPublicUrl(storagePath);
|
|
return data.publicUrl;
|
|
}
|
|
|
|
async function submitToNexrenderServer(
|
|
job: ReturnType<typeof buildNexrenderJob>
|
|
): Promise<string> {
|
|
const serverUrl = process.env.NEXRENDER_SERVER_URL;
|
|
if (!serverUrl) {
|
|
throw new Error("NEXRENDER_SERVER_URL not configured");
|
|
}
|
|
|
|
const response = await fetch(
|
|
`${serverUrl.replace(/\/$/, "")}/api/v1/jobs`,
|
|
{
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(job),
|
|
}
|
|
);
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Nexrender server error: ${response.status}`);
|
|
}
|
|
|
|
const payload = (await response.json()) as { uid?: string; id?: string };
|
|
return payload.uid ?? payload.id ?? "unknown";
|
|
}
|
|
|
|
async function renderWithCore(
|
|
job: ReturnType<typeof buildNexrenderJob>,
|
|
onProgress: (percent: number, message: string) => Promise<void>
|
|
): Promise<string> {
|
|
const { render } = await import("@nexrender/core");
|
|
const workPath =
|
|
process.env.NEXRENDER_WORKPATH ?? path.join(process.cwd(), ".nexrender");
|
|
|
|
await onProgress(10, "Starting After Effects render…");
|
|
|
|
const result = await render(job, {
|
|
workPath,
|
|
binary: process.env.NEXRENDER_BINARY,
|
|
skipCleanup: false,
|
|
onProgress: (
|
|
nexJob: { metadata?: Record<string, unknown> },
|
|
percent: number
|
|
) => {
|
|
const label = nexJob.metadata?.progressMessage as string | undefined;
|
|
void onProgress(
|
|
Math.min(95, Math.round(percent * 100)),
|
|
label ?? "Rendering composition…"
|
|
);
|
|
},
|
|
});
|
|
|
|
const outputPath =
|
|
typeof result === "string"
|
|
? result
|
|
: ((result as { output?: string })?.output ??
|
|
job.actions?.postrender?.[0]?.output);
|
|
|
|
if (!outputPath || typeof outputPath !== "string") {
|
|
throw new Error("Nexrender did not return output path");
|
|
}
|
|
|
|
return outputPath;
|
|
}
|
|
|
|
async function mockRender(
|
|
scenes: RenderScene[],
|
|
onProgress: (percent: number, message: string) => Promise<void>
|
|
): Promise<string> {
|
|
const total = scenes.length;
|
|
for (let i = 0; i < total; i += 1) {
|
|
const percent = Math.round(((i + 1) / total) * 90);
|
|
await onProgress(
|
|
percent,
|
|
`Rendering scene ${i + 1} of ${total}…`
|
|
);
|
|
await new Promise((resolve) => setTimeout(resolve, 800));
|
|
}
|
|
await onProgress(95, "Encoding MP4…");
|
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
return "";
|
|
}
|
|
|
|
export async function processRenderJob(jobId: string): Promise<void> {
|
|
const supabase = getSupabase();
|
|
const { data: row, error } = await supabase
|
|
.from("render_jobs")
|
|
.select("*")
|
|
.eq("id", jobId)
|
|
.single();
|
|
|
|
if (error || !row) {
|
|
throw new Error(`Job ${jobId} not found`);
|
|
}
|
|
|
|
const scenes = row.scenes as RenderScene[];
|
|
const settings = row.settings as RenderSettings;
|
|
const totalScenes = scenes.length;
|
|
|
|
const onProgress = async (percent: number, message: string) => {
|
|
await updateJob(jobId, {
|
|
status: "processing",
|
|
progress: percent,
|
|
progress_message: message,
|
|
});
|
|
};
|
|
|
|
await updateJob(jobId, {
|
|
status: "processing",
|
|
progress: 2,
|
|
progress_message: "Preparing render…",
|
|
});
|
|
|
|
const workDir = process.env.NEXRENDER_WORKPATH ?? path.join(process.cwd(), ".nexrender");
|
|
const outputPath = path.join(workDir, "output", `${jobId}.mp4`);
|
|
|
|
try {
|
|
const nexrenderJob = buildNexrenderJob(
|
|
scenes,
|
|
settings,
|
|
jobId,
|
|
outputPath
|
|
);
|
|
|
|
let renderedPath = "";
|
|
|
|
const useMock =
|
|
process.env.RENDER_MOCK === "true" ||
|
|
(!process.env.NEXRENDER_SERVER_URL &&
|
|
!process.env.NEXRENDER_BINARY &&
|
|
!process.env.NEXRENDER_TEMPLATE_SRC);
|
|
|
|
if (useMock) {
|
|
await mockRender(scenes, onProgress);
|
|
await onProgress(96, "Uploading to storage…");
|
|
const placeholder = Buffer.from(
|
|
"Mock render — configure NEXRENDER_BINARY or RENDER_MOCK=false"
|
|
);
|
|
const storagePath = `${jobId}/output.mp4`;
|
|
await supabase.storage.from("renders").upload(storagePath, placeholder, {
|
|
contentType: "text/plain",
|
|
upsert: true,
|
|
});
|
|
const { data: urlData } = supabase.storage
|
|
.from("renders")
|
|
.getPublicUrl(storagePath);
|
|
await updateJob(jobId, {
|
|
status: "completed",
|
|
progress: 100,
|
|
progress_message: "Render complete (mock)",
|
|
output_url: urlData.publicUrl,
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (process.env.NEXRENDER_SERVER_URL) {
|
|
await onProgress(15, "Submitting to nexrender server…");
|
|
const uid = await submitToNexrenderServer(nexrenderJob);
|
|
await onProgress(25, `Nexrender job ${uid} started…`);
|
|
|
|
for (let i = 0; i < totalScenes; i += 1) {
|
|
await onProgress(
|
|
25 + Math.round(((i + 1) / totalScenes) * 60),
|
|
`Rendering scene ${i + 1} of ${totalScenes}…`
|
|
);
|
|
await new Promise((resolve) => setTimeout(resolve, 1200));
|
|
}
|
|
|
|
renderedPath = outputPath;
|
|
} else {
|
|
for (let i = 0; i < totalScenes; i += 1) {
|
|
await onProgress(
|
|
10 + Math.round((i / totalScenes) * 20),
|
|
`Rendering scene ${i + 1} of ${totalScenes}…`
|
|
);
|
|
}
|
|
renderedPath = await renderWithCore(nexrenderJob, onProgress);
|
|
}
|
|
|
|
await onProgress(96, "Uploading to storage…");
|
|
const publicUrl = await uploadToStorage(jobId, renderedPath);
|
|
|
|
await updateJob(jobId, {
|
|
status: "completed",
|
|
progress: 100,
|
|
progress_message: "Render complete",
|
|
output_url: publicUrl,
|
|
});
|
|
} catch (err) {
|
|
const message =
|
|
err instanceof Error ? err.message : "Unknown render error";
|
|
await updateJob(jobId, {
|
|
status: "failed",
|
|
progress: 0,
|
|
progress_message: "Render failed",
|
|
error_message: message,
|
|
});
|
|
throw err;
|
|
}
|
|
}
|