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
+173
View File
@@ -0,0 +1,173 @@
import type { RenderScene, RenderSettings } from "../src/lib/render-schemas";
import { RESOLUTION_DIMENSIONS } from "../src/lib/render-schemas";
export interface NexrenderAsset {
type: string;
layerName?: string;
composition?: string;
property?: string;
value?: string | number;
src?: string;
width?: number;
height?: number;
time?: number;
[key: string]: unknown;
}
export interface NexrenderJob {
template: {
src: string;
composition: string;
frameStart?: number;
frameEnd?: number;
};
assets: NexrenderAsset[];
actions?: {
postrender?: Array<Record<string, unknown>>;
};
onRenderProgress?: string;
metadata?: Record<string, unknown>;
}
function layerToAsset(
layer: RenderScene["layers"][number],
scene: RenderScene,
sceneIndex: number
): NexrenderAsset | null {
const time = sceneIndex * scene.duration;
switch (layer.type) {
case "text": {
const text =
typeof layer.props.text === "string" ? layer.props.text : "Text";
return {
type: "data",
layerName: `Scene${sceneIndex + 1}_Text`,
composition: scene.name,
property: "Source Text",
value: text,
time,
};
}
case "image": {
const src = typeof layer.props.src === "string" ? layer.props.src : "";
if (!src) return null;
return {
type: "image",
layerName: `Scene${sceneIndex + 1}_Image`,
composition: scene.name,
src,
width: layer.width,
height: layer.height,
time,
};
}
case "video": {
const src = typeof layer.props.src === "string" ? layer.props.src : "";
if (!src) return null;
return {
type: "video",
layerName: `Scene${sceneIndex + 1}_Video`,
composition: scene.name,
src,
time,
};
}
case "shape":
return {
type: "data",
layerName: `Scene${sceneIndex + 1}_Shape`,
composition: scene.name,
property: "Opacity",
value: Math.round(layer.opacity * 100),
time,
};
default:
return null;
}
}
export function buildNexrenderJob(
scenes: RenderScene[],
settings: RenderSettings,
jobId: string,
outputPath: string
): NexrenderJob {
const templateSrc =
process.env.NEXRENDER_TEMPLATE_SRC ??
"file:///templates/creatorstudio-base.aep";
const composition =
process.env.NEXRENDER_COMPOSITION ?? "CreatorStudio_Main";
const { width, height } = RESOLUTION_DIMENSIONS[settings.resolution];
const totalDuration = scenes.reduce((sum, scene) => sum + scene.duration, 0);
const frameEnd = Math.ceil(totalDuration * settings.fps);
const assets: NexrenderAsset[] = [
{
type: "data",
layerName: "Settings",
composition,
property: "Width",
value: width,
},
{
type: "data",
layerName: "Settings",
composition,
property: "Height",
value: height,
},
{
type: "data",
layerName: "Settings",
composition,
property: "Frame Rate",
value: settings.fps,
},
];
scenes.forEach((scene, sceneIndex) => {
assets.push({
type: "data",
layerName: `Scene${sceneIndex + 1}`,
composition,
property: "Duration",
value: scene.duration,
time: sceneIndex * scene.duration,
});
const sortedLayers = [...scene.layers].sort(
(a, b) => a.zIndex - b.zIndex
);
sortedLayers.forEach((layer) => {
const asset = layerToAsset(layer, scene, sceneIndex);
if (asset) assets.push(asset);
});
});
return {
template: {
src: templateSrc,
composition,
frameStart: 0,
frameEnd,
},
assets,
actions: {
postrender: [
{
module: "@nexrender/action-copy",
output: outputPath,
},
],
},
metadata: {
jobId,
resolution: settings.resolution,
fps: settings.fps,
format: settings.format,
},
};
}
+13
View File
@@ -0,0 +1,13 @@
declare module "@nexrender/core" {
export interface NexrenderRenderOptions {
workPath?: string;
binary?: string;
skipCleanup?: boolean;
onProgress?: (job: { metadata?: Record<string, unknown> }, percent: number) => void;
}
export function render(
job: unknown,
options?: NexrenderRenderOptions
): Promise<string | { output?: string }>;
}
+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;
}
}
+71
View File
@@ -0,0 +1,71 @@
/**
* Standalone render worker — run: npm run render-worker
* POST /process { jobId } — requires RENDER_WORKER_SECRET if set
*/
import http from "node:http";
import { processRenderJob } from "./render-job-processor";
const PORT = Number(process.env.RENDER_WORKER_PORT ?? 3355);
const SECRET = process.env.RENDER_WORKER_SECRET;
function isAuthorized(request: http.IncomingMessage): boolean {
if (!SECRET) return true;
const header = request.headers.authorization;
return header === `Bearer ${SECRET}`;
}
function readBody(request: http.IncomingMessage): Promise<string> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
request.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
request.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
request.on("error", reject);
});
}
const server = http.createServer(async (request, response) => {
const url = request.url ?? "/";
if (request.method === "GET" && url === "/health") {
response.writeHead(200, { "Content-Type": "application/json" });
response.end(JSON.stringify({ ok: true }));
return;
}
if (request.method === "POST" && url === "/process") {
if (!isAuthorized(request)) {
response.writeHead(401, { "Content-Type": "application/json" });
response.end(JSON.stringify({ error: "Unauthorized" }));
return;
}
try {
const body = JSON.parse(await readBody(request)) as { jobId?: string };
if (!body.jobId) {
response.writeHead(400, { "Content-Type": "application/json" });
response.end(JSON.stringify({ error: "jobId required" }));
return;
}
response.writeHead(202, { "Content-Type": "application/json" });
response.end(JSON.stringify({ accepted: true, jobId: body.jobId }));
void processRenderJob(body.jobId).catch((err) => {
console.error(`Render job ${body.jobId} failed:`, err);
});
} catch {
response.writeHead(400, { "Content-Type": "application/json" });
response.end(JSON.stringify({ error: "Invalid JSON" }));
}
return;
}
response.writeHead(404, { "Content-Type": "application/json" });
response.end(JSON.stringify({ error: "Not found" }));
});
server.listen(PORT, () => {
console.log(`Render worker listening on http://localhost:${PORT}`);
console.log("Endpoints: GET /health, POST /process");
});