feat: full studio build -- light theme, canvas thumbnails, i18n (fa/en)
This commit is contained in:
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
Vendored
+13
@@ -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 }>;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
Reference in New Issue
Block a user