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 ): Promise { const supabase = getSupabase(); await supabase.from("render_jobs").update(updates).eq("id", jobId); } async function uploadToStorage( jobId: string, filePath: string ): Promise { 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 ): Promise { 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, onProgress: (percent: number, message: string) => Promise ): Promise { 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 }, 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 ): Promise { 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 { 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; } }