Files
Claw3D/server/studio-ai-worker.js
2026-04-22 13:28:22 -05:00

1827 lines
61 KiB
JavaScript

/* eslint-env node */
/* global Buffer, URL, console, fetch, module, process, require, setTimeout */
const fs = require("node:fs");
const http = require("node:http");
const os = require("node:os");
const path = require("node:path");
const { randomUUID } = require("node:crypto");
const THREE = require("three");
const { PNG } = require("pngjs");
const jpeg = require("jpeg-js");
const DEFAULT_PORT = Number.parseInt(process.env.CLAW3D_STUDIO_PROVIDER_PORT || "3333", 10);
const DEFAULT_HOST = process.env.CLAW3D_STUDIO_PROVIDER_HOST || "127.0.0.1";
const TASK_TIMEOUT_MS = 1_200;
const TASK_METADATA_FILENAME = "task.json";
const PREVIEW_ARTIFACT_SCALE = 12;
const respondJson = (res, statusCode, body) => {
res.writeHead(statusCode, {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET,POST,OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
"Cache-Control": "no-store",
"Content-Type": "application/json; charset=utf-8",
});
res.end(JSON.stringify(body));
};
const respondFile = (res, statusCode, filePath, contentType) => {
res.writeHead(statusCode, {
"Access-Control-Allow-Origin": "*",
"Cache-Control": "no-store",
"Content-Type": contentType,
});
fs.createReadStream(filePath).pipe(res);
};
const writePng = (filePath, width, height, fillPixel) =>
new Promise((resolve, reject) => {
const png = new PNG({ width, height });
for (let y = 0; y < height; y += 1) {
for (let x = 0; x < width; x += 1) {
const idx = (width * y + x) << 2;
const [red, green, blue, alpha] = fillPixel(x, y);
png.data[idx] = red;
png.data[idx + 1] = green;
png.data[idx + 2] = blue;
png.data[idx + 3] = alpha;
}
}
png
.pack()
.pipe(fs.createWriteStream(filePath))
.on("finish", resolve)
.on("error", reject);
});
const readRequestBody = (req) =>
new Promise((resolve, reject) => {
const chunks = [];
req.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
req.on("error", reject);
req.on("end", () => resolve(Buffer.concat(chunks)));
});
const ensureDirectory = (dirPath) => {
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true });
}
};
const resolveStateDir = () => {
const override = process.env.OPENCLAW_STATE_DIR?.trim();
if (override) {
return path.resolve(override);
}
return path.join(os.homedir(), ".openclaw");
};
const resolveWorkerDir = () => {
const dir = path.join(resolveStateDir(), "claw3d", "studio-ai-worker");
ensureDirectory(dir);
return dir;
};
const writeTaskMetadata = (taskDir, task) => {
const metadata = {
id: task.id,
adapterId: task.adapterId,
providerTaskId: typeof task.providerTaskId === "string" ? task.providerTaskId : "",
usingTestMode: typeof task.usingTestMode === "boolean" ? task.usingTestMode : null,
status: task.status,
progress: task.progress,
createdAt: task.createdAt,
startedAt: task.startedAt,
finishedAt: task.finishedAt,
modelPath: task.modelPath ? path.basename(task.modelPath) : null,
thumbnailPath: task.thumbnailPath ? path.basename(task.thumbnailPath) : null,
depthPreviewPath: task.depthPreviewPath ? path.basename(task.depthPreviewPath) : null,
normalPreviewPath: task.normalPreviewPath ? path.basename(task.normalPreviewPath) : null,
errorMessage: task.errorMessage,
sourceImagePath: task.sourceImagePath ? path.basename(task.sourceImagePath) : null,
additionalImages: Array.isArray(task.additionalImages)
? task.additionalImages.map((image) => ({
fileName: typeof image.fileName === "string" ? image.fileName : "",
mimeType: typeof image.mimeType === "string" ? image.mimeType : "",
role: typeof image.role === "string" ? image.role : "detail",
}))
: [],
palette: task.palette,
size: task.size,
};
fs.writeFileSync(
path.join(taskDir, TASK_METADATA_FILENAME),
JSON.stringify(metadata, null, 2),
"utf8",
);
};
const loadTaskMetadata = (rootDir, taskId) => {
const taskDir = path.join(rootDir, taskId);
const metadataPath = path.join(taskDir, TASK_METADATA_FILENAME);
if (!fs.existsSync(metadataPath)) return null;
const raw = JSON.parse(fs.readFileSync(metadataPath, "utf8"));
const revivePath = (fileName) => (typeof fileName === "string" && fileName ? path.join(taskDir, fileName) : null);
const task = {
id: typeof raw.id === "string" ? raw.id : taskId,
adapterId:
typeof raw.adapterId === "string" && raw.adapterId
? raw.adapterId
: "heightfield_relief",
providerTaskId:
typeof raw.providerTaskId === "string" ? raw.providerTaskId : "",
usingTestMode: typeof raw.usingTestMode === "boolean" ? raw.usingTestMode : undefined,
status:
raw.status === "PENDING" ||
raw.status === "IN_PROGRESS" ||
raw.status === "SUCCEEDED" ||
raw.status === "FAILED" ||
raw.status === "CANCELED"
? raw.status
: "FAILED",
progress: Number.isFinite(raw.progress) ? raw.progress : 0,
createdAt: Number.isFinite(raw.createdAt) ? raw.createdAt : Date.now(),
startedAt: Number.isFinite(raw.startedAt) ? raw.startedAt : 0,
finishedAt: Number.isFinite(raw.finishedAt) ? raw.finishedAt : 0,
modelPath: revivePath(raw.modelPath),
thumbnailPath: revivePath(raw.thumbnailPath),
depthPreviewPath: revivePath(raw.depthPreviewPath),
normalPreviewPath: revivePath(raw.normalPreviewPath),
errorMessage: typeof raw.errorMessage === "string" ? raw.errorMessage : "",
sourceImagePath: revivePath(raw.sourceImagePath),
additionalImages: Array.isArray(raw.additionalImages) ? raw.additionalImages : [],
palette: Array.isArray(raw.palette) ? raw.palette : [],
size:
raw.size && typeof raw.size === "object"
? {
width: Number.isFinite(raw.size.width) ? raw.size.width : 1024,
height: Number.isFinite(raw.size.height) ? raw.size.height : 1024,
}
: { width: 1024, height: 1024 },
};
if (task.status === "PENDING" || task.status === "IN_PROGRESS") {
task.status = "FAILED";
task.progress = 100;
task.finishedAt = Date.now();
task.errorMessage = "Worker restarted before task completion.";
writeTaskMetadata(taskDir, task);
}
return task;
};
const hex = (value) => value.toString(16).padStart(2, "0");
const rgbToHex = (red, green, blue) => `#${hex(red)}${hex(green)}${hex(blue)}`;
const clamp = (value, min, max) => Math.min(max, Math.max(min, value));
const normalizeAdapterId = (adapterId) => {
if (adapterId === "heightfield-relief" || adapterId === "heightfield_relief") {
return "heightfield_relief";
}
if (adapterId === "portrait-volume" || adapterId === "portrait_volume") {
return "portrait_volume";
}
return "portrait_volume";
};
const normalizeImageRole = (role) => {
if (role === "front" || role === "side" || role === "back" || role === "detail") {
return role;
}
return "detail";
};
const smoothstep = (edge0, edge1, value) => {
const t = clamp((value - edge0) / Math.max(edge1 - edge0, 1e-6), 0, 1);
return t * t * (3 - 2 * t);
};
const parsePngSize = (buffer) => {
if (buffer.length < 24) return { width: 1024, height: 1024 };
const signature = buffer.subarray(0, 8);
const expected = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
if (!signature.equals(expected)) return { width: 1024, height: 1024 };
return {
width: buffer.readUInt32BE(16),
height: buffer.readUInt32BE(20),
};
};
const decodeRasterImage = (buffer, mimeType) => {
const normalized = (mimeType || "").trim().toLowerCase();
if (normalized === "image/png") {
try {
const png = PNG.sync.read(buffer);
return {
width: png.width,
height: png.height,
channels: 4,
data: png.data,
};
} catch {
const { width, height } = parsePngSize(buffer);
return {
width,
height,
channels: 0,
data: null,
};
}
}
if (normalized === "image/jpeg" || normalized === "image/jpg") {
try {
const decoded = jpeg.decode(buffer, { useTArray: true });
return {
width: decoded.width,
height: decoded.height,
channels: 4,
data: decoded.data,
};
} catch {
const { width, height } = parsePngSize(buffer);
return {
width,
height,
channels: 0,
data: null,
};
}
}
const { width, height } = parsePngSize(buffer);
return {
width,
height,
channels: 0,
data: null,
};
};
const sampleIntensityGrid = (params, cells = 20) => {
const { raster, buffer } = params;
const grid = [];
if (raster?.data && raster.width > 0 && raster.height > 0) {
const data = raster.data;
for (let row = 0; row < cells; row += 1) {
const values = [];
const y0 = Math.floor((row / cells) * raster.height);
const y1 = Math.max(y0 + 1, Math.floor(((row + 1) / cells) * raster.height));
for (let col = 0; col < cells; col += 1) {
const x0 = Math.floor((col / cells) * raster.width);
const x1 = Math.max(x0 + 1, Math.floor(((col + 1) / cells) * raster.width));
let total = 0;
let count = 0;
for (let y = y0; y < y1; y += 1) {
for (let x = x0; x < x1; x += 1) {
const index = (y * raster.width + x) * 4;
const red = data[index] ?? 0;
const green = data[index + 1] ?? red;
const blue = data[index + 2] ?? green;
const alpha = data[index + 3] ?? 255;
const luminance = ((red * 0.2126 + green * 0.7152 + blue * 0.0722) / 255) * (alpha / 255);
total += luminance;
count += 1;
}
}
values.push(clamp(Math.round((total / Math.max(count, 1)) * 1000) / 1000, 0, 1));
}
grid.push(values);
}
return grid;
}
const length = Math.max(buffer.length, 3);
for (let row = 0; row < cells; row += 1) {
const values = [];
for (let col = 0; col < cells; col += 1) {
const normalizedIndex = (row * cells + col) / Math.max(cells * cells - 1, 1);
const offset = Math.min(Math.floor(normalizedIndex * (length - 3)), length - 3);
const red = buffer[offset] ?? 0;
const green = buffer[offset + 1] ?? red;
const blue = buffer[offset + 2] ?? green;
const luminance = (red * 0.2126 + green * 0.7152 + blue * 0.0722) / 255;
values.push(clamp(Math.round(luminance * 1000) / 1000, 0, 1));
}
grid.push(values);
}
return grid;
};
const sampleColorGrid = (params, cells = 20) => {
const { raster, buffer } = params;
const grid = [];
if (raster?.data && raster.width > 0 && raster.height > 0) {
const data = raster.data;
for (let row = 0; row < cells; row += 1) {
const values = [];
const y0 = Math.floor((row / cells) * raster.height);
const y1 = Math.max(y0 + 1, Math.floor(((row + 1) / cells) * raster.height));
for (let col = 0; col < cells; col += 1) {
const x0 = Math.floor((col / cells) * raster.width);
const x1 = Math.max(x0 + 1, Math.floor(((col + 1) / cells) * raster.width));
let redTotal = 0;
let greenTotal = 0;
let blueTotal = 0;
let count = 0;
for (let y = y0; y < y1; y += 1) {
for (let x = x0; x < x1; x += 1) {
const index = (y * raster.width + x) * 4;
redTotal += data[index] ?? 0;
greenTotal += data[index + 1] ?? data[index] ?? 0;
blueTotal += data[index + 2] ?? data[index + 1] ?? 0;
count += 1;
}
}
values.push([
Math.round(redTotal / Math.max(count, 1)),
Math.round(greenTotal / Math.max(count, 1)),
Math.round(blueTotal / Math.max(count, 1)),
]);
}
grid.push(values);
}
return grid;
}
const length = Math.max(buffer.length, 3);
for (let row = 0; row < cells; row += 1) {
const values = [];
for (let col = 0; col < cells; col += 1) {
const normalizedIndex = (row * cells + col) / Math.max(cells * cells - 1, 1);
const offset = Math.min(Math.floor(normalizedIndex * (length - 3)), length - 3);
values.push([
buffer[offset] ?? 0,
buffer[offset + 1] ?? buffer[offset] ?? 0,
buffer[offset + 2] ?? buffer[offset + 1] ?? 0,
]);
}
grid.push(values);
}
return grid;
};
const averageColor = (values) => {
if (!Array.isArray(values) || values.length === 0) return [127, 127, 127];
let red = 0;
let green = 0;
let blue = 0;
for (const value of values) {
red += value?.[0] ?? 127;
green += value?.[1] ?? 127;
blue += value?.[2] ?? 127;
}
return [
Math.round(red / values.length),
Math.round(green / values.length),
Math.round(blue / values.length),
];
};
const smoothGrid = (grid, iterations = 1) => {
let current = grid.map((row) => row.slice());
for (let iteration = 0; iteration < iterations; iteration += 1) {
const next = current.map((row) => row.slice());
for (let row = 0; row < current.length; row += 1) {
for (let col = 0; col < current[row].length; col += 1) {
let total = 0;
let count = 0;
for (let y = Math.max(0, row - 1); y <= Math.min(current.length - 1, row + 1); y += 1) {
for (let x = Math.max(0, col - 1); x <= Math.min(current[row].length - 1, col + 1); x += 1) {
total += current[y][x];
count += 1;
}
}
next[row][col] = total / Math.max(count, 1);
}
}
current = next;
}
return current;
};
const buildPortraitMask = (rows, cols) => {
const mask = [];
for (let row = 0; row < rows; row += 1) {
const y = rows <= 1 ? 0 : row / (rows - 1);
const rowValues = [];
for (let col = 0; col < cols; col += 1) {
const x = cols <= 1 ? 0 : col / (cols - 1);
const dx = (x - 0.5) / 0.36;
const dy = (y - 0.44) / 0.54;
const oval = 1 - clamp(dx * dx + dy * dy, 0, 1.8);
const shoulders = 1 - clamp(Math.abs(x - 0.5) / 0.6, 0, 1);
const shoulderWeight = smoothstep(0.58, 0.9, y) * shoulders * 0.55;
const headWeight = smoothstep(0.02, 0.18, y) * (1 - smoothstep(0.68, 0.95, y));
rowValues.push(clamp(oval * headWeight + shoulderWeight, 0, 1));
}
mask.push(rowValues);
}
return mask;
};
const applyPortraitRelief = (intensityGrid) => {
const rows = intensityGrid.length;
const cols = intensityGrid[0]?.length ?? 0;
const smoothed = smoothGrid(intensityGrid, 2);
const portraitMask = buildPortraitMask(rows, cols);
const refined = smoothed.map((row, rowIndex) =>
row.map((value, colIndex) => {
const mask = portraitMask[rowIndex]?.[colIndex] ?? 0;
const centered = value - 0.5;
const sculpted =
0.38 +
mask * 0.72 +
centered * 0.42 +
Math.max(0, mask - 0.45) * 0.25;
return clamp(sculpted, 0, 1);
}),
);
return smoothGrid(refined, 1);
};
const estimateNormalLikeField = (intensityGrid) => {
const rows = intensityGrid.length;
const cols = intensityGrid[0]?.length ?? 0;
return intensityGrid.map((row, rowIndex) =>
row.map((value, colIndex) => {
const left = intensityGrid[rowIndex]?.[Math.max(0, colIndex - 1)] ?? value;
const right = intensityGrid[rowIndex]?.[Math.min(cols - 1, colIndex + 1)] ?? value;
const up = intensityGrid[Math.max(0, rowIndex - 1)]?.[colIndex] ?? value;
const down = intensityGrid[Math.min(rows - 1, rowIndex + 1)]?.[colIndex] ?? value;
const nx = clamp((right - left) * 0.5 + 0.5, 0, 1);
const ny = clamp((down - up) * 0.5 + 0.5, 0, 1);
return { nx, ny, depth: value };
}),
);
};
const writeDepthPreview = async (filePath, intensityGrid) => {
const rows = intensityGrid.length;
const cols = intensityGrid[0]?.length ?? 0;
if (rows === 0 || cols === 0) {
return;
}
const width = cols * PREVIEW_ARTIFACT_SCALE;
const height = rows * PREVIEW_ARTIFACT_SCALE;
await writePng(filePath, width, height, (x, y) => {
const row = Math.min(rows - 1, Math.floor(y / PREVIEW_ARTIFACT_SCALE));
const col = Math.min(cols - 1, Math.floor(x / PREVIEW_ARTIFACT_SCALE));
const value = Math.round(clamp(intensityGrid[row]?.[col] ?? 0.5, 0, 1) * 255);
return [value, value, value, 255];
});
};
const writeNormalPreview = async (filePath, normalGrid) => {
const rows = normalGrid.length;
const cols = normalGrid[0]?.length ?? 0;
if (rows === 0 || cols === 0) {
return;
}
const width = cols * PREVIEW_ARTIFACT_SCALE;
const height = rows * PREVIEW_ARTIFACT_SCALE;
await writePng(filePath, width, height, (x, y) => {
const row = Math.min(rows - 1, Math.floor(y / PREVIEW_ARTIFACT_SCALE));
const col = Math.min(cols - 1, Math.floor(x / PREVIEW_ARTIFACT_SCALE));
const sample = normalGrid[row]?.[col] ?? { nx: 0.5, ny: 0.5, depth: 0.5 };
return [
Math.round(clamp(sample.nx, 0, 1) * 255),
Math.round(clamp(sample.ny, 0, 1) * 255),
Math.round(clamp(sample.depth * 0.65 + 0.35, 0, 1) * 255),
255,
];
});
};
const rotateGrid180 = (grid) =>
grid
.slice()
.reverse()
.map((row) => row.slice().reverse());
const mirrorGridHorizontally = (grid) =>
grid.map((row) => row.slice().reverse());
const generateSyntheticViewSet = (intensityGrid, colorGrid) => {
const front = {
role: "front",
intensityGrid,
colorGrid,
normalGrid: estimateNormalLikeField(intensityGrid),
};
const sideIntensity = smoothGrid(mirrorGridHorizontally(intensityGrid), 1);
const sideColor = mirrorGridHorizontally(colorGrid);
const side = {
role: "side",
intensityGrid: sideIntensity,
colorGrid: sideColor,
normalGrid: estimateNormalLikeField(sideIntensity),
};
const backIntensity = smoothGrid(rotateGrid180(intensityGrid), 1);
const backColor = rotateGrid180(colorGrid);
const back = {
role: "back",
intensityGrid: backIntensity,
colorGrid: backColor,
normalGrid: estimateNormalLikeField(backIntensity),
};
return [front, side, back];
};
const fuseSyntheticViewSet = (views) => {
const fusedIntensity = fuseIntensityViews(views);
const fusedColor = fuseColorViews(views);
const fusedNormal = views[0]?.normalGrid?.map((row, rowIndex) =>
row.map((_, colIndex) => {
let totalX = 0;
let totalY = 0;
let totalDepth = 0;
let weightTotal = 0;
for (const view of views) {
const sample = view?.normalGrid?.[rowIndex]?.[colIndex];
if (!sample) continue;
const weight = roleWeight(view.role);
totalX += sample.nx * weight;
totalY += sample.ny * weight;
totalDepth += sample.depth * weight;
weightTotal += weight;
}
return {
nx: weightTotal > 0 ? totalX / weightTotal : 0.5,
ny: weightTotal > 0 ? totalY / weightTotal : 0.5,
depth: weightTotal > 0 ? totalDepth / weightTotal : 0.5,
};
}),
) ?? [];
return {
intensityGrid: fusedIntensity,
colorGrid: fusedColor,
normalGrid: fusedNormal,
};
};
const samplePalette = (params) => {
const { raster, buffer } = params;
const buckets = new Map();
if (raster?.data && raster.width > 0 && raster.height > 0) {
const data = raster.data;
const step = Math.max(1, Math.floor((raster.width * raster.height) / 1800));
for (let pixel = 0; pixel < raster.width * raster.height; pixel += step) {
const index = pixel * 4;
const red = data[index] ?? 0;
const green = data[index + 1] ?? 0;
const blue = data[index + 2] ?? 0;
const key = `${red >> 4}:${green >> 4}:${blue >> 4}`;
const current = buckets.get(key);
if (current) {
current.count += 1;
current.red += red;
current.green += green;
current.blue += blue;
} else {
buckets.set(key, { count: 1, red, green, blue });
}
}
} else {
const step = Math.max(3, Math.floor(buffer.length / 1800));
for (let index = 0; index + 2 < buffer.length; index += step) {
const red = buffer[index] ?? 0;
const green = buffer[index + 1] ?? 0;
const blue = buffer[index + 2] ?? 0;
const key = `${red >> 4}:${green >> 4}:${blue >> 4}`;
const current = buckets.get(key);
if (current) {
current.count += 1;
current.red += red;
current.green += green;
current.blue += blue;
} else {
buckets.set(key, { count: 1, red, green, blue });
}
}
}
const colors = Array.from(buckets.values())
.sort((left, right) => right.count - left.count)
.slice(0, 4)
.map((bucket) =>
rgbToHex(
Math.round(bucket.red / bucket.count),
Math.round(bucket.green / bucket.count),
Math.round(bucket.blue / bucket.count),
),
);
while (colors.length < 4) {
colors.push(colors[colors.length - 1] || "#8b5cf6");
}
return colors;
};
class NodeFileReader {
constructor() {
this.result = null;
this.onloadend = null;
}
readAsArrayBuffer(blob) {
blob
.arrayBuffer()
.then((buffer) => {
this.result = buffer;
if (typeof this.onloadend === "function") {
this.onloadend();
}
})
.catch((error) => {
throw error;
});
}
}
if (typeof globalThis.FileReader === "undefined") {
globalThis.FileReader = NodeFileReader;
}
const decodeImageUrl = async (imageUrl) => {
const trimmed = typeof imageUrl === "string" ? imageUrl.trim() : "";
if (!trimmed) {
throw new Error("image_url is required.");
}
if (trimmed.startsWith("data:")) {
const idx = trimmed.indexOf(",");
if (idx === -1) throw new Error("Invalid data URI.");
const meta = trimmed.slice(5, idx);
const mimeType = meta.split(";")[0] || "image/png";
return {
buffer: Buffer.from(trimmed.slice(idx + 1), "base64"),
mimeType,
};
}
const response = await fetch(trimmed);
if (!response.ok) {
throw new Error(`Failed to download image_url (${response.status}).`);
}
return {
buffer: Buffer.from(await response.arrayBuffer()),
mimeType: response.headers.get("content-type") || "image/png",
};
};
const createHeightfieldScene = async (params) => {
const { GLTFExporter } = await import("three/examples/jsm/exporters/GLTFExporter.js");
const { colorGrid, intensityGrid, palette, width, height } = params;
const refinedIntensity = applyPortraitRelief(intensityGrid);
const rows = refinedIntensity.length;
const cols = refinedIntensity[0]?.length ?? 0;
const scene = new THREE.Scene();
const panelWidth = clamp((width / Math.max(height, 1)) * 5.8, 2.8, 7.2);
const panelHeight = clamp((height / Math.max(width, 1)) * 7.2, 4.2, 8.4);
const root = new THREE.Group();
root.name = "self_hosted_heightfield_relief";
const frame = new THREE.Mesh(
new THREE.BoxGeometry(panelWidth * 1.08, panelHeight * 1.08, 0.18),
new THREE.MeshStandardMaterial({ color: palette[1] || "#666", roughness: 0.8 }),
);
frame.position.set(0, panelHeight * 0.5, -0.12);
root.add(frame);
const plane = new THREE.PlaneGeometry(panelWidth, panelHeight, cols - 1, rows - 1);
const positions = plane.attributes.position;
const colors = new Float32Array(rows * cols * 3);
for (let row = 0; row < rows; row += 1) {
for (let col = 0; col < cols; col += 1) {
const index = row * cols + col;
const intensity = refinedIntensity[row]?.[col] ?? 0.5;
const xNormalized = cols <= 1 ? 0 : col / (cols - 1);
const yNormalized = rows <= 1 ? 0 : row / (rows - 1);
const edgeFadeX = smoothstep(0, 0.12, xNormalized) * (1 - smoothstep(0.88, 1, xNormalized));
const edgeFadeY = smoothstep(0, 0.08, yNormalized) * (1 - smoothstep(0.92, 1, yNormalized));
const edgeFade = clamp(edgeFadeX * edgeFadeY, 0, 1);
positions.setZ(index, intensity * edgeFade * 1.25);
const rgb = colorGrid[row]?.[col] ?? [127, 127, 127];
colors[index * 3] = (rgb[0] ?? 127) / 255;
colors[index * 3 + 1] = (rgb[1] ?? 127) / 255;
colors[index * 3 + 2] = (rgb[2] ?? 127) / 255;
}
}
plane.setAttribute("color", new THREE.BufferAttribute(colors, 3));
positions.needsUpdate = true;
plane.computeVertexNormals();
const relief = new THREE.Mesh(
plane,
new THREE.MeshStandardMaterial({
vertexColors: true,
side: THREE.DoubleSide,
roughness: 0.76,
metalness: 0.04,
flatShading: false,
}),
);
relief.position.set(0, panelHeight * 0.5, 0.02);
root.add(relief);
const base = new THREE.Mesh(
new THREE.BoxGeometry(panelWidth * 1.14, 0.22, panelHeight * 1.14),
new THREE.MeshStandardMaterial({ color: palette[3] || "#222", roughness: 0.9 }),
);
base.position.set(0, -0.35, 0);
root.add(base);
const accent = new THREE.Mesh(
new THREE.OctahedronGeometry(0.62, 0),
new THREE.MeshStandardMaterial({
color: palette[2] || palette[0] || "#fff",
emissive: palette[2] || "#000",
emissiveIntensity: 0.65,
roughness: 0.16,
metalness: 0.22,
}),
);
accent.position.set(panelWidth * 0.42, panelHeight * 0.82, -0.8);
root.add(accent);
scene.add(root);
const exporter = new GLTFExporter();
const glb = await new Promise((resolve, reject) => {
exporter.parse(
scene,
(result) => {
if (result instanceof ArrayBuffer) {
resolve(Buffer.from(result));
return;
}
reject(new Error("Expected binary GLB output."));
},
(error) => reject(error instanceof Error ? error : new Error(String(error))),
{ binary: true, onlyVisible: true, trs: false },
);
});
return glb;
};
const createHeightfieldAdapter = () => ({
id: "heightfield_relief",
async generate(params) {
const raster = decodeRasterImage(params.buffer, params.mimeType);
const size = {
width: raster.width || parsePngSize(params.buffer).width,
height: raster.height || parsePngSize(params.buffer).height,
};
const sampleParams = { raster, buffer: params.buffer };
const palette = samplePalette(sampleParams);
const intensityGrid = Array.isArray(params.fusedIntensityGrid) && params.fusedIntensityGrid.length > 0
? params.fusedIntensityGrid
: sampleIntensityGrid(sampleParams, 24);
const colorGrid = Array.isArray(params.fusedColorGrid) && params.fusedColorGrid.length > 0
? params.fusedColorGrid
: sampleColorGrid(sampleParams, 24);
const previewDepthGrid = applyPortraitRelief(intensityGrid);
const glb = await createHeightfieldScene({
colorGrid,
intensityGrid,
palette,
width: size.width,
height: size.height,
});
return {
palette,
size,
glb,
depthGrid: previewDepthGrid,
normalGrid: estimateNormalLikeField(previewDepthGrid),
thumbnailSourcePath: params.sourceImagePath,
};
},
});
const createPortraitVolumeScene = async (params) => {
const { GLTFExporter } = await import("three/examples/jsm/exporters/GLTFExporter.js");
const { colorGrid, intensityGrid, palette, width, height } = params;
const refinedIntensity = applyPortraitRelief(intensityGrid);
const rows = refinedIntensity.length;
const cols = refinedIntensity[0]?.length ?? 0;
const scene = new THREE.Scene();
const panelWidth = clamp((width / Math.max(height, 1)) * 6.2, 3.1, 7.8);
const panelHeight = clamp((height / Math.max(width, 1)) * 8.1, 4.8, 9.2);
const portraitMask = buildPortraitMask(rows, cols);
const cellWidth = panelWidth / Math.max(cols, 1);
const cellHeight = panelHeight / Math.max(rows, 1);
const root = new THREE.Group();
root.name = "self_hosted_portrait_volume";
const pedestal = new THREE.Mesh(
new THREE.CylinderGeometry(panelWidth * 0.52, panelWidth * 0.58, 0.42, 24),
new THREE.MeshStandardMaterial({
color: palette[3] || "#171717",
roughness: 0.92,
metalness: 0.02,
}),
);
pedestal.position.set(0, -0.4, 0);
root.add(pedestal);
const backPlate = new THREE.Mesh(
new THREE.BoxGeometry(panelWidth * 1.02, panelHeight * 1.02, 0.14),
new THREE.MeshStandardMaterial({
color: palette[1] || "#4b5563",
roughness: 0.86,
metalness: 0.04,
}),
);
backPlate.position.set(0, panelHeight * 0.48, -0.18);
root.add(backPlate);
for (let row = 0; row < rows; row += 1) {
for (let col = 0; col < cols; col += 1) {
const mask = portraitMask[row]?.[col] ?? 0;
if (mask < 0.06) continue;
const intensity = refinedIntensity[row]?.[col] ?? 0.5;
const rgb = colorGrid[row]?.[col] ?? [127, 127, 127];
const xNormalized = cols <= 1 ? 0.5 : col / (cols - 1);
const yNormalized = rows <= 1 ? 0.5 : row / (rows - 1);
const hairBias = smoothstep(0.04, 0.22, yNormalized) * (1 - smoothstep(0.38, 0.5, yNormalized));
const shoulderBias = smoothstep(0.62, 0.9, yNormalized) * 0.4;
const depth = 0.16 + intensity * 0.9 * mask + hairBias * 0.32 + shoulderBias * 0.22;
const centeredX = -panelWidth / 2 + cellWidth * col + cellWidth / 2;
const centeredY = panelHeight - (panelHeight / rows) * row - cellHeight / 2;
const box = new THREE.Mesh(
new THREE.BoxGeometry(cellWidth * 0.96, cellHeight * 0.96, depth),
new THREE.MeshStandardMaterial({
color: rgbToHex(rgb[0] ?? 127, rgb[1] ?? 127, rgb[2] ?? 127),
roughness: 0.72,
metalness: 0.05,
}),
);
box.position.set(centeredX, centeredY, depth * 0.5 - 0.04);
box.rotation.y = (xNormalized - 0.5) * 0.08;
root.add(box);
}
}
const collar = new THREE.Mesh(
new THREE.TorusGeometry(panelWidth * 0.18, 0.08, 12, 36),
new THREE.MeshStandardMaterial({
color: palette[2] || palette[0] || "#ffffff",
emissive: palette[2] || "#000000",
emissiveIntensity: 0.18,
roughness: 0.18,
metalness: 0.42,
}),
);
collar.position.set(0, panelHeight * 0.18, 0.46);
collar.rotation.x = Math.PI / 2;
root.add(collar);
const halo = new THREE.Mesh(
new THREE.TorusGeometry(panelWidth * 0.42, 0.04, 12, 48),
new THREE.MeshStandardMaterial({
color: palette[2] || palette[0] || "#ffffff",
emissive: palette[2] || "#000000",
emissiveIntensity: 0.35,
roughness: 0.2,
metalness: 0.24,
}),
);
halo.position.set(0, panelHeight * 0.58, -0.42);
halo.rotation.x = Math.PI / 2;
root.add(halo);
scene.add(root);
const exporter = new GLTFExporter();
const glb = await new Promise((resolve, reject) => {
exporter.parse(
scene,
(result) => {
if (result instanceof ArrayBuffer) {
resolve(Buffer.from(result));
return;
}
reject(new Error("Expected binary GLB output."));
},
(error) => reject(error instanceof Error ? error : new Error(String(error))),
{ binary: true, onlyVisible: true, trs: false },
);
});
return glb;
};
const createPortraitVolumeAdapter = () => ({
id: "portrait_volume",
async generate(params) {
const raster = decodeRasterImage(params.buffer, params.mimeType);
const size = {
width: raster.width || parsePngSize(params.buffer).width,
height: raster.height || parsePngSize(params.buffer).height,
};
const sampleParams = { raster, buffer: params.buffer };
const palette = samplePalette(sampleParams);
const baseIntensityGrid = Array.isArray(params.fusedIntensityGrid) && params.fusedIntensityGrid.length > 0
? params.fusedIntensityGrid
: sampleIntensityGrid(sampleParams, 18);
const baseColorGrid = Array.isArray(params.fusedColorGrid) && params.fusedColorGrid.length > 0
? params.fusedColorGrid
: sampleColorGrid(sampleParams, 18);
const syntheticViews = generateSyntheticViewSet(baseIntensityGrid, baseColorGrid);
const fusedViews = fuseSyntheticViewSet(syntheticViews);
const glb = await createPortraitVolumeScene({
colorGrid: fusedViews.colorGrid,
intensityGrid: fusedViews.intensityGrid,
palette,
width: size.width,
height: size.height,
});
return {
palette,
size,
glb,
depthGrid: fusedViews.intensityGrid,
normalGrid: fusedViews.normalGrid,
thumbnailSourcePath: params.sourceImagePath,
};
},
});
const createAdapterRegistry = () => {
const adapters = new Map();
const heightfieldAdapter = createHeightfieldAdapter();
const portraitVolumeAdapter = createPortraitVolumeAdapter();
adapters.set(heightfieldAdapter.id, heightfieldAdapter);
adapters.set(portraitVolumeAdapter.id, portraitVolumeAdapter);
return {
getAdapter(adapterId) {
return adapters.get(normalizeAdapterId(adapterId)) || portraitVolumeAdapter;
},
listAdapters() {
return Array.from(adapters.values()).map((adapter) => ({
id: adapter.id,
label:
adapter.id === "portrait_volume" ? "Portrait volume" : "Heightfield relief",
}));
},
defaultAdapterId: portraitVolumeAdapter.id,
};
};
const mergeRasterViews = (rasters) => {
const available = rasters.filter(
(raster) => raster && raster.data && raster.width > 0 && raster.height > 0,
);
if (available.length === 0) {
return null;
}
const width = Math.max(...available.map((raster) => raster.width));
const height = Math.max(...available.map((raster) => raster.height));
const data = new Uint8Array(width * height * 4);
for (let y = 0; y < height; y += 1) {
for (let x = 0; x < width; x += 1) {
let red = 0;
let green = 0;
let blue = 0;
let alpha = 0;
let count = 0;
for (const raster of available) {
const sx = Math.min(
raster.width - 1,
Math.max(0, Math.round((x / Math.max(width - 1, 1)) * (raster.width - 1))),
);
const sy = Math.min(
raster.height - 1,
Math.max(0, Math.round((y / Math.max(height - 1, 1)) * (raster.height - 1))),
);
const index = (sy * raster.width + sx) * 4;
red += raster.data[index] ?? 0;
green += raster.data[index + 1] ?? red;
blue += raster.data[index + 2] ?? green;
alpha += raster.data[index + 3] ?? 255;
count += 1;
}
const out = (y * width + x) * 4;
data[out] = Math.round(red / Math.max(count, 1));
data[out + 1] = Math.round(green / Math.max(count, 1));
data[out + 2] = Math.round(blue / Math.max(count, 1));
data[out + 3] = Math.round(alpha / Math.max(count, 1));
}
}
return {
width,
height,
channels: 4,
data,
};
};
const roleWeight = (role) => {
if (role === "front") return 1;
if (role === "side") return 0.72;
if (role === "back") return 0.35;
if (role === "detail") return 0.55;
return 0.6;
};
const fuseIntensityViews = (views) => {
if (!Array.isArray(views) || views.length === 0) return [];
const baseGrid = views[0]?.intensityGrid ?? [];
return baseGrid.map((row, rowIndex) =>
row.map((_, colIndex) => {
let total = 0;
let weightTotal = 0;
for (const view of views) {
const value = view?.intensityGrid?.[rowIndex]?.[colIndex];
if (typeof value !== "number") continue;
const weight = roleWeight(view.role);
total += value * weight;
weightTotal += weight;
}
return weightTotal > 0 ? total / weightTotal : 0.5;
}),
);
};
const fuseColorViews = (views) => {
if (!Array.isArray(views) || views.length === 0) return [];
const baseGrid = views[0]?.colorGrid ?? [];
return baseGrid.map((row, rowIndex) =>
row.map((_, colIndex) => {
const palette = [];
for (const view of views) {
const color = view?.colorGrid?.[rowIndex]?.[colIndex];
if (!Array.isArray(color)) continue;
const copies = Math.max(1, Math.round(roleWeight(view.role) * 2));
for (let i = 0; i < copies; i += 1) {
palette.push(color);
}
}
return averageColor(palette);
}),
);
};
const delayMs = (ms) =>
new Promise((resolve) => {
setTimeout(resolve, ms);
});
const parsePositiveInt = (value, fallback) => {
const parsed = Number.parseInt(value || "", 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
};
const normalizeEnvText = (value) => {
const trimmed = (value || "").trim();
if (!trimmed || trimmed === "undefined" || trimmed === "null") {
return "";
}
return trimmed;
};
const resolveWorkerBackendConfig = () => {
const modeRaw = normalizeEnvText(process.env.CLAW3D_STUDIO_WORKER_MODE).toLowerCase();
const upstreamUrl = normalizeEnvText(process.env.CLAW3D_STUDIO_UPSTREAM_PROVIDER_URL);
const mode = modeRaw || (upstreamUrl ? "upstream_openapi" : "local_mock");
return {
mode: mode === "upstream_openapi" ? "upstream_openapi" : "local_mock",
upstreamUrl,
upstreamApiKey: normalizeEnvText(process.env.CLAW3D_STUDIO_UPSTREAM_PROVIDER_API_KEY),
upstreamPollIntervalMs: parsePositiveInt(
process.env.CLAW3D_STUDIO_UPSTREAM_POLL_INTERVAL_MS,
1200,
),
upstreamTimeoutMs: parsePositiveInt(
process.env.CLAW3D_STUDIO_UPSTREAM_TIMEOUT_MS,
45 * 60 * 1000,
),
};
};
const toDataUri = (buffer, mimeType) =>
`data:${mimeType || "image/png"};base64,${Buffer.from(buffer).toString("base64")}`;
const parseProviderTaskStatus = (value) => {
if (
value === "PENDING" ||
value === "IN_PROGRESS" ||
value === "SUCCEEDED" ||
value === "FAILED" ||
value === "CANCELED"
) {
return value;
}
return "FAILED";
};
const resolveProviderAssetUrl = (value, providerBaseUrl) => {
if (typeof value !== "string" || !value.trim()) return "";
return new URL(value.trim(), `${providerBaseUrl}/`).toString();
};
const downloadBinaryBuffer = async (url, apiKey) => {
const response = await fetch(url, {
headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : undefined,
cache: "no-store",
});
if (!response.ok) {
throw new Error(`Failed to download provider artifact ${url} (${response.status}).`);
}
return Buffer.from(await response.arrayBuffer());
};
const createTaskStore = () => {
const tasks = new Map();
const rootDir = resolveWorkerDir();
const adapterRegistry = createAdapterRegistry();
const backendConfig = resolveWorkerBackendConfig();
const getTaskDir = (taskId) => {
const dir = path.join(rootDir, taskId);
ensureDirectory(dir);
return dir;
};
const toTaskObject = (task, responseBaseUrl) => ({
id: task.id,
type: "image-to-3d",
adapter_id: task.adapterId,
provider_task_id: task.providerTaskId || "",
model_urls: task.modelPath
? {
glb: `${responseBaseUrl}/openapi/v1/image-to-3d/${task.id}/output/model.glb`,
}
: {},
thumbnail_url: task.thumbnailPath
&& task.thumbnailPath !== task.sourceImagePath
? `${responseBaseUrl}/openapi/v1/image-to-3d/${task.id}/output/thumbnail.png`
: "",
depth_preview_url: task.depthPreviewPath
? `${responseBaseUrl}/openapi/v1/image-to-3d/${task.id}/output/depth.png`
: "",
normal_preview_url: task.normalPreviewPath
? `${responseBaseUrl}/openapi/v1/image-to-3d/${task.id}/output/normal.png`
: "",
progress: task.progress,
width: task.size?.width ?? null,
height: task.size?.height ?? null,
palette: Array.isArray(task.palette) ? task.palette : [],
created_at: task.createdAt,
started_at: task.startedAt,
finished_at: task.finishedAt,
status: task.status,
texture_urls: [],
task_error: {
message: task.errorMessage || "",
},
using_test_mode:
typeof task.usingTestMode === "boolean"
? task.usingTestMode
: backendConfig.mode === "local_mock",
});
const buildLocalTaskDebugLog = (task) => {
const lines = [
`Worker mode: ${backendConfig.mode}.`,
`Task id: ${task.id}.`,
`Status: ${task.status}.`,
`Progress: ${task.progress}%.`,
];
if (task.providerTaskId) {
lines.push(`Upstream task id: ${task.providerTaskId}.`);
}
if (task.startedAt) {
lines.push(`Started at: ${new Date(task.startedAt).toISOString()}.`);
}
if (task.finishedAt) {
lines.push(`Finished at: ${new Date(task.finishedAt).toISOString()}.`);
}
if (task.errorMessage) {
lines.push(`Error: ${task.errorMessage}.`);
}
return lines.join("\n");
};
const fetchUpstreamTaskDebugLog = async (providerTaskId) => {
if (!backendConfig.upstreamUrl || !providerTaskId) {
return "";
}
const response = await fetch(
`${backendConfig.upstreamUrl}/image-to-3d/${encodeURIComponent(providerTaskId)}/debug-log`,
{
cache: "no-store",
headers: backendConfig.upstreamApiKey
? { Authorization: `Bearer ${backendConfig.upstreamApiKey}` }
: undefined,
},
);
if (response.status === 404) {
return "";
}
const raw = await response.text();
let body = {};
try {
body = JSON.parse(raw);
} catch {
body = {};
}
if (!response.ok || !body || typeof body !== "object") {
throw new Error(
`Upstream provider debug log failed. ${raw.trim() || `${response.status}`}.`,
);
}
return typeof body.log === "string" ? body.log : "";
};
const runLocalTaskGeneration = async (params, sourceImagePath) => {
const adapterId = normalizeAdapterId(params.adapterId || adapterRegistry.defaultAdapterId);
const adapter = adapterRegistry.getAdapter(adapterId);
const baseRaster = decodeRasterImage(params.buffer, params.mimeType || "image/png");
const baseSampleParams = { raster: baseRaster, buffer: params.buffer };
const viewSamples = [
{
role: params.role || "front",
intensityGrid: sampleIntensityGrid(baseSampleParams, 18),
colorGrid: sampleColorGrid(baseSampleParams, 18),
},
...(Array.isArray(params.additionalImages)
? params.additionalImages.map((image) => {
const raster = decodeRasterImage(image.buffer, image.mimeType || "image/png");
const sampleParams = { raster, buffer: image.buffer };
return {
role: image.role || "detail",
intensityGrid: sampleIntensityGrid(sampleParams, 18),
colorGrid: sampleColorGrid(sampleParams, 18),
};
})
: []),
];
const mergedRaster = mergeRasterViews([
baseRaster,
...(Array.isArray(params.additionalImages)
? params.additionalImages.map((image) =>
decodeRasterImage(image.buffer, image.mimeType || "image/png"),
)
: []),
]);
const result = await adapter.generate({
buffer: params.buffer,
raster: mergedRaster,
mimeType: params.mimeType || "image/png",
sourceImagePath,
prompt: params.prompt || "",
mode: params.mode || "image_mesh",
fusedIntensityGrid: fuseIntensityViews(viewSamples),
fusedColorGrid: fuseColorViews(viewSamples),
});
return {
adapterId,
modelBuffer: result.glb,
thumbnailPath: result.thumbnailSourcePath || sourceImagePath,
palette: result.palette || [],
size: result.size || null,
depthGrid: result.depthGrid || null,
normalGrid: result.normalGrid || null,
};
};
const runUpstreamTaskGeneration = async (params, task, sourceImagePath) => {
if (!backendConfig.upstreamUrl) {
throw new Error(
"CLAW3D_STUDIO_UPSTREAM_PROVIDER_URL is required when CLAW3D_STUDIO_WORKER_MODE=upstream_openapi.",
);
}
const payload = {
image_url: toDataUri(params.buffer, params.mimeType || "image/png"),
image_urls: Array.isArray(params.additionalImages)
? params.additionalImages.map((image) => ({
image_url: toDataUri(image.buffer, image.mimeType || "image/png"),
role: image.role || "detail",
}))
: [],
image_role: params.role || "front",
model_type: params.mode === "image_avatar" ? "lowpoly" : "standard",
ai_model: "latest",
should_texture: true,
target_formats: ["glb"],
...(params.prompt ? { texture_prompt: String(params.prompt).trim().slice(0, 600) } : {}),
...(params.adapterId ? { adapter_id: params.adapterId } : {}),
};
const createResponse = await fetch(`${backendConfig.upstreamUrl}/image-to-3d`, {
method: "POST",
headers: {
...(backendConfig.upstreamApiKey
? { Authorization: `Bearer ${backendConfig.upstreamApiKey}` }
: {}),
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
const createRaw = await createResponse.text();
let createBody = {};
try {
createBody = JSON.parse(createRaw);
} catch {
createBody = {};
}
const providerTaskId =
createBody &&
typeof createBody === "object" &&
typeof createBody.result === "string" &&
createBody.result.trim()
? createBody.result.trim()
: "";
if (!createResponse.ok || !providerTaskId) {
throw new Error(
`Upstream provider create task failed. ${createRaw.trim() || `${createResponse.status}`}.`,
);
}
task.providerTaskId = providerTaskId;
writeTaskMetadata(getTaskDir(task.id), task);
let finalTask = null;
const startedAt = Date.now();
while (!finalTask) {
if (Date.now() - startedAt > backendConfig.upstreamTimeoutMs) {
throw new Error("Upstream provider task polling timed out.");
}
const response = await fetch(
`${backendConfig.upstreamUrl}/image-to-3d/${encodeURIComponent(providerTaskId)}`,
{
cache: "no-store",
headers: backendConfig.upstreamApiKey
? { Authorization: `Bearer ${backendConfig.upstreamApiKey}` }
: undefined,
},
);
const raw = await response.text();
let body = {};
try {
body = JSON.parse(raw);
} catch {
body = {};
}
if (!response.ok || !body || typeof body !== "object") {
throw new Error(
`Upstream provider polling failed. ${raw.trim() || `${response.status}`}.`,
);
}
const status = parseProviderTaskStatus(body.status);
const progress =
typeof body.progress === "number" && Number.isFinite(body.progress)
? body.progress
: task.progress;
task.status = status;
task.progress = Math.max(task.progress, progress);
task.adapterId = normalizeAdapterId(body.adapter_id || task.adapterId);
writeTaskMetadata(getTaskDir(task.id), task);
if (status === "SUCCEEDED" || status === "FAILED" || status === "CANCELED") {
finalTask = body;
break;
}
await delayMs(backendConfig.upstreamPollIntervalMs);
}
const terminalStatus = parseProviderTaskStatus(finalTask.status);
if (terminalStatus !== "SUCCEEDED") {
const errorMessage =
finalTask.task_error &&
typeof finalTask.task_error === "object" &&
typeof finalTask.task_error.message === "string"
? finalTask.task_error.message
: "";
throw new Error(
errorMessage || `Upstream provider task ended with status ${terminalStatus}.`,
);
}
const modelUrl = resolveProviderAssetUrl(
finalTask.model_urls && typeof finalTask.model_urls === "object"
? finalTask.model_urls.glb
: "",
backendConfig.upstreamUrl,
);
if (!modelUrl) {
throw new Error("Upstream provider did not return model_urls.glb.");
}
const [modelBuffer, thumbnailBuffer, depthBuffer, normalBuffer] = await Promise.all([
downloadBinaryBuffer(modelUrl, backendConfig.upstreamApiKey),
finalTask.thumbnail_url
? downloadBinaryBuffer(
resolveProviderAssetUrl(finalTask.thumbnail_url, backendConfig.upstreamUrl),
backendConfig.upstreamApiKey,
)
: Promise.resolve(null),
finalTask.depth_preview_url
? downloadBinaryBuffer(
resolveProviderAssetUrl(finalTask.depth_preview_url, backendConfig.upstreamUrl),
backendConfig.upstreamApiKey,
)
: Promise.resolve(null),
finalTask.normal_preview_url
? downloadBinaryBuffer(
resolveProviderAssetUrl(finalTask.normal_preview_url, backendConfig.upstreamUrl),
backendConfig.upstreamApiKey,
)
: Promise.resolve(null),
]);
return {
adapterId: normalizeAdapterId(finalTask.adapter_id || task.adapterId),
modelBuffer,
thumbnailBuffer,
depthBuffer,
normalBuffer,
thumbnailPath: sourceImagePath,
palette: Array.isArray(finalTask.palette)
? finalTask.palette.filter((entry) => typeof entry === "string")
: [],
size: {
width:
typeof finalTask.width === "number" && Number.isFinite(finalTask.width)
? finalTask.width
: task.size.width,
height:
typeof finalTask.height === "number" && Number.isFinite(finalTask.height)
? finalTask.height
: task.size.height,
},
depthGrid: null,
normalGrid: null,
usingTestMode:
typeof finalTask.using_test_mode === "boolean" ? finalTask.using_test_mode : undefined,
};
};
const applyTaskResult = async (task, taskDir, sourceImagePath, result) => {
const modelPath = path.join(taskDir, "model.glb");
fs.writeFileSync(modelPath, result.modelBuffer);
task.modelPath = modelPath;
task.adapterId = normalizeAdapterId(result.adapterId || task.adapterId);
if (typeof result.usingTestMode === "boolean") {
task.usingTestMode = result.usingTestMode;
}
task.thumbnailPath = result.thumbnailPath || sourceImagePath;
task.palette = Array.isArray(result.palette) ? result.palette : [];
if (result.size && typeof result.size === "object") {
task.size = {
width:
typeof result.size.width === "number" && Number.isFinite(result.size.width)
? result.size.width
: task.size.width,
height:
typeof result.size.height === "number" && Number.isFinite(result.size.height)
? result.size.height
: task.size.height,
};
}
if (result.thumbnailBuffer) {
const thumbnailPath = path.join(taskDir, "thumbnail.png");
fs.writeFileSync(thumbnailPath, result.thumbnailBuffer);
task.thumbnailPath = thumbnailPath;
}
if (result.depthBuffer) {
const depthPath = path.join(taskDir, "depth.png");
fs.writeFileSync(depthPath, result.depthBuffer);
task.depthPreviewPath = depthPath;
} else if (Array.isArray(result.depthGrid) && result.depthGrid.length > 0) {
const depthPath = path.join(taskDir, "depth.png");
await writeDepthPreview(depthPath, result.depthGrid);
task.depthPreviewPath = depthPath;
}
if (result.normalBuffer) {
const normalPath = path.join(taskDir, "normal.png");
fs.writeFileSync(normalPath, result.normalBuffer);
task.normalPreviewPath = normalPath;
} else if (Array.isArray(result.normalGrid) && result.normalGrid.length > 0) {
const normalPath = path.join(taskDir, "normal.png");
await writeNormalPreview(normalPath, result.normalGrid);
task.normalPreviewPath = normalPath;
}
};
const executeTask = async (task, taskDir, sourceImagePath, params) => {
task.status = "IN_PROGRESS";
task.progress = 18;
task.startedAt = Date.now();
writeTaskMetadata(taskDir, task);
try {
const result =
backendConfig.mode === "upstream_openapi"
? await runUpstreamTaskGeneration(params, task, sourceImagePath)
: await runLocalTaskGeneration(params, sourceImagePath);
await applyTaskResult(task, taskDir, sourceImagePath, result);
task.progress = 100;
task.status = "SUCCEEDED";
task.finishedAt = Date.now();
writeTaskMetadata(taskDir, task);
} catch (error) {
task.status = "FAILED";
task.progress = 100;
task.finishedAt = Date.now();
task.errorMessage = error instanceof Error ? error.message : String(error);
writeTaskMetadata(taskDir, task);
}
};
const createTask = async (params, responseBaseUrl) => {
const taskId = randomUUID();
const taskDir = getTaskDir(taskId);
const sourceImagePath = path.join(taskDir, "source.png");
fs.writeFileSync(sourceImagePath, params.buffer);
const adapterId = normalizeAdapterId(
params.adapterId ||
(backendConfig.mode === "upstream_openapi"
? "portrait_volume"
: adapterRegistry.defaultAdapterId),
);
const task = {
id: taskId,
adapterId,
usingTestMode: backendConfig.mode === "local_mock",
status: "PENDING",
progress: 0,
createdAt: Date.now(),
startedAt: 0,
finishedAt: 0,
modelPath: null,
providerTaskId: "",
thumbnailPath: sourceImagePath,
depthPreviewPath: null,
normalPreviewPath: null,
errorMessage: "",
sourceImagePath,
additionalImages: Array.isArray(params.additionalImages) ? params.additionalImages : [],
palette: [],
size: { width: 1024, height: 1024 },
};
tasks.set(taskId, task);
writeTaskMetadata(taskDir, task);
const taskDelayMs = backendConfig.mode === "upstream_openapi" ? 0 : TASK_TIMEOUT_MS;
setTimeout(() => {
void executeTask(task, taskDir, sourceImagePath, params);
}, taskDelayMs);
return { result: taskId, task: toTaskObject(task, responseBaseUrl) };
};
return {
listAdapters() {
if (backendConfig.mode === "upstream_openapi") {
return [
{ id: "portrait_volume", label: "Portrait volume" },
{ id: "heightfield_relief", label: "Heightfield relief" },
];
}
return adapterRegistry.listAdapters();
},
initialize() {
for (const entry of fs.readdirSync(rootDir, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const task = loadTaskMetadata(rootDir, entry.name);
if (!task) continue;
tasks.set(task.id, task);
}
},
createTask,
getTask(taskId, responseBaseUrl) {
const task = tasks.get(taskId);
if (!task) return null;
return toTaskObject(task, responseBaseUrl);
},
getTaskFile(taskId, kind) {
const task = tasks.get(taskId);
if (!task) return null;
if (kind === "thumbnail") return task.thumbnailPath;
if (kind === "model") return task.modelPath;
if (kind === "depth") return task.depthPreviewPath;
if (kind === "normal") return task.normalPreviewPath;
return null;
},
async getTaskDebugLog(taskId) {
const task = tasks.get(taskId);
if (!task) return null;
if (backendConfig.mode !== "upstream_openapi") {
return buildLocalTaskDebugLog(task);
}
const upstreamLog = await fetchUpstreamTaskDebugLog(task.providerTaskId);
return upstreamLog || buildLocalTaskDebugLog(task);
},
};
};
const createStudioAiWorkerServer = (params = {}) => {
const host = params.host || DEFAULT_HOST;
const port = Number.isFinite(params.port) ? params.port : DEFAULT_PORT;
const publicBaseUrl = normalizeEnvText(
params.publicBaseUrl || process.env.CLAW3D_STUDIO_PROVIDER_PUBLIC_URL || "",
).replace(/\/+$/, "");
const taskStore = createTaskStore();
taskStore.initialize();
const server = http.createServer(async (req, res) => {
if (!req.url) {
respondJson(res, 404, { error: "Not found." });
return;
}
if (req.method === "OPTIONS") {
res.writeHead(204, {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET,POST,OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
});
res.end();
return;
}
const url = new URL(req.url, `http://${host}:${port}`);
const pathname = url.pathname;
const responseBaseUrl = publicBaseUrl || `http://${host}:${port}`;
try {
if (req.method === "GET" && pathname === "/health") {
respondJson(res, 200, {
ok: true,
service: "studio-ai-worker",
public_base_url: responseBaseUrl,
});
return;
}
if (req.method === "GET" && pathname === "/openapi/v1/image-to-3d/adapters") {
respondJson(res, 200, {
adapters: taskStore.listAdapters(),
});
return;
}
if (req.method === "POST" && pathname === "/openapi/v1/image-to-3d") {
const rawBody = await readRequestBody(req);
const body = JSON.parse(rawBody.toString("utf8"));
const { buffer, mimeType } = await decodeImageUrl(body.image_url);
const additionalImages = [];
if (Array.isArray(body.image_urls)) {
for (const imageEntry of body.image_urls) {
if (typeof imageEntry === "string") {
if (!imageEntry.trim()) continue;
const decoded = await decodeImageUrl(imageEntry);
additionalImages.push({
...decoded,
role: "detail",
});
continue;
}
if (!imageEntry || typeof imageEntry !== "object") continue;
const imageUrl =
typeof imageEntry.image_url === "string" ? imageEntry.image_url.trim() : "";
if (!imageUrl) continue;
const decoded = await decodeImageUrl(imageUrl);
additionalImages.push({
...decoded,
role: normalizeImageRole(imageEntry.role),
});
}
}
const created = await taskStore.createTask(
{
buffer,
additionalImages,
adapterId:
typeof body.adapter_id === "string" && body.adapter_id.trim()
? body.adapter_id.trim()
: undefined,
prompt:
typeof body.texture_prompt === "string"
? body.texture_prompt
: "",
mode:
body.model_type === "lowpoly" ? "image_avatar" : "image_mesh",
mimeType,
role: normalizeImageRole(body.image_role || "front"),
},
responseBaseUrl,
);
respondJson(res, 200, { result: created.result });
return;
}
const taskMatch = pathname.match(/^\/openapi\/v1\/image-to-3d\/([^/]+)$/);
if (req.method === "GET" && taskMatch) {
const task = taskStore.getTask(taskMatch[1], responseBaseUrl);
if (!task) {
respondJson(res, 404, { error: "Task not found." });
return;
}
respondJson(res, 200, task);
return;
}
const debugLogMatch = pathname.match(/^\/openapi\/v1\/image-to-3d\/([^/]+)\/debug-log$/);
if (req.method === "GET" && debugLogMatch) {
const log = await taskStore.getTaskDebugLog(debugLogMatch[1]);
if (log === null) {
respondJson(res, 404, { error: "Task not found." });
return;
}
respondJson(res, 200, { log });
return;
}
const modelMatch = pathname.match(/^\/openapi\/v1\/image-to-3d\/([^/]+)\/output\/model\.glb$/);
if (req.method === "GET" && modelMatch) {
const filePath = taskStore.getTaskFile(modelMatch[1], "model");
if (!filePath || !fs.existsSync(filePath)) {
respondJson(res, 404, { error: "Model not ready." });
return;
}
respondFile(res, 200, filePath, "model/gltf-binary");
return;
}
const thumbnailMatch = pathname.match(/^\/openapi\/v1\/image-to-3d\/([^/]+)\/output\/thumbnail\.png$/);
if (req.method === "GET" && thumbnailMatch) {
const filePath = taskStore.getTaskFile(thumbnailMatch[1], "thumbnail");
if (!filePath || !fs.existsSync(filePath)) {
respondJson(res, 404, { error: "Thumbnail not ready." });
return;
}
respondFile(res, 200, filePath, "image/png");
return;
}
const depthMatch = pathname.match(/^\/openapi\/v1\/image-to-3d\/([^/]+)\/output\/depth\.png$/);
if (req.method === "GET" && depthMatch) {
const filePath = taskStore.getTaskFile(depthMatch[1], "depth");
if (!filePath || !fs.existsSync(filePath)) {
respondJson(res, 404, { error: "Depth preview not ready." });
return;
}
respondFile(res, 200, filePath, "image/png");
return;
}
const normalMatch = pathname.match(/^\/openapi\/v1\/image-to-3d\/([^/]+)\/output\/normal\.png$/);
if (req.method === "GET" && normalMatch) {
const filePath = taskStore.getTaskFile(normalMatch[1], "normal");
if (!filePath || !fs.existsSync(filePath)) {
respondJson(res, 404, { error: "Normal preview not ready." });
return;
}
respondFile(res, 200, filePath, "image/png");
return;
}
respondJson(res, 404, { error: "Not found." });
} catch (error) {
respondJson(res, 500, {
error: error instanceof Error ? error.message : "Worker failure.",
});
}
});
return {
server,
start() {
return new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(port, host, () => {
server.off("error", reject);
resolve();
});
});
},
close() {
return new Promise((resolve) => {
server.close(() => resolve());
});
},
host,
port,
};
};
async function main() {
const worker = createStudioAiWorkerServer();
await worker.start();
console.info(`[studio-ai-worker] Listening on http://${worker.host}:${worker.port}`);
}
if (require.main === module) {
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
}
module.exports = {
createStudioAiWorkerServer,
};