feat: full studio build -- light theme, canvas thumbnails, i18n (fa/en)

This commit is contained in:
Soroush.Asadi
2026-05-24 17:37:21 +03:30
parent d962483359
commit c61f587767
295 changed files with 29797 additions and 265 deletions
+247
View File
@@ -0,0 +1,247 @@
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;
}
}