This commit is contained in:
iamlukethedev
2026-04-22 13:28:22 -05:00
parent 96b5cc9f6a
commit 0072bbbbc6
32 changed files with 3040 additions and 224 deletions
Executable
+149
View File
@@ -0,0 +1,149 @@
#!/usr/bin/env bash
set -e +o pipefail
# Set up paths first
bin_name="codacy-cli-v2"
# Determine OS-specific paths
os_name=$(uname)
arch=$(uname -m)
case "$arch" in
"x86_64")
arch="amd64"
;;
"x86")
arch="386"
;;
"aarch64"|"arm64")
arch="arm64"
;;
esac
if [ -z "$CODACY_CLI_V2_TMP_FOLDER" ]; then
if [ "$(uname)" = "Linux" ]; then
CODACY_CLI_V2_TMP_FOLDER="$HOME/.cache/codacy/codacy-cli-v2"
elif [ "$(uname)" = "Darwin" ]; then
CODACY_CLI_V2_TMP_FOLDER="$HOME/Library/Caches/Codacy/codacy-cli-v2"
else
CODACY_CLI_V2_TMP_FOLDER=".codacy-cli-v2"
fi
fi
version_file="$CODACY_CLI_V2_TMP_FOLDER/version.yaml"
get_version_from_yaml() {
if [ -f "$version_file" ]; then
local version=$(grep -o 'version: *"[^"]*"' "$version_file" | cut -d'"' -f2)
if [ -n "$version" ]; then
echo "$version"
return 0
fi
fi
return 1
}
get_latest_version() {
local response
if [ -n "$GH_TOKEN" ]; then
response=$(curl -Lq --header "Authorization: Bearer $GH_TOKEN" "https://api.github.com/repos/codacy/codacy-cli-v2/releases/latest" 2>/dev/null)
else
response=$(curl -Lq "https://api.github.com/repos/codacy/codacy-cli-v2/releases/latest" 2>/dev/null)
fi
handle_rate_limit "$response"
local version=$(echo "$response" | grep -m 1 tag_name | cut -d'"' -f4)
echo "$version"
}
handle_rate_limit() {
local response="$1"
if echo "$response" | grep -q "API rate limit exceeded"; then
fatal "Error: GitHub API rate limit exceeded. Please try again later"
fi
}
download_file() {
local url="$1"
echo "Downloading from URL: ${url}"
if command -v curl > /dev/null 2>&1; then
curl -# -LS "$url" -O
elif command -v wget > /dev/null 2>&1; then
wget "$url"
else
fatal "Error: Could not find curl or wget, please install one."
fi
}
download() {
local url="$1"
local output_folder="$2"
( cd "$output_folder" && download_file "$url" )
}
download_cli() {
# OS name lower case
suffix=$(echo "$os_name" | tr '[:upper:]' '[:lower:]')
local bin_folder="$1"
local bin_path="$2"
local version="$3"
if [ ! -f "$bin_path" ]; then
echo "📥 Downloading CLI version $version..."
remote_file="codacy-cli-v2_${version}_${suffix}_${arch}.tar.gz"
url="https://github.com/codacy/codacy-cli-v2/releases/download/${version}/${remote_file}"
download "$url" "$bin_folder"
tar xzfv "${bin_folder}/${remote_file}" -C "${bin_folder}"
fi
}
# Warn if CODACY_CLI_V2_VERSION is set and update is requested
if [ -n "$CODACY_CLI_V2_VERSION" ] && [ "$1" = "update" ]; then
echo "⚠️ Warning: Performing update with forced version $CODACY_CLI_V2_VERSION"
echo " Unset CODACY_CLI_V2_VERSION to use the latest version"
fi
# Ensure version.yaml exists and is up to date
if [ ! -f "$version_file" ] || [ "$1" = "update" ]; then
echo "️ Fetching latest version..."
version=$(get_latest_version)
mkdir -p "$CODACY_CLI_V2_TMP_FOLDER"
echo "version: \"$version\"" > "$version_file"
fi
# Set the version to use
if [ -n "$CODACY_CLI_V2_VERSION" ]; then
version="$CODACY_CLI_V2_VERSION"
else
version=$(get_version_from_yaml)
fi
# Set up version-specific paths
bin_folder="${CODACY_CLI_V2_TMP_FOLDER}/${version}"
mkdir -p "$bin_folder"
bin_path="$bin_folder"/"$bin_name"
# Download the tool if not already installed
download_cli "$bin_folder" "$bin_path" "$version"
chmod +x "$bin_path"
run_command="$bin_path"
if [ -z "$run_command" ]; then
fatal "Codacy cli v2 binary could not be found."
fi
if [ "$#" -eq 1 ] && [ "$1" = "download" ]; then
echo "Codacy cli v2 download succeeded"
else
eval "$run_command $*"
fi
+15
View File
@@ -0,0 +1,15 @@
runtimes:
- dart@3.7.2
- go@1.24.13
- java@17.0.10
- node@22.2.0
- python@3.11.11
tools:
- dartanalyzer@3.7.2
- eslint@8.57.0
- lizard@1.17.31
- opengrep@1.16.4
- pmd@7.11.0
- pylint@3.3.6
- revive@1.7.0
- trivy@0.69.3
+65
View File
@@ -48,3 +48,68 @@ DEBUG=true
# ELEVENLABS_API_KEY=
# ELEVENLABS_VOICE_ID=21m00Tcm4TlvDq8ikWAM
# ELEVENLABS_MODEL_ID=eleven_flash_v2_5
# Studio image-to-3D provider configuration
# Enable the self-hosted provider flow in /studio.
# CLAW3D_STUDIO_ENABLE_REAL_AI=true
# Worker/provider base URL consumed by the app route.
# CLAW3D_STUDIO_PROVIDER_URL=http://127.0.0.1:3333/openapi/v1
# Optional bearer token used by the app route.
# CLAW3D_STUDIO_PROVIDER_API_KEY=
# Studio AI worker runtime configuration
# Default mode is local_mock. Set upstream_openapi to delegate to an external backend.
# CLAW3D_STUDIO_WORKER_MODE=local_mock
# Real local upstream helper:
# 1. Run `npm run studio-ai-upstream-setup` once to create the Python environment.
# 2. Run `npm run studio-ai-upstream-local` to start the Hunyuan-based backend on 8080.
# Optional local upstream helper bind overrides.
# CLAW3D_STUDIO_LOCAL_UPSTREAM_HOST=127.0.0.1
# CLAW3D_STUDIO_LOCAL_UPSTREAM_PORT=8080
# Optional public base URL used in the local upstream task responses.
# CLAW3D_STUDIO_LOCAL_UPSTREAM_PUBLIC_URL=
# Real backend runtime knobs.
# CLAW3D_STUDIO_REAL_BACKEND_DEVICE=auto
# Quality-oriented defaults use the non-turbo Hunyuan models plus stronger conditioning.
# Lower these values only if the backend becomes too slow on your machine.
# CLAW3D_STUDIO_REAL_BACKEND_MODEL_ID_SINGLE=tencent/Hunyuan3D-2.1
# CLAW3D_STUDIO_REAL_BACKEND_SUBFOLDER_SINGLE=hunyuan3d-dit-v2-1
# CLAW3D_STUDIO_REAL_BACKEND_HUNYUAN21_SOURCE_ROOT=~/.cache/claw3d/Hunyuan3D-2.1
# CLAW3D_STUDIO_REAL_BACKEND_MODEL_ID_MULTI=tencent/Hunyuan3D-2mv
# CLAW3D_STUDIO_REAL_BACKEND_SUBFOLDER_MULTI=hunyuan3d-dit-v2-mv
# CLAW3D_STUDIO_REAL_BACKEND_NUM_INFERENCE_STEPS=30
# CLAW3D_STUDIO_REAL_BACKEND_GUIDANCE_SCALE=5.0
# CLAW3D_STUDIO_REAL_BACKEND_OCTREE_RESOLUTION=384
# CLAW3D_STUDIO_REAL_BACKEND_NUM_CHUNKS=20000
# CLAW3D_STUDIO_REAL_BACKEND_TARGET_IMAGE_SIZE=1024
# CLAW3D_STUDIO_REAL_BACKEND_CONDITION_PADDING_RATIO=0.12
# CLAW3D_STUDIO_REAL_BACKEND_REMOVE_BACKGROUND=true
# CLAW3D_STUDIO_REAL_BACKEND_ENABLE_FLASHVDM=true
# Enable the official CUDA-only Hunyuan paint/material pass when available.
# CLAW3D_STUDIO_REAL_BACKEND_ENABLE_TEXTURE_PIPELINE=true
# CLAW3D_STUDIO_REAL_BACKEND_TEXTURE_MAX_VIEWS=6
# CLAW3D_STUDIO_REAL_BACKEND_TEXTURE_RESOLUTION=512
# Upstream provider URL used when CLAW3D_STUDIO_WORKER_MODE=upstream_openapi.
# CLAW3D_STUDIO_UPSTREAM_PROVIDER_URL=http://127.0.0.1:8080/openapi/v1
# Optional worker timeout for long-running upstream jobs. Default is 45 minutes.
# CLAW3D_STUDIO_UPSTREAM_TIMEOUT_MS=2700000
# Optional bearer token sent from worker to upstream provider.
# CLAW3D_STUDIO_UPSTREAM_PROVIDER_API_KEY=
# Remote CUDA upstream example for Vast.ai.
# CLAW3D_STUDIO_WORKER_MODE=upstream_openapi
# CLAW3D_STUDIO_UPSTREAM_PROVIDER_URL=https://<vast-host>:<public-port>/openapi/v1
# CLAW3D_STUDIO_UPSTREAM_PROVIDER_API_KEY=<shared-token>
# Optional polling cadence and timeout for upstream task status.
# CLAW3D_STUDIO_UPSTREAM_POLL_INTERVAL_MS=1200
# CLAW3D_STUDIO_UPSTREAM_TIMEOUT_MS=480000
# Optional externally reachable base URL used in returned artifact URLs.
# CLAW3D_STUDIO_PROVIDER_PUBLIC_URL=
# Optional worker bind settings.
# CLAW3D_STUDIO_PROVIDER_HOST=127.0.0.1
# CLAW3D_STUDIO_PROVIDER_PORT=3333
# Remote real backend security/runtime options.
# Require a bearer token on the Python backend when exposed publicly.
# CLAW3D_STUDIO_REAL_BACKEND_API_KEY=
# Force NVIDIA execution on remote hosts when auto-detection is not enough.
# CLAW3D_STUDIO_REAL_BACKEND_DEVICE=cuda
+5
View File
@@ -86,3 +86,8 @@ test-results
# Local HTTPS development certificates (generated by dev:https).
.certs/
.understand-anything/
.venv-studio-ai-backend/
#Ignore cursor AI rules
.cursor/rules/codacy.mdc
+97
View File
@@ -0,0 +1,97 @@
# Studio AI on Vast.ai CUDA
This guide runs the CUDA texture backend on a rented Vast.ai GPU while keeping Claw3D app and worker on localhost.
## 1) Build and publish the CUDA backend image
Build from the repository root:
```bash
docker build -f server/studio-ai-real-backend.cuda.Dockerfile -t <registry>/<image>:<tag> .
```
Push the image to a registry your Vast.ai instance can pull.
## 2) Launch on Vast.ai
Recommended instance settings:
- Persistent instance (not serverless).
- One NVIDIA GPU with enough VRAM for Hunyuan3D shape + paint stages.
- Docker image set to `<registry>/<image>:<tag>`.
- Expose container port `8000` with Docker options `-p 8000:8000`.
- Set `OPEN_BUTTON_PORT=8000` in Vast if you want the UI open shortcut to target the API port.
- Mount persistent storage so model caches survive restarts.
Recommended container env vars:
- `CLAW3D_STUDIO_LOCAL_UPSTREAM_HOST=0.0.0.0`.
- `CLAW3D_STUDIO_LOCAL_UPSTREAM_PORT=8000`.
- `CLAW3D_STUDIO_REAL_BACKEND_DEVICE=cuda`.
- `CLAW3D_STUDIO_REAL_BACKEND_API_KEY=<shared-token>`.
- `CLAW3D_STUDIO_REAL_BACKEND_HUNYUAN21_SOURCE_ROOT=/opt/hunyuan/Hunyuan3D-2.1`.
After startup, obtain the public `IP:PORT` mapping from the Vast instance panel.
## 3) Point the local worker to the remote backend
Keep Studio using the local worker URL:
- `CLAW3D_STUDIO_PROVIDER_URL=http://127.0.0.1:3333/openapi/v1`.
Run the local worker in upstream mode:
```bash
CLAW3D_STUDIO_WORKER_MODE=upstream_openapi \
CLAW3D_STUDIO_UPSTREAM_PROVIDER_URL=http://<vast-public-ip>:<vast-public-port>/openapi/v1 \
CLAW3D_STUDIO_UPSTREAM_PROVIDER_API_KEY=<shared-token> \
npm run studio-ai-worker
```
Then run the app:
```bash
CLAW3D_STUDIO_ENABLE_REAL_AI=true \
CLAW3D_STUDIO_PROVIDER_URL=http://127.0.0.1:3333/openapi/v1 \
npm run dev
```
## 4) HTTPS and token guidance
- If the endpoint is plain HTTP on a public IP, traffic is not encrypted.
- Prefer TLS termination in front of Vast, then use `https://.../openapi/v1`.
- The backend accepts `Authorization: Bearer <token>` when `CLAW3D_STUDIO_REAL_BACKEND_API_KEY` is set.
- Reuse that same token in `CLAW3D_STUDIO_UPSTREAM_PROVIDER_API_KEY` on the local worker.
## 5) Health check
With token auth enabled:
```bash
curl -H "Authorization: Bearer <shared-token>" "http://<vast-public-ip>:<vast-public-port>/health"
```
Without token auth:
```bash
curl "http://<vast-public-ip>:<vast-public-port>/health"
```
## 6) End-to-end smoke test
Run the included smoke script from the repo root to validate create, poll, and model download:
```bash
npm run smoke:remote-upstream -- \
--base-url "http://<vast-public-ip>:<vast-public-port>/openapi/v1" \
--api-key "<shared-token>" \
--image "<absolute-path-to-input-image>" \
--output "tmp/vast-smoke.glb"
```
The script performs:
- `GET /health`.
- `POST /openapi/v1/image-to-3d`.
- `GET /openapi/v1/image-to-3d/{id}` polling.
- `GET .../output/model.glb` download.
+46 -8
View File
@@ -14,7 +14,9 @@ The goal is to keep Studio unchanged while the worker internals improve from moc
Current entrypoint:
- `npm run studio:ai-worker`
- `npm run studio-ai-worker`
- `npm run studio-ai-upstream-local`
- `npm run studio-ai-upstream-setup`
Current implementation:
@@ -29,6 +31,24 @@ Environment overrides:
- `CLAW3D_STUDIO_PROVIDER_HOST`
- `CLAW3D_STUDIO_PROVIDER_PORT`
- `CLAW3D_STUDIO_WORKER_MODE` (`local_mock` or `upstream_openapi`)
- `CLAW3D_STUDIO_UPSTREAM_PROVIDER_URL` (required when using `upstream_openapi`)
- `CLAW3D_STUDIO_UPSTREAM_PROVIDER_API_KEY` (optional bearer token for upstream requests)
- `CLAW3D_STUDIO_UPSTREAM_POLL_INTERVAL_MS` (optional poll cadence in milliseconds)
- `CLAW3D_STUDIO_UPSTREAM_TIMEOUT_MS` (optional timeout in milliseconds)
- `CLAW3D_STUDIO_LOCAL_UPSTREAM_HOST` (optional bind override for `npm run studio-ai-upstream-local`)
- `CLAW3D_STUDIO_LOCAL_UPSTREAM_PORT` (optional bind override for `npm run studio-ai-upstream-local`)
- `CLAW3D_STUDIO_LOCAL_UPSTREAM_PUBLIC_URL` (optional public base URL returned by the Python backend)
- `CLAW3D_STUDIO_REAL_BACKEND_DEVICE` (`auto`, `mps`, or `cpu`)
- `CLAW3D_STUDIO_REAL_BACKEND_NUM_INFERENCE_STEPS`
- `CLAW3D_STUDIO_REAL_BACKEND_GUIDANCE_SCALE`
- `CLAW3D_STUDIO_REAL_BACKEND_OCTREE_RESOLUTION`
- `CLAW3D_STUDIO_REAL_BACKEND_NUM_CHUNKS`
- `CLAW3D_STUDIO_REAL_BACKEND_TARGET_IMAGE_SIZE`
- `CLAW3D_STUDIO_REAL_BACKEND_CONDITION_PADDING_RATIO`
- `CLAW3D_STUDIO_REAL_BACKEND_REMOVE_BACKGROUND`
- `CLAW3D_STUDIO_REAL_BACKEND_ENABLE_FLASHVDM`
- `CLAW3D_STUDIO_PROVIDER_PUBLIC_URL` (optional externally reachable base URL in returned task artifact links)
## Studio configuration
@@ -132,20 +152,28 @@ Returns:
## Current adapter architecture
The worker now supports adapter-based generation.
The worker supports adapter-based generation and backend mode switching.
Current adapters:
- `portrait-volume` — default adapter
- `heightfield-relief` — simpler fallback adapter
Current behavior:
Current behavior in `local_mock` mode:
- decodes uploaded image pixels locally
- samples intensities and colors from the decoded raster
- produces GLB artifacts through internal geometry adapters
- exposes a task lifecycle compatible with Studio
Current behavior in `upstream_openapi` mode:
- accepts the same worker request payload from Studio
- forwards generation requests to an upstream provider at `CLAW3D_STUDIO_UPSTREAM_PROVIDER_URL`
- polls upstream task status
- downloads upstream GLB/preview artifacts
- serves downloaded artifacts back through the same worker contract
### Adapter notes
#### `portrait-volume`
@@ -166,16 +194,24 @@ Next adapters can be added behind the same contract without changing Studio.
### Terminal 1
- `npm run studio:ai-worker`
- `npm run studio-ai-upstream-setup`
### Terminal 2
- `CLAW3D_STUDIO_ENABLE_REAL_AI=true npm run dev`
- `npm run studio-ai-upstream-local`
Optional explicit provider URL:
### Terminal 3
- `CLAW3D_STUDIO_WORKER_MODE=upstream_openapi CLAW3D_STUDIO_UPSTREAM_PROVIDER_URL=http://127.0.0.1:8080/openapi/v1 npm run studio-ai-worker`
### Terminal 4
- `CLAW3D_STUDIO_ENABLE_REAL_AI=true CLAW3D_STUDIO_PROVIDER_URL=http://127.0.0.1:3333/openapi/v1 npm run dev`
The local upstream helper now starts a Python Hunyuan-based image-to-3D backend on `127.0.0.1:8080`.
The first launch can take several minutes because the model weights may need to download and initialize.
The default quality profile now uses Hunyuan3D 2.1 for single-view generation, the non-turbo Hunyuan multi-view model for multiple images, stronger source-image normalization, and higher inference/detail settings than the initial speed-first setup.
Then open:
- `/studio`
@@ -191,6 +227,8 @@ Recommended manual checks:
## Current limitations
- The worker still uses a mock internal adapter, not a learned 3D reconstruction model.
- `local_mock` mode still uses internal heuristic adapters, not a learned 3D reconstruction model.
- The output is relief-style and better than the old primitive placeholders, but still not production-quality reconstruction.
- The contract is intentionally stable so a real model adapter can replace the mock without breaking Studio.
- `upstream_openapi` mode quality depends entirely on the connected upstream model service.
- The built-in local upstream backend uses Hunyuan3D community weights, which have their own license terms.
- The contract is intentionally stable so backend internals can change without breaking Studio.
-11
View File
@@ -1957,17 +1957,6 @@
"node": ">=12.4.0"
}
},
"node_modules/@opentelemetry/api": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
"integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
"license": "Apache-2.0",
"optional": true,
"peer": true,
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/@peculiar/asn1-cms": {
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.6.1.tgz",
+3
View File
@@ -7,6 +7,8 @@
"dev": "node server/index.js --dev",
"dev:https": "node server/index.js --dev --https",
"studio-ai-worker": "node server/studio-ai-worker.js",
"studio-ai-upstream-setup": "node scripts/studio-ai-upstream-setup.mjs",
"studio-ai-upstream-local": "node server/studio-ai-upstream-local.js",
"hermes-adapter": "node server/hermes-gateway-adapter.js",
"demo-gateway": "node server/demo-gateway-adapter.js",
"build": "next build",
@@ -16,6 +18,7 @@
"sync:gateway-client": "node scripts/sync-openclaw-gateway-client.ts",
"studio:setup": "node scripts/studio-setup.js",
"smoke:dev-server": "node scripts/smoke-dev-server.mjs",
"smoke:remote-upstream": "node scripts/studio-ai-remote-smoke.mjs",
"typecheck": "tsc --noEmit",
"test": "vitest",
"e2e": "playwright test"
Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 MiB

+173
View File
@@ -0,0 +1,173 @@
/* eslint-env node */
/* global console, process, fetch, setTimeout, URL, Buffer */
import fs from "node:fs";
import path from "node:path";
const parseArgs = () => {
const args = process.argv.slice(2);
const values = {};
for (let index = 0; index < args.length; index += 1) {
const token = args[index];
if (!token.startsWith("--")) continue;
const key = token.slice(2);
const next = args[index + 1];
if (!next || next.startsWith("--")) {
values[key] = "true";
continue;
}
values[key] = next;
index += 1;
}
return values;
};
const guessMimeType = (filePath) => {
const extension = path.extname(filePath).toLowerCase();
if (extension === ".jpg" || extension === ".jpeg") return "image/jpeg";
if (extension === ".webp") return "image/webp";
return "image/png";
};
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const withAuthHeaders = (apiKey) =>
apiKey
? {
Authorization: `Bearer ${apiKey}`,
}
: {};
const ensureOk = async (response, context) => {
if (response.ok) return;
const message = (await response.text()).trim();
throw new Error(`${context} failed (${response.status}): ${message || "no body"}`);
};
const parseBoolean = (value, fallback) => {
if (value == null) return fallback;
const normalized = String(value).trim().toLowerCase();
if (["1", "true", "yes", "on"].includes(normalized)) return true;
if (["0", "false", "no", "off"].includes(normalized)) return false;
return fallback;
};
const main = async () => {
const options = parseArgs();
const baseUrl = (options["base-url"] || process.env.CLAW3D_STUDIO_UPSTREAM_PROVIDER_URL || "").trim();
const apiKey = (options["api-key"] || process.env.CLAW3D_STUDIO_UPSTREAM_PROVIDER_API_KEY || "").trim();
const imagePath = path.resolve(options.image || "tests/fixtures/studio-ai/sample-input.png");
const outputPath = path.resolve(options.output || "tmp/studio-ai-remote-smoke-model.glb");
const pollMs = Number.parseInt(options["poll-ms"] || "1200", 10);
const timeoutMs = Number.parseInt(options["timeout-ms"] || "900000", 10);
const shouldTexture = parseBoolean(options["should-texture"], true);
if (!baseUrl) {
throw new Error("Missing --base-url (or CLAW3D_STUDIO_UPSTREAM_PROVIDER_URL).");
}
if (!fs.existsSync(imagePath)) {
throw new Error(`Input image not found: ${imagePath}`);
}
const upstreamBase = baseUrl.replace(/\/$/, "");
const origin = new URL(upstreamBase).origin;
const imageBuffer = fs.readFileSync(imagePath);
const imageMime = guessMimeType(imagePath);
const imageDataUri = `data:${imageMime};base64,${imageBuffer.toString("base64")}`;
console.log(`Smoke test upstream: ${upstreamBase}`);
console.log(`Input image: ${imagePath}`);
console.log(`Texturing enabled: ${shouldTexture}`);
const healthResponse = await fetch(`${origin}/health`, {
headers: withAuthHeaders(apiKey),
});
await ensureOk(healthResponse, "Health check");
const healthBody = await healthResponse.text();
console.log(`Health response: ${healthBody}`);
const createResponse = await fetch(`${upstreamBase}/image-to-3d`, {
method: "POST",
headers: {
...withAuthHeaders(apiKey),
"Content-Type": "application/json",
},
body: JSON.stringify({
image_url: imageDataUri,
image_role: "front",
target_formats: ["glb"],
should_texture: shouldTexture,
ai_model: "latest",
}),
});
await ensureOk(createResponse, "Create task");
const createBody = await createResponse.json();
const taskId = typeof createBody.result === "string" ? createBody.result.trim() : "";
if (!taskId) {
throw new Error(`Create task returned no task id: ${JSON.stringify(createBody)}`);
}
const debugLogUrl = `${upstreamBase}/image-to-3d/${encodeURIComponent(taskId)}/debug-log`;
console.log(`Task id: ${taskId}`);
console.log(`Debug log URL: ${debugLogUrl}`);
const startedAt = Date.now();
let finalTask = null;
while (!finalTask) {
if (Date.now() - startedAt > timeoutMs) {
throw new Error(`Polling timed out after ${timeoutMs}ms.`);
}
const taskResponse = await fetch(`${upstreamBase}/image-to-3d/${encodeURIComponent(taskId)}`, {
headers: withAuthHeaders(apiKey),
cache: "no-store",
});
await ensureOk(taskResponse, "Poll task");
const task = await taskResponse.json();
const status = String(task.status || "");
const progress = Number(task.progress || 0);
console.log(`Task status: ${status} (${progress}%).`);
if (status === "SUCCEEDED" || status === "FAILED" || status === "CANCELED") {
finalTask = task;
break;
}
await sleep(pollMs);
}
if (finalTask.status !== "SUCCEEDED") {
const errorMessage = finalTask?.task_error?.message || "unknown upstream error";
try {
const debugResponse = await fetch(debugLogUrl, {
headers: withAuthHeaders(apiKey),
cache: "no-store",
});
if (debugResponse.ok) {
const debugBody = await debugResponse.json();
const debugLog = typeof debugBody?.log === "string" ? debugBody.log.trim() : "";
if (debugLog) {
console.log("Debug log:");
console.log(debugLog);
}
}
} catch {
// Ignore debug-log retrieval failures and keep the original task error.
}
throw new Error(`Task did not succeed. Status=${finalTask.status}. Error=${errorMessage}`);
}
const modelUrl =
typeof finalTask?.model_urls?.glb === "string" && finalTask.model_urls.glb.trim()
? finalTask.model_urls.glb.trim()
: `${upstreamBase}/image-to-3d/${encodeURIComponent(taskId)}/output/model.glb`;
const modelResponse = await fetch(modelUrl, {
headers: withAuthHeaders(apiKey),
});
await ensureOk(modelResponse, "Download GLB");
const modelBuffer = Buffer.from(await modelResponse.arrayBuffer());
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
fs.writeFileSync(outputPath, modelBuffer);
console.log(`Downloaded GLB: ${outputPath}`);
console.log("Remote smoke test succeeded.");
};
main().catch((error) => {
console.error(`Remote smoke test failed: ${error instanceof Error ? error.message : String(error)}`);
process.exit(1);
});
+57
View File
@@ -0,0 +1,57 @@
/* eslint-env node */
/* global console, process */
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(scriptDir, "..");
const venvDir = path.join(repoRoot, ".venv-studio-ai-backend");
const requirementsPath = path.join(repoRoot, "server", "studio-ai-real-backend.requirements.txt");
const hunyuan21Dir =
process.env.CLAW3D_STUDIO_REAL_BACKEND_HUNYUAN21_SOURCE_ROOT?.trim() ||
path.join(os.homedir(), ".cache", "claw3d", "Hunyuan3D-2.1");
const requestedPython = process.env.PYTHON?.trim();
const run = (command, args) => {
const result = spawnSync(command, args, {
cwd: repoRoot,
env: process.env,
stdio: "inherit",
});
if (result.status !== 0) {
process.exit(result.status ?? 1);
}
};
const resolvePythonCommand = () => {
if (requestedPython) {
return requestedPython;
}
return process.platform === "win32" ? "python" : "python3";
};
const resolveVenvPython = () =>
process.platform === "win32"
? path.join(venvDir, "Scripts", "python.exe")
: path.join(venvDir, "bin", "python");
if (!fs.existsSync(venvDir)) {
run(resolvePythonCommand(), ["-m", "venv", ".venv-studio-ai-backend"]);
}
const venvPython = resolveVenvPython();
if (!fs.existsSync(venvPython)) {
console.error("The Studio AI backend virtual environment is missing its Python executable.");
process.exit(1);
}
fs.mkdirSync(path.dirname(hunyuan21Dir), { recursive: true });
if (!fs.existsSync(path.join(hunyuan21Dir, ".git"))) {
run("git", ["clone", "--depth", "1", "https://github.com/Tencent-Hunyuan/Hunyuan3D-2.1.git", hunyuan21Dir]);
}
run(venvPython, ["-m", "pip", "install", "-U", "pip", "setuptools", "wheel"]);
run(venvPython, ["-m", "pip", "install", "-r", requirementsPath]);
@@ -0,0 +1,73 @@
FROM nvidia/cuda:12.8.1-devel-ubuntu22.04
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
ca-certificates \
cmake \
curl \
git \
libegl1 \
libgl1 \
libglib2.0-0 \
libgles2 \
libglvnd0 \
libglx0 \
libopengl0 \
libsm6 \
libxext6 \
libxrender1 \
ninja-build \
pkg-config \
python3 \
python3-dev \
python3-pip \
python3-venv \
unzip \
wget \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
RUN useradd --create-home --shell /bin/bash appuser
COPY server/studio-ai-real-backend.requirements.txt /app/server/studio-ai-real-backend.requirements.txt
RUN python3 -m pip install --upgrade pip setuptools wheel && \
python3 -m pip install --no-cache-dir \
--pre torch torchvision torchaudio \
--index-url https://download.pytorch.org/whl/nightly/cu128 && \
python3 -m pip install --no-cache-dir --force-reinstall "numpy<2" && \
python3 -m pip install --no-cache-dir --no-build-isolation basicsr==1.4.2 && \
python3 -m pip install --no-cache-dir -r /app/server/studio-ai-real-backend.requirements.txt
RUN python3 -m pip install --no-cache-dir fast-simplification
RUN git clone --depth 1 https://github.com/Tencent-Hunyuan/Hunyuan3D-2.1.git /opt/hunyuan/Hunyuan3D-2.1 && \
export CUDA_HOME=/usr/local/cuda && \
export CUDA_NVCC_FLAGS="-allow-unsupported-compiler" && \
export TORCH_CUDA_ARCH_LIST="8.0;8.6;8.9;9.0;12.0" && \
python3 -m pip install --no-cache-dir --no-build-isolation /opt/hunyuan/Hunyuan3D-2.1/hy3dpaint/custom_rasterizer && \
ln -sf /usr/bin/python3 /usr/local/bin/python && \
cd /opt/hunyuan/Hunyuan3D-2.1/hy3dpaint/DifferentiableRenderer && \
bash compile_mesh_painter.sh && \
mkdir -p /opt/hunyuan/Hunyuan3D-2.1/hy3dpaint/ckpt && \
wget -q https://github.com/xinntao/Real-ESRGAN/releases/download/v0.1.0/RealESRGAN_x4plus.pth \
-O /opt/hunyuan/Hunyuan3D-2.1/hy3dpaint/ckpt/RealESRGAN_x4plus.pth
COPY server/studio_ai_real_backend.py /app/server/studio_ai_real_backend.py
COPY server/studio-ai-real-backend.cuda.entrypoint.sh /usr/local/bin/studio-ai-real-backend-start
RUN chmod +x /usr/local/bin/studio-ai-real-backend-start && \
mkdir -p /opt/hunyuan && \
chown -R appuser:appuser /app /opt/hunyuan
ENV CLAW3D_STUDIO_LOCAL_UPSTREAM_HOST=0.0.0.0
ENV CLAW3D_STUDIO_LOCAL_UPSTREAM_PORT=8000
ENV CLAW3D_STUDIO_REAL_BACKEND_DEVICE=auto
ENV CLAW3D_STUDIO_REAL_BACKEND_HUNYUAN21_SOURCE_ROOT=/opt/hunyuan/Hunyuan3D-2.1
ENV PIP_NO_BUILD_ISOLATION=1
ENV TORCH_CUDA_ARCH_LIST="8.0;8.6;8.9;9.0;12.0"
ENV PYOPENGL_PLATFORM=egl
EXPOSE 8000
USER appuser
ENTRYPOINT ["/usr/local/bin/studio-ai-real-backend-start"]
@@ -0,0 +1,23 @@
#!/usr/bin/env bash
set -euo pipefail
HUNYUAN_ROOT="${CLAW3D_STUDIO_REAL_BACKEND_HUNYUAN21_SOURCE_ROOT:-/opt/hunyuan/Hunyuan3D-2.1}"
mkdir -p "$(dirname "${HUNYUAN_ROOT}")"
if [ ! -d "${HUNYUAN_ROOT}/.git" ]; then
git clone --depth 1 https://github.com/Tencent-Hunyuan/Hunyuan3D-2.1.git "${HUNYUAN_ROOT}"
fi
export CLAW3D_STUDIO_REAL_BACKEND_HUNYUAN21_SOURCE_ROOT="${HUNYUAN_ROOT}"
export PYTHONUNBUFFERED=1
while true; do
echo "[studio-ai-real-backend.entrypoint] starting backend at $(date -u +"%Y-%m-%dT%H:%M:%SZ")."
if python3 -u /app/server/studio_ai_real_backend.py; then
echo "[studio-ai-real-backend.entrypoint] backend exited cleanly."
exit 0
fi
exit_code=$?
echo "[studio-ai-real-backend.entrypoint] backend exited with code ${exit_code}; restarting in 2 seconds."
sleep 2
done
@@ -0,0 +1,20 @@
fastapi
hy3dgen
diffusers==0.30.0
einops==0.8.0
imageio==2.36.0
numpy<2
omegaconf==2.3.0
onnxruntime==1.16.3
opencv-python==4.10.0.84
pybind11==2.13.4
pygltflib==1.16.3
pymeshlab==2022.2.post3
pytorch-lightning
realesrgan==0.3.0
scikit-image==0.24.0
timm
torchmetrics
transformers==4.46.0
uvicorn
xatlas==0.0.9
+64
View File
@@ -0,0 +1,64 @@
/* eslint-env node */
/* global __dirname, console, module, process, require */
const fs = require("node:fs");
const path = require("node:path");
const { spawn } = require("node:child_process");
const repoRoot = path.resolve(__dirname, "..");
const requestedPython = process.env.CLAW3D_STUDIO_REAL_BACKEND_PYTHON?.trim() || "";
const resolvePythonBinary = () => {
const candidates = [
requestedPython,
path.join(repoRoot, ".venv-studio-ai-backend", "bin", "python"),
path.join(repoRoot, ".venv-studio-ai-backend", "Scripts", "python.exe"),
].filter(Boolean);
return candidates.find((candidate) => fs.existsSync(candidate)) || null;
};
async function main() {
const pythonBinary = resolvePythonBinary();
if (!pythonBinary) {
throw new Error(
"Studio AI backend environment is missing. Run `npm run studio-ai-upstream-setup` first or set CLAW3D_STUDIO_REAL_BACKEND_PYTHON.",
);
}
const scriptPath = path.join(__dirname, "studio_ai_real_backend.py");
const child = spawn(pythonBinary, [scriptPath], {
cwd: repoRoot,
env: process.env,
stdio: "inherit",
});
const forwardSignal = (signal) => {
if (child.killed) return;
child.kill(signal);
};
process.on("SIGINT", () => forwardSignal("SIGINT"));
process.on("SIGTERM", () => forwardSignal("SIGTERM"));
await new Promise((resolve, reject) => {
child.once("error", reject);
child.once("exit", (code, signal) => {
if (signal) {
process.exitCode = 1;
} else {
process.exitCode = code ?? 0;
}
resolve();
});
});
}
if (require.main === module) {
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
}
module.exports = {
main,
};
+486 -87
View File
@@ -1,3 +1,5 @@
/* 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");
@@ -85,6 +87,8 @@ 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,
@@ -125,6 +129,9 @@ const loadTaskMetadata = (rootDir, taskId) => {
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" ||
@@ -1059,10 +1066,80 @@ const fuseColorViews = (views) => {
);
};
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);
@@ -1070,23 +1147,25 @@ const createTaskStore = () => {
return dir;
};
const toTaskObject = (task, baseUrl) => ({
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: `${baseUrl}/openapi/v1/image-to-3d/${task.id}/output/model.glb`,
glb: `${responseBaseUrl}/openapi/v1/image-to-3d/${task.id}/output/model.glb`,
}
: {},
thumbnail_url: task.thumbnailPath
? `${baseUrl}/openapi/v1/image-to-3d/${task.id}/output/thumbnail.png`
&& task.thumbnailPath !== task.sourceImagePath
? `${responseBaseUrl}/openapi/v1/image-to-3d/${task.id}/output/thumbnail.png`
: "",
depth_preview_url: task.depthPreviewPath
? `${baseUrl}/openapi/v1/image-to-3d/${task.id}/output/depth.png`
? `${responseBaseUrl}/openapi/v1/image-to-3d/${task.id}/output/depth.png`
: "",
normal_preview_url: task.normalPreviewPath
? `${baseUrl}/openapi/v1/image-to-3d/${task.id}/output/normal.png`
? `${responseBaseUrl}/openapi/v1/image-to-3d/${task.id}/output/normal.png`
: "",
progress: task.progress,
width: task.size?.width ?? null,
@@ -1100,25 +1179,380 @@ const createTaskStore = () => {
task_error: {
message: task.errorMessage || "",
},
using_test_mode:
typeof task.usingTestMode === "boolean"
? task.usingTestMode
: backendConfig.mode === "local_mock",
});
const createTask = async (params, baseUrl) => {
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 || adapterRegistry.defaultAdapterId);
const adapter = adapterRegistry.getAdapter(adapterId);
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,
@@ -1131,84 +1565,22 @@ const createTaskStore = () => {
tasks.set(taskId, task);
writeTaskMetadata(taskDir, task);
setTimeout(async () => {
task.status = "IN_PROGRESS";
task.progress = 18;
task.startedAt = Date.now();
writeTaskMetadata(taskDir, task);
try {
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),
});
const modelPath = path.join(taskDir, "model.glb");
const depthPreviewPath = path.join(taskDir, "depth.png");
const normalPreviewPath = path.join(taskDir, "normal.png");
fs.writeFileSync(modelPath, result.glb);
if (Array.isArray(result.depthGrid) && result.depthGrid.length > 0) {
await writeDepthPreview(depthPreviewPath, result.depthGrid);
task.depthPreviewPath = depthPreviewPath;
}
if (Array.isArray(result.normalGrid) && result.normalGrid.length > 0) {
await writeNormalPreview(normalPreviewPath, result.normalGrid);
task.normalPreviewPath = normalPreviewPath;
}
task.modelPath = modelPath;
task.thumbnailPath = result.thumbnailSourcePath || sourceImagePath;
task.palette = result.palette || [];
task.size = result.size || task.size;
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);
}
}, TASK_TIMEOUT_MS);
const taskDelayMs = backendConfig.mode === "upstream_openapi" ? 0 : TASK_TIMEOUT_MS;
setTimeout(() => {
void executeTask(task, taskDir, sourceImagePath, params);
}, taskDelayMs);
return { result: taskId, task: toTaskObject(task, baseUrl) };
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() {
@@ -1220,10 +1592,10 @@ const createTaskStore = () => {
}
},
createTask,
getTask(taskId, baseUrl) {
getTask(taskId, responseBaseUrl) {
const task = tasks.get(taskId);
if (!task) return null;
return toTaskObject(task, baseUrl);
return toTaskObject(task, responseBaseUrl);
},
getTaskFile(taskId, kind) {
const task = tasks.get(taskId);
@@ -1234,12 +1606,24 @@ const createTaskStore = () => {
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();
@@ -1260,11 +1644,15 @@ const createStudioAiWorkerServer = (params = {}) => {
const url = new URL(req.url, `http://${host}:${port}`);
const pathname = url.pathname;
const baseUrl = `http://${host}:${port}`;
const responseBaseUrl = publicBaseUrl || `http://${host}:${port}`;
try {
if (req.method === "GET" && pathname === "/health") {
respondJson(res, 200, { ok: true, service: "studio-ai-worker" });
respondJson(res, 200, {
ok: true,
service: "studio-ai-worker",
public_base_url: responseBaseUrl,
});
return;
}
@@ -1319,7 +1707,7 @@ const createStudioAiWorkerServer = (params = {}) => {
mimeType,
role: normalizeImageRole(body.image_role || "front"),
},
baseUrl,
responseBaseUrl,
);
respondJson(res, 200, { result: created.result });
return;
@@ -1327,7 +1715,7 @@ const createStudioAiWorkerServer = (params = {}) => {
const taskMatch = pathname.match(/^\/openapi\/v1\/image-to-3d\/([^/]+)$/);
if (req.method === "GET" && taskMatch) {
const task = taskStore.getTask(taskMatch[1], baseUrl);
const task = taskStore.getTask(taskMatch[1], responseBaseUrl);
if (!task) {
respondJson(res, 404, { error: "Task not found." });
return;
@@ -1336,6 +1724,17 @@ const createStudioAiWorkerServer = (params = {}) => {
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");
File diff suppressed because it is too large Load Diff
+29
View File
@@ -14,6 +14,7 @@ import {
buildRealAiSummary,
buildStudioAiProviderAvailability,
createSelfHostedImageTo3dTask,
getSelfHostedImageTo3dTaskDebugLog,
getSelfHostedImageTo3dTask,
isRealStudioAiEnabled,
} from "@/lib/studio-world/provider";
@@ -273,6 +274,7 @@ export async function GET(request: Request) {
height: task.height ?? null,
palette: task.palette ?? [],
errorMessage: task.taskErrorMessage,
usingTestMode: task.usingTestMode,
});
return NextResponse.json(
{
@@ -291,6 +293,33 @@ export async function GET(request: Request) {
{ headers: { "Cache-Control": "no-store" } },
);
}
if (action === "task-log") {
if (!projectId) {
return NextResponse.json(
{ error: "projectId is required for task log." },
{ status: 400 },
);
}
const project = getStudioProject(projectId);
if (!project?.externalModel?.taskId) {
return NextResponse.json(
{ error: "No external model task exists for this project." },
{ status: 400 },
);
}
if (project.externalModel.provider !== "self_hosted") {
return NextResponse.json(
{ error: "Unsupported provider for task log." },
{ status: 400 },
);
}
return NextResponse.json(
{
taskLog: await getSelfHostedImageTo3dTaskDebugLog(project.externalModel.taskId),
},
{ headers: { "Cache-Control": "no-store" } },
);
}
return NextResponse.json(
{
projects: listStudioProjects(),
@@ -13,7 +13,11 @@ import type {
StudioWorldAssetDraft,
StudioWorldDraft,
} from "@/lib/studio-world/types";
import { buildAssetGeometry, buildAssetMaterial, buildGlowMaterial } from "@/features/studio-world/preview/scene-utils";
import {
buildAssetGeometry,
buildAssetMaterial,
buildGlowMaterial,
} from "@/features/studio-world/preview/scene-utils";
type AssetMeshProps = {
asset: StudioWorldAssetDraft;
@@ -36,7 +40,11 @@ const AssetMesh = ({ asset }: AssetMeshProps) => {
const elapsed = clock.elapsedTime;
const [x, y, z] = asset.position;
if (asset.animation === "bob") {
groupRef.current.position.set(x, y + Math.sin(elapsed * 1.8 + x) * 0.22, z);
groupRef.current.position.set(
x,
y + Math.sin(elapsed * 1.8 + x) * 0.22,
z,
);
} else if (asset.animation === "pulse") {
const scale = 1 + Math.sin(elapsed * 2.4 + z) * 0.06;
groupRef.current.position.set(x, y, z);
@@ -57,7 +65,9 @@ const AssetMesh = ({ asset }: AssetMeshProps) => {
position={[0, Math.max(asset.scale[1] * 0.6, 0.8), 0]}
material={glowMaterial}
>
<sphereGeometry args={[Math.max(asset.scale[0] * 0.28, 0.35), 18, 18]} />
<sphereGeometry
args={[Math.max(asset.scale[0] * 0.28, 0.35), 18, 18]}
/>
</mesh>
) : null}
</group>
@@ -79,14 +89,28 @@ const SceneContents = ({ sceneDraft }: { sceneDraft: StudioWorldDraft }) => {
shadow-mapSize-width={2048}
shadow-mapSize-height={2048}
/>
<directionalLight position={[-12, 10, -6]} intensity={0.45} color={sceneDraft.palette.glow} />
<directionalLight
position={[-12, 10, -6]}
intensity={0.45}
color={sceneDraft.palette.glow}
/>
<mesh rotation={[-Math.PI / 2, 0, 0]} receiveShadow>
<planeGeometry args={[sceneDraft.worldBounds.width * 2.2, sceneDraft.worldBounds.depth * 2.2]} />
<meshStandardMaterial color={sceneDraft.palette.ground} roughness={0.95} metalness={0.02} />
<planeGeometry
args={[
sceneDraft.worldBounds.width * 2.2,
sceneDraft.worldBounds.depth * 2.2,
]}
/>
<meshStandardMaterial
color={sceneDraft.palette.ground}
roughness={0.95}
metalness={0.02}
/>
</mesh>
<gridHelper
args={[
Math.max(sceneDraft.worldBounds.width, sceneDraft.worldBounds.depth) * 2,
Math.max(sceneDraft.worldBounds.width, sceneDraft.worldBounds.depth) *
2,
24,
new THREE.Color(sceneDraft.palette.glow),
new THREE.Color(sceneDraft.palette.structure),
@@ -148,10 +172,18 @@ const RemoteGlbContents = ({ glbUrl }: { glbUrl: string }) => {
shadow-mapSize-width={2048}
shadow-mapSize-height={2048}
/>
<directionalLight position={[-12, 10, -6]} intensity={0.4} color="#8bd6ff" />
<directionalLight
position={[-12, 10, -6]}
intensity={0.4}
color="#8bd6ff"
/>
<mesh rotation={[-Math.PI / 2, 0, 0]} receiveShadow>
<planeGeometry args={[24, 24]} />
<meshStandardMaterial color="#121a22" roughness={0.96} metalness={0.01} />
<meshStandardMaterial
color="#121a22"
roughness={0.96}
metalness={0.01}
/>
</mesh>
<gridHelper
args={[24, 24, new THREE.Color("#3b82f6"), new THREE.Color("#334155")]}
@@ -174,7 +206,10 @@ const RemoteGlbContents = ({ glbUrl }: { glbUrl: string }) => {
type StudioWorldPreviewProps = {
sceneDraft: StudioWorldDraft;
referenceImage?: StudioSourceImageRecord | null;
project?: Pick<StudioProjectRecord, "mode" | "provider" | "externalModel"> | null;
project?: Pick<
StudioProjectRecord,
"mode" | "provider" | "externalModel"
> | null;
};
export function StudioWorldPreview({
@@ -183,25 +218,49 @@ export function StudioWorldPreview({
project = null,
}: StudioWorldPreviewProps) {
const isRemoteAiProject = project?.provider === "self_hosted";
const remoteStatus = project?.externalModel?.status ?? null;
const remoteReady = Boolean(project?.externalModel?.glbUrl);
const remoteGlbUrl = project?.externalModel?.glbUrl ?? null;
const remoteThumbnailUrl = project?.externalModel?.thumbnailUrl ?? null;
const remoteDepthPreviewUrl = project?.externalModel?.depthPreviewUrl ?? null;
const remoteNormalPreviewUrl = project?.externalModel?.normalPreviewUrl ?? null;
const previewLabel = isRemoteAiProject
? remoteReady
? "Remote AI result available"
: "Remote AI task in progress"
: "Local Studio preview";
const previewSubLabel = isRemoteAiProject
? remoteReady
? "Showing local fallback scene while provider GLB and thumbnail are ready."
: "Showing local fallback scene until the provider finishes."
: project?.mode === "image_avatar"
? "Image-guided avatar proxy."
: project?.mode === "image_mesh"
? "Image-guided mesh draft."
: "Local world draft.";
const remoteProgress = Math.max(
0,
Math.min(100, remoteReady ? 100 : (project?.externalModel?.progress ?? 0)),
);
const remoteStatusLabel =
remoteStatus === "completed" && !remoteReady
? "Syncing"
: remoteStatus === "completed"
? "Complete"
: remoteStatus === "failed"
? "Failed"
: remoteStatus === "in_progress"
? "Generating"
: remoteStatus === "pending"
? "Queued"
: "Idle";
const remoteProgressTone =
remoteStatus === "failed"
? "bg-red-400/90"
: remoteReady
? "bg-emerald-400/90"
: "bg-cyan-300/90";
const remoteBackendBadge = !isRemoteAiProject
? null
: project?.externalModel?.usingTestMode === true
? {
label: "Mock",
className: "border-amber-400/30 bg-amber-500/15 text-amber-100",
}
: project?.externalModel?.usingTestMode === false
? {
label: "Real upstream",
className:
"border-emerald-400/30 bg-emerald-500/15 text-emerald-100",
}
: {
label: "Checking backend",
className: "border-white/15 bg-black/35 text-white/75",
};
return (
<div className="relative h-full min-h-[360px] w-full overflow-hidden rounded-2xl border border-border/60 bg-black/70">
<Canvas
@@ -219,23 +278,34 @@ export function StudioWorldPreview({
<SceneContents sceneDraft={sceneDraft} />
)}
</Canvas>
<div className="pointer-events-none absolute inset-x-0 top-0 flex items-center justify-between bg-gradient-to-b from-black/55 to-transparent px-4 py-3">
<div>
<p className="font-mono text-[10px] uppercase tracking-[0.18em] text-cyan-100/80">
Claw3D Studio Preview
</p>
<p className="mt-1 text-sm text-white/90">{sceneDraft.promptSummary}</p>
<p className="mt-1 text-[11px] text-white/65">{previewLabel}</p>
</div>
<div className="pointer-events-none absolute right-4 top-4 flex items-center gap-2">
{remoteBackendBadge ? (
<div
className={`rounded-full border px-3 py-1 font-mono text-[10px] uppercase tracking-[0.16em] ${remoteBackendBadge.className}`}
>
{remoteBackendBadge.label}
</div>
) : null}
<div className="rounded-full border border-white/15 bg-black/35 px-3 py-1 font-mono text-[10px] uppercase tracking-[0.16em] text-white/75">
{sceneDraft.assets.length} assets
</div>
</div>
<div className="pointer-events-none absolute inset-x-0 top-16 px-4">
<div className="inline-flex rounded-full border border-white/10 bg-black/35 px-3 py-1 font-mono text-[10px] uppercase tracking-[0.14em] text-white/70">
{previewSubLabel}
{isRemoteAiProject ? (
<div className="pointer-events-none absolute inset-x-0 bottom-4 flex justify-center px-4">
<div className="w-full max-w-sm rounded-2xl border border-white/10 bg-black/45 px-3 py-2 shadow-2xl backdrop-blur">
<div className="flex items-center justify-between gap-3 font-mono text-[10px] uppercase tracking-[0.16em] text-white/75">
<span>{remoteStatusLabel}</span>
<span>{remoteProgress}%</span>
</div>
<div className="mt-2 h-2 overflow-hidden rounded-full bg-white/10">
<div
className={`h-full rounded-full transition-[width] duration-500 ease-out ${remoteProgressTone}`}
style={{ width: `${remoteProgress}%` }}
/>
</div>
</div>
</div>
</div>
) : null}
{referenceImage ? (
<div className="pointer-events-none absolute bottom-4 left-4 w-36 overflow-hidden rounded-2xl border border-white/15 bg-black/45 shadow-2xl backdrop-blur">
<Image
@@ -250,7 +320,9 @@ export function StudioWorldPreview({
<div className="font-mono text-[10px] uppercase tracking-[0.16em] text-cyan-100/80">
Reference
</div>
<div className="mt-1 truncate text-xs text-white/85">{referenceImage.fileName}</div>
<div className="mt-1 truncate text-xs text-white/85">
{referenceImage.fileName}
</div>
<div className="mt-1 text-[11px] text-white/60">
{project?.mode === "image_avatar"
? "Image-guided avatar proxy."
@@ -281,44 +353,6 @@ export function StudioWorldPreview({
</div>
</div>
) : null}
{remoteDepthPreviewUrl || remoteNormalPreviewUrl ? (
<div className="pointer-events-none absolute bottom-4 left-1/2 grid w-[21rem] -translate-x-1/2 grid-cols-2 gap-3 rounded-2xl border border-white/15 bg-black/45 p-3 shadow-2xl backdrop-blur">
{remoteDepthPreviewUrl ? (
<div className="overflow-hidden rounded-xl border border-white/10 bg-black/35">
<Image
src={remoteDepthPreviewUrl}
alt="Remote AI depth preview"
width={160}
height={120}
className="h-24 w-full object-cover"
unoptimized
/>
<div className="px-2 py-1.5">
<div className="font-mono text-[10px] uppercase tracking-[0.16em] text-cyan-100/80">
Depth
</div>
</div>
</div>
) : null}
{remoteNormalPreviewUrl ? (
<div className="overflow-hidden rounded-xl border border-white/10 bg-black/35">
<Image
src={remoteNormalPreviewUrl}
alt="Remote AI normal preview"
width={160}
height={120}
className="h-24 w-full object-cover"
unoptimized
/>
<div className="px-2 py-1.5">
<div className="font-mono text-[10px] uppercase tracking-[0.16em] text-cyan-100/80">
Normal
</div>
</div>
</div>
) : null}
</div>
) : null}
</div>
);
}
@@ -6,6 +6,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { HeaderBar } from "@/features/agents/components/HeaderBar";
import { exportStudioProjectGlb } from "@/features/studio-world/export/exportGlb";
import { StudioWorldPreview } from "@/features/studio-world/preview/StudioWorldPreview";
import { StudioWorldTaskLogCard } from "@/features/studio-world/screens/StudioWorldTaskLogCard";
import type {
StudioProviderAvailability,
StudioProjectRecord,
@@ -43,6 +44,11 @@ const IMAGE_ROLE_OPTIONS: Array<NonNullable<StudioSourceImageRecord["role"]>> =
"detail",
];
const STUDIO_INPUT_CLASS =
"ui-input w-full !text-foreground placeholder:!text-muted-foreground";
const STUDIO_TEXTAREA_CLASS = `${STUDIO_INPUT_CLASS} min-h-36 resize-y`;
const STUDIO_SELECT_CLASS = "ui-input w-full !text-foreground";
type ExportManifestResponse = {
exportManifest?: unknown;
error?: string;
@@ -73,6 +79,32 @@ type StudioWorldResponse = {
error?: string;
};
type StudioWorldTaskStatusResponse = StudioWorldResponse & {
project: StudioProjectRecord;
};
const shouldPollExternalModel = (externalModel?: StudioProjectRecord["externalModel"] | null) =>
externalModel?.status === "pending" ||
externalModel?.status === "in_progress" ||
(externalModel?.status === "completed" && !externalModel.glbUrl?.trim());
const fetchProjectTaskStatus = async (
projectId: string,
): Promise<StudioWorldTaskStatusResponse> => {
const response = await fetch(
`/api/studio-world?action=task-status&projectId=${encodeURIComponent(projectId)}`,
{ cache: "no-store" },
);
const body = (await response.json()) as StudioWorldResponse;
if (!response.ok || !body.project) {
throw new Error(body.error || "Failed to load task status.");
}
return {
...body,
project: body.project,
};
};
const downloadFileFromUrl = async (params: {
url: string;
filename: string;
@@ -138,6 +170,7 @@ export function StudioWorldScreen() {
() => projects.find((entry) => entry.id === selectedProjectId) ?? projects[0] ?? null,
[projects, selectedProjectId],
);
const shouldPollRemoteTask = shouldPollExternalModel(selectedProject?.externalModel);
const refreshProjects = useCallback(async () => {
setLoading(true);
@@ -178,31 +211,25 @@ export function StudioWorldScreen() {
}, [providerAvailability, selectedProject]);
useEffect(() => {
if (!selectedProject?.externalModel?.taskId) return;
if (
selectedProject.externalModel.status !== "pending" &&
selectedProject.externalModel.status !== "in_progress"
) {
return;
}
if (!selectedProject?.externalModel?.taskId || !shouldPollRemoteTask) return;
let cancelled = false;
const poll = async () => {
try {
const response = await fetch(
`/api/studio-world?action=task-status&projectId=${encodeURIComponent(selectedProject.id)}`,
{ cache: "no-store" },
);
const body = (await response.json()) as StudioWorldResponse;
if (!response.ok || !body.project || cancelled) {
return;
}
const body = await fetchProjectTaskStatus(selectedProject.id);
if (cancelled) return;
setProjects((current) =>
current.map((entry) => (entry.id === body.project!.id ? body.project! : entry)),
current.map((entry) => (entry.id === body.project.id ? body.project : entry)),
);
if (body.providerTask?.status === "SUCCEEDED") {
setStatusLine("Real AI image-to-3D task completed.");
} else if (body.providerTask?.status === "FAILED" || body.providerTask?.status === "CANCELED") {
setStatusLine(body.providerTask.taskErrorMessage || "Real AI image-to-3D task failed.");
const nextStatusLine =
body.providerTask?.status === "SUCCEEDED"
? body.project.externalModel?.glbUrl
? "Real AI image-to-3D task completed."
: "Real AI image-to-3D task completed. Syncing provider GLB."
: body.providerTask?.status === "FAILED" || body.providerTask?.status === "CANCELED"
? body.providerTask.taskErrorMessage || "Real AI image-to-3D task failed."
: null;
if (nextStatusLine) {
setStatusLine(nextStatusLine);
}
} catch {
// ignore transient poll failures; next interval may succeed
@@ -216,7 +243,7 @@ export function StudioWorldScreen() {
cancelled = true;
window.clearInterval(intervalId);
};
}, [selectedProject]);
}, [selectedProject?.id, selectedProject?.externalModel?.taskId, shouldPollRemoteTask]);
const handleImageUpload = async (file: File) => {
setUploadingImage(true);
@@ -262,6 +289,10 @@ export function StudioWorldScreen() {
);
};
const handleRemoveImage = (imageId: string) => {
setUploadedImages((current) => current.filter((image) => image.id !== imageId));
};
const handleGenerate = async () => {
setBusy(true);
setStatusLine(
@@ -535,7 +566,21 @@ export function StudioWorldScreen() {
<div className="mt-3 space-y-3">
<div className="grid gap-3 sm:grid-cols-3">
{uploadedImages.map((image, index) => (
<div key={image.id} className="overflow-hidden rounded-xl border border-border/60 bg-black/10">
<div
key={image.id}
className="overflow-hidden rounded-xl border border-border/60 bg-black/10"
>
<div className="flex items-center justify-end border-b border-border/50 px-3 py-2">
<button
type="button"
className="text-xs font-medium text-muted-foreground transition-colors hover:text-foreground"
onClick={() => handleRemoveImage(image.id)}
disabled={busy || uploadingImage}
aria-label={`Remove ${image.fileName}`}
>
Remove
</button>
</div>
<Image
src={image.dataUrl}
alt={image.fileName}
@@ -554,7 +599,7 @@ export function StudioWorldScreen() {
View role
</span>
<select
className="ui-input w-full"
className={STUDIO_SELECT_CLASS}
value={image.role ?? (index === 0 ? "front" : "side")}
onChange={(event) =>
handleImageRoleChange(
@@ -630,7 +675,7 @@ export function StudioWorldScreen() {
<label className="block">
<span className="mb-1.5 block text-xs font-medium text-foreground">Project name</span>
<input
className="ui-input w-full"
className={STUDIO_INPUT_CLASS}
value={name}
onChange={(event) => setName(event.target.value)}
placeholder="Studio Prototype"
@@ -639,7 +684,7 @@ export function StudioWorldScreen() {
<label className="block">
<span className="mb-1.5 block text-xs font-medium text-foreground">Generation brief</span>
<textarea
className="ui-input min-h-36 w-full resize-y"
className={STUDIO_TEXTAREA_CLASS}
value={prompt}
onChange={(event) => setPrompt(event.target.value)}
placeholder="Describe the world, hero assets, camera mood, and export intent."
@@ -649,7 +694,7 @@ export function StudioWorldScreen() {
<label className="block">
<span className="mb-1.5 block text-xs font-medium text-foreground">Generation backend</span>
<select
className="ui-input w-full"
className={STUDIO_SELECT_CLASS}
value={provider}
onChange={(event) => setProvider(event.target.value as StudioWorldGenerationProvider)}
>
@@ -665,7 +710,7 @@ export function StudioWorldScreen() {
<label className="block">
<span className="mb-1.5 block text-xs font-medium text-foreground">Worker strategy</span>
<select
className="ui-input w-full"
className={STUDIO_SELECT_CLASS}
value={workerAdapter}
onChange={(event) => setWorkerAdapter(event.target.value as StudioWorkerAdapterKind)}
>
@@ -675,7 +720,7 @@ export function StudioWorldScreen() {
</label>
<label className="block">
<span className="mb-1.5 block text-xs font-medium text-foreground">Style</span>
<select className="ui-input w-full" value={style} onChange={(event) => setStyle(event.target.value as StudioWorldStyle)}>
<select className={STUDIO_SELECT_CLASS} value={style} onChange={(event) => setStyle(event.target.value as StudioWorldStyle)}>
{STYLE_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
@@ -685,7 +730,7 @@ export function StudioWorldScreen() {
</label>
<label className="block">
<span className="mb-1.5 block text-xs font-medium text-foreground">Scale</span>
<select className="ui-input w-full" value={scale} onChange={(event) => setScale(event.target.value as StudioWorldScale)}>
<select className={STUDIO_SELECT_CLASS} value={scale} onChange={(event) => setScale(event.target.value as StudioWorldScale)}>
{SCALE_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
@@ -695,7 +740,7 @@ export function StudioWorldScreen() {
</label>
<label className="block">
<span className="mb-1.5 block text-xs font-medium text-foreground">Focus</span>
<select className="ui-input w-full" value={focus} onChange={(event) => setFocus(event.target.value as StudioWorldFocus)}>
<select className={STUDIO_SELECT_CLASS} value={focus} onChange={(event) => setFocus(event.target.value as StudioWorldFocus)}>
{FOCUS_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
@@ -706,7 +751,7 @@ export function StudioWorldScreen() {
<label className="block">
<span className="mb-1.5 block text-xs font-medium text-foreground">Seed</span>
<input
className="ui-input w-full"
className={STUDIO_INPUT_CLASS}
value={seed}
onChange={(event) => setSeed(event.target.value)}
placeholder="Optional deterministic seed"
@@ -777,6 +822,10 @@ export function StudioWorldScreen() {
</div>
</div>
</div>
<StudioWorldTaskLogCard
key={selectedProject.externalModel?.taskId ?? selectedProject.id}
project={selectedProject}
/>
</div>
) : (
<div className="flex h-full items-center justify-center rounded-2xl border border-dashed border-border/70 text-sm text-muted-foreground">
@@ -0,0 +1,162 @@
"use client";
import { useEffect, useState } from "react";
import type { StudioProjectRecord } from "@/lib/studio-world/types";
type StudioWorldLogResponse = {
taskLog?: string;
error?: string;
};
const shouldPollExternalModel = (externalModel?: StudioProjectRecord["externalModel"] | null) =>
externalModel?.status === "pending" ||
externalModel?.status === "in_progress" ||
(externalModel?.status === "completed" && !externalModel.glbUrl?.trim());
const fetchProjectTaskLog = async (projectId: string) => {
const response = await fetch(
`/api/studio-world?action=task-log&projectId=${encodeURIComponent(projectId)}`,
{ cache: "no-store" },
);
const body = (await response.json()) as StudioWorldLogResponse;
if (!response.ok) {
throw new Error(body.error || "Failed to load task log.");
}
return typeof body.taskLog === "string" ? body.taskLog : "";
};
const formatTimestamp = (value: string) => {
const parsed = Date.parse(value);
if (!Number.isFinite(parsed)) return value;
return new Intl.DateTimeFormat(undefined, {
dateStyle: "medium",
timeStyle: "short",
}).format(parsed);
};
const useStudioTaskLog = (project: StudioProjectRecord) => {
const [taskLog, setTaskLog] = useState("");
const [taskLogError, setTaskLogError] = useState<string | null>(null);
const [taskLogUpdatedAt, setTaskLogUpdatedAt] = useState<string | null>(null);
const taskId = project.externalModel?.taskId ?? null;
const shouldPollTaskLog = shouldPollExternalModel(project.externalModel);
useEffect(() => {
if (!project.id || !taskId) return;
let cancelled = false;
const pollTaskLog = async () => {
try {
const nextTaskLog = await fetchProjectTaskLog(project.id);
if (cancelled) return;
setTaskLog(nextTaskLog);
setTaskLogError(null);
setTaskLogUpdatedAt(new Date().toISOString());
} catch (loadError) {
if (cancelled) return;
setTaskLogError(
loadError instanceof Error ? loadError.message : "Failed to load task log.",
);
}
};
void pollTaskLog();
if (!shouldPollTaskLog) {
return () => {
cancelled = true;
};
}
const intervalId = window.setInterval(() => {
void pollTaskLog();
}, 4000);
return () => {
cancelled = true;
window.clearInterval(intervalId);
};
}, [project.id, shouldPollTaskLog, taskId]);
return {
taskLog,
taskLogError,
taskLogUpdatedAt,
};
};
const StudioWorldTaskLogBody = (props: {
project: StudioProjectRecord;
taskLog: string;
taskLogError: string | null;
taskLogUpdatedAt: string | null;
}) => {
if (!props.project.externalModel) {
return (
<div className="text-sm text-muted-foreground">
Start an image-to-3D generation to see stage-by-stage backend logs here.
</div>
);
}
if (props.taskLogError) {
return <div className="text-sm text-destructive">{props.taskLogError}</div>;
}
if (!props.taskLog.trim()) {
return (
<div className="space-y-1 text-sm text-muted-foreground">
<div>No backend log lines have arrived yet.</div>
<div>
Status: <span className="font-medium text-foreground">{props.project.externalModel.status}</span>
{" "}at{" "}
<span className="font-medium text-foreground">{props.project.externalModel.progress}%</span>.
</div>
<div className="font-mono text-[11px] text-muted-foreground">
Task id: {props.project.externalModel.taskId}
</div>
<div className="text-xs text-muted-foreground">
If this status and percentage do not change for a while, the job is likely stuck.
</div>
{props.taskLogUpdatedAt ? (
<div className="text-xs text-muted-foreground">
Last checked {formatTimestamp(props.taskLogUpdatedAt)}.
</div>
) : null}
</div>
);
}
return (
<pre className="max-h-64 overflow-auto whitespace-pre-wrap break-words font-mono text-[11px] leading-5 text-muted-foreground">
{props.taskLog.trim()}
</pre>
);
};
export function StudioWorldTaskLogCard({ project }: { project: StudioProjectRecord }) {
const { taskLog, taskLogError, taskLogUpdatedAt } = useStudioTaskLog(project);
return (
<div className="ui-card min-h-0 p-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<div>
<div className="font-mono text-[10px] uppercase tracking-[0.16em] text-muted-foreground">
Live task log
</div>
<div className="mt-1 text-xs text-muted-foreground">
{project.externalModel
? `Streaming worker debug output for ${project.externalModel.status}.`
: "No self-hosted AI task has been created for this project yet."}
</div>
</div>
{taskLogUpdatedAt ? (
<div className="font-mono text-[10px] uppercase tracking-[0.14em] text-muted-foreground">
Updated {formatTimestamp(taskLogUpdatedAt)}
</div>
) : null}
</div>
<div className="mt-3 rounded-xl border border-border/60 bg-black/25 p-3">
<StudioWorldTaskLogBody
project={project}
taskLog={taskLog}
taskLogError={taskLogError}
taskLogUpdatedAt={taskLogUpdatedAt}
/>
</div>
</div>
);
}
+33
View File
@@ -29,6 +29,7 @@ export type StudioAiTaskRecord = {
height: number | null;
palette: string[];
taskErrorMessage: string | null;
usingTestMode?: boolean;
createdAt: string;
updatedAt: string;
};
@@ -54,6 +55,11 @@ type SelfHostedTaskResponse = {
task_error?: {
message?: string;
};
using_test_mode?: boolean;
};
type SelfHostedTaskDebugLogResponse = {
log?: string;
};
const SELF_HOSTED_API_BASE_URL = "http://127.0.0.1:3333/openapi/v1";
@@ -278,11 +284,38 @@ export const getSelfHostedImageTo3dTask = async (
? body.palette.filter((entry): entry is string => typeof entry === "string")
: [],
taskErrorMessage: body.task_error?.message?.trim() || null,
usingTestMode:
typeof body.using_test_mode === "boolean" ? body.using_test_mode : undefined,
createdAt: now,
updatedAt: now,
};
};
export const getSelfHostedImageTo3dTaskDebugLog = async (taskId: string): Promise<string> => {
const { baseUrl, apiKey } = resolveSelfHostedProviderConfig();
const response = await fetch(`${baseUrl}/image-to-3d/${encodeURIComponent(taskId)}/debug-log`, {
headers: {
...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
},
cache: "no-store",
});
if (response.status === 404) {
return "";
}
const rawBody = await response.text();
let body: SelfHostedTaskDebugLogResponse = {};
try {
body = JSON.parse(rawBody) as SelfHostedTaskDebugLogResponse;
} catch {
body = {};
}
if (!response.ok) {
const diagnostic = rawBody.trim() || `${response.status} ${response.statusText}`;
throw new Error(`Failed to fetch self-hosted provider task log. ${diagnostic}`);
}
return typeof body.log === "string" ? body.log : "";
};
export const waitForSelfHostedImageTo3dTask = async (params: {
taskId: string;
timeoutMs?: number;
+6 -2
View File
@@ -198,7 +198,10 @@ const normalizeStore = (value: unknown): StudioProjectsStore => {
: [],
errorMessage:
asString(entry.externalModel.errorMessage, "").trim() || null,
usingTestMode: entry.externalModel.usingTestMode === true,
usingTestMode:
typeof entry.externalModel.usingTestMode === "boolean"
? entry.externalModel.usingTestMode
: undefined,
} satisfies StudioExternalModelRecord)
: null,
};
@@ -414,7 +417,8 @@ export const createStudioPendingProject = (params: {
palette: [],
textureUrls: [],
errorMessage: null,
usingTestMode: params.usingTestMode === true,
usingTestMode:
typeof params.usingTestMode === "boolean" ? params.usingTestMode : undefined,
},
};
store.projects = [project, ...store.projects].sort((left, right) =>
+155 -2
View File
@@ -1,10 +1,18 @@
import fs from "node:fs";
import http from "node:http";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
const makeTempDir = (name: string) => fs.mkdtempSync(path.join(os.tmpdir(), `${name}-`));
const restoreEnv = (name: string, value: string | undefined) => {
if (typeof value === "string") {
process.env[name] = value;
return;
}
delete process.env[name];
};
const ONE_BY_ONE_PNG = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAAElEQVR4nGNgAAAAAgAB5SfUogAAAABJRU5ErkJggg==",
@@ -14,11 +22,19 @@ const ONE_BY_ONE_PNG = Buffer.from(
describe("studio AI worker contract", () => {
const priorStateDir = process.env.OPENCLAW_STATE_DIR;
const priorPort = process.env.CLAW3D_STUDIO_PROVIDER_PORT;
const priorWorkerMode = process.env.CLAW3D_STUDIO_WORKER_MODE;
const priorUpstreamUrl = process.env.CLAW3D_STUDIO_UPSTREAM_PROVIDER_URL;
const priorUpstreamApiKey = process.env.CLAW3D_STUDIO_UPSTREAM_PROVIDER_API_KEY;
const priorPublicBaseUrl = process.env.CLAW3D_STUDIO_PROVIDER_PUBLIC_URL;
let tempDir: string | null = null;
afterEach(() => {
process.env.OPENCLAW_STATE_DIR = priorStateDir;
process.env.CLAW3D_STUDIO_PROVIDER_PORT = priorPort;
restoreEnv("OPENCLAW_STATE_DIR", priorStateDir);
restoreEnv("CLAW3D_STUDIO_PROVIDER_PORT", priorPort);
restoreEnv("CLAW3D_STUDIO_WORKER_MODE", priorWorkerMode);
restoreEnv("CLAW3D_STUDIO_UPSTREAM_PROVIDER_URL", priorUpstreamUrl);
restoreEnv("CLAW3D_STUDIO_UPSTREAM_PROVIDER_API_KEY", priorUpstreamApiKey);
restoreEnv("CLAW3D_STUDIO_PROVIDER_PUBLIC_URL", priorPublicBaseUrl);
if (tempDir) {
fs.rmSync(tempDir, { recursive: true, force: true });
tempDir = null;
@@ -111,4 +127,141 @@ describe("studio AI worker contract", () => {
await worker.close();
}
});
it("delegates to an upstream provider and serves downloaded artifacts", async () => {
tempDir = makeTempDir("studio-ai-worker-upstream");
process.env.OPENCLAW_STATE_DIR = tempDir;
process.env.CLAW3D_STUDIO_PROVIDER_PORT = "3346";
process.env.CLAW3D_STUDIO_WORKER_MODE = "upstream_openapi";
process.env.CLAW3D_STUDIO_UPSTREAM_PROVIDER_URL = "http://127.0.0.1:4455/openapi/v1";
let pollCount = 0;
const upstreamServer = http.createServer(async (req, res) => {
const url = new URL(req.url || "/", "http://127.0.0.1:4455");
const pathname = url.pathname;
if (req.method === "POST" && pathname === "/openapi/v1/image-to-3d") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ result: "provider-task-123" }));
return;
}
if (req.method === "GET" && pathname === "/openapi/v1/image-to-3d/provider-task-123") {
pollCount += 1;
const status = pollCount >= 2 ? "SUCCEEDED" : "IN_PROGRESS";
res.writeHead(200, { "Content-Type": "application/json" });
res.end(
JSON.stringify({
id: "provider-task-123",
adapter_id: "heightfield-relief",
status,
progress: status === "SUCCEEDED" ? 100 : 45,
model_urls:
status === "SUCCEEDED"
? { glb: "http://127.0.0.1:4455/files/model.glb" }
: {},
thumbnail_url:
status === "SUCCEEDED"
? "http://127.0.0.1:4455/files/thumbnail.png"
: "",
depth_preview_url:
status === "SUCCEEDED" ? "http://127.0.0.1:4455/files/depth.png" : "",
normal_preview_url:
status === "SUCCEEDED" ? "http://127.0.0.1:4455/files/normal.png" : "",
width: 768,
height: 1024,
palette: ["#111111", "#222222", "#333333", "#444444"],
}),
);
return;
}
if (req.method === "GET" && pathname === "/files/model.glb") {
res.writeHead(200, { "Content-Type": "model/gltf-binary" });
res.end(Buffer.from("glTFprovider-model"));
return;
}
if (req.method === "GET" && pathname === "/files/thumbnail.png") {
res.writeHead(200, { "Content-Type": "image/png" });
res.end(ONE_BY_ONE_PNG);
return;
}
if (req.method === "GET" && pathname === "/files/depth.png") {
res.writeHead(200, { "Content-Type": "image/png" });
res.end(ONE_BY_ONE_PNG);
return;
}
if (req.method === "GET" && pathname === "/files/normal.png") {
res.writeHead(200, { "Content-Type": "image/png" });
res.end(ONE_BY_ONE_PNG);
return;
}
res.writeHead(404, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "Not found" }));
});
await new Promise<void>((resolve, reject) => {
upstreamServer.once("error", reject);
upstreamServer.listen(4455, "127.0.0.1", () => resolve());
});
const { createStudioAiWorkerServer } = await import("../../server/studio-ai-worker.js");
const worker = createStudioAiWorkerServer({
host: "127.0.0.1",
port: 3346,
});
await worker.start();
try {
const createResponse = await fetch("http://127.0.0.1:3346/openapi/v1/image-to-3d", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
image_url: `data:image/png;base64,${ONE_BY_ONE_PNG.toString("base64")}`,
model_type: "standard",
adapter_id: "portrait-volume",
texture_prompt: "person likeness",
}),
});
expect(createResponse.status).toBe(200);
const createBody = (await createResponse.json()) as { result?: string };
const taskId = createBody.result ?? "";
expect(taskId.length).toBeGreaterThan(0);
let taskBody: {
status?: string;
adapter_id?: string;
model_urls?: { glb?: string };
thumbnail_url?: string;
depth_preview_url?: string;
normal_preview_url?: string;
width?: number;
height?: number;
palette?: string[];
} = {};
for (let attempt = 0; attempt < 12; attempt += 1) {
await new Promise((resolve) => setTimeout(resolve, 250));
const taskResponse = await fetch(`http://127.0.0.1:3346/openapi/v1/image-to-3d/${taskId}`);
expect(taskResponse.status).toBe(200);
taskBody = (await taskResponse.json()) as typeof taskBody;
if (taskBody.status === "SUCCEEDED") break;
}
expect(taskBody.status).toBe("SUCCEEDED");
expect(taskBody.adapter_id).toBe("heightfield_relief");
expect(taskBody.model_urls?.glb).toMatch(/model\.glb$/);
expect(taskBody.thumbnail_url).toMatch(/thumbnail\.png$/);
expect(taskBody.depth_preview_url).toMatch(/depth\.png$/);
expect(taskBody.normal_preview_url).toMatch(/normal\.png$/);
expect(taskBody.width).toBe(768);
expect(taskBody.height).toBe(1024);
expect(taskBody.palette).toEqual(["#111111", "#222222", "#333333", "#444444"]);
const modelResponse = await fetch(taskBody.model_urls!.glb!);
expect(modelResponse.status).toBe(200);
expect(modelResponse.headers.get("content-type")).toBe("model/gltf-binary");
const modelBuffer = Buffer.from(await modelResponse.arrayBuffer());
expect(modelBuffer.toString("utf8")).toBe("glTFprovider-model");
} finally {
await worker.close();
await new Promise<void>((resolve) => upstreamServer.close(() => resolve()));
}
});
});
+77 -6
View File
@@ -6,21 +6,32 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { DELETE, GET, POST } from "@/app/api/studio-world/route";
const makeTempDir = (name: string) => fs.mkdtempSync(path.join(os.tmpdir(), `${name}-`));
const restoreEnv = (name: string, value: string | undefined) => {
if (typeof value === "string") {
process.env[name] = value;
return;
}
delete process.env[name];
};
describe("studio world route", () => {
const priorStateDir = process.env.OPENCLAW_STATE_DIR;
const priorMeshyApiKey = process.env.MESHY_API_KEY;
const priorRealAi = process.env.CLAW3D_STUDIO_ENABLE_REAL_AI;
const priorProviderUrl = process.env.CLAW3D_STUDIO_PROVIDER_URL;
let tempDir: string | null = null;
beforeEach(() => {
vi.restoreAllMocks();
delete process.env.CLAW3D_STUDIO_PROVIDER_URL;
delete process.env.CLAW3D_STUDIO_ENABLE_REAL_AI;
});
afterEach(() => {
process.env.OPENCLAW_STATE_DIR = priorStateDir;
process.env.MESHY_API_KEY = priorMeshyApiKey;
process.env.CLAW3D_STUDIO_ENABLE_REAL_AI = priorRealAi;
restoreEnv("OPENCLAW_STATE_DIR", priorStateDir);
restoreEnv("MESHY_API_KEY", priorMeshyApiKey);
restoreEnv("CLAW3D_STUDIO_ENABLE_REAL_AI", priorRealAi);
restoreEnv("CLAW3D_STUDIO_PROVIDER_URL", priorProviderUrl);
if (tempDir) {
fs.rmSync(tempDir, { recursive: true, force: true });
tempDir = null;
@@ -180,7 +191,7 @@ describe("studio world route", () => {
(asset) => asset.kind === "avatar_head",
),
).toBe(true);
});
}, 15000);
it("creates an image-guided mesh project", async () => {
tempDir = makeTempDir("studio-world-image-mesh-route");
@@ -246,7 +257,7 @@ describe("studio world route", () => {
(asset) => asset.id === "mesh_panel",
),
).toBe(true);
});
}, 15000);
it("submits a real AI image-to-3D task when a self-hosted provider is configured", async () => {
tempDir = makeTempDir("studio-world-self-hosted-route");
@@ -338,6 +349,66 @@ describe("studio world route", () => {
expect(body.project?.externalModel?.normalPreviewUrl).toBeNull();
expect(body.providerAvailability?.provider).toBe("self_hosted");
expect(body.providerAvailability?.available).toBe(true);
}, 15000);
it("falls back to local generation when self-hosted provider is configured but real AI is disabled", async () => {
tempDir = makeTempDir("studio-world-self-hosted-fallback-route");
process.env.OPENCLAW_STATE_DIR = tempDir;
process.env.CLAW3D_STUDIO_PROVIDER_URL = "http://provider.test/openapi/v1";
process.env.CLAW3D_STUDIO_ENABLE_REAL_AI = "false";
const fetchSpy = vi.fn(async () => {
throw new Error("Provider should not be called when real AI is disabled.");
});
globalThis.fetch = fetchSpy as typeof fetch;
const response = await POST({
text: async () =>
JSON.stringify({
action: "generate",
input: {
name: "Fallback Test",
prompt: "Fallback local generation when provider is disabled.",
style: "stylized",
scale: "medium",
focus: "assets",
provider: "self_hosted",
imageMode: "mesh",
sourceImage: {
id: "source_123",
fileName: "fallback.png",
mimeType: "image/png",
sizeBytes: 1,
width: 1,
height: 1,
uploadedAt: new Date().toISOString(),
dataUrl:
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAAElEQVR4nGNgAAAAAgAB5SfUogAAAABJRU5ErkJggg==",
palette: ["#111111", "#222222", "#333333", "#444444"],
role: "front",
},
},
}),
} as unknown as Request);
const body = (await response.json()) as {
project?: {
provider?: string;
mode?: string;
latestJob?: { status?: string; providerTaskId?: string | null };
};
providerAvailability?: { provider?: string; available?: boolean; configured?: boolean };
};
expect(response.status).toBe(200);
expect(body.project?.provider).toBe("self_hosted");
expect(body.project?.mode).toBe("image_mesh");
expect(body.project?.latestJob?.status).toBe("completed");
expect(body.project?.latestJob?.providerTaskId ?? null).toBeNull();
expect(body.providerAvailability?.provider).toBe("self_hosted");
expect(body.providerAvailability?.available).toBe(false);
expect(body.providerAvailability?.configured).toBe(true);
expect(fetchSpy).not.toHaveBeenCalled();
});
it("creates a multi-image mesh project request payload", async () => {
@@ -404,5 +475,5 @@ describe("studio world route", () => {
expect(projectResponse.status).toBe(200);
expect(body.project?.sceneDraft?.mode).toBe("image_mesh");
expect(body.project?.sourceImages?.length).toBe(2);
});
}, 15000);
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 864 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.
Binary file not shown.