mirror of
https://github.com/iamlukethedev/Claw3D.git
synced 2026-08-14 00:58:04 +00:00
Studio
This commit is contained in:
Executable
+149
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
Generated
-11
@@ -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",
|
||||
|
||||
@@ -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.
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.6 MiB |
@@ -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);
|
||||
});
|
||||
@@ -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]);
|
||||
Binary file not shown.
@@ -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
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.
Reference in New Issue
Block a user