Add image-guided studio avatar generation

Co-authored-by: Luke The Dev <iamlukethedev@users.noreply.github.com>
This commit is contained in:
Cursor Agent
2026-04-18 16:48:45 +00:00
co-authored by Luke The Dev
parent e556a7bb6e
commit 43e9f0a75a
9 changed files with 858 additions and 14 deletions
+87 -2
View File
@@ -12,6 +12,7 @@ import {
import { buildOfficeMapFromStudioProject } from "@/lib/studio-world/office";
import {
createStudioProject,
createStudioSourceImage,
deleteStudioProject,
getStudioProject,
listStudioProjects,
@@ -27,6 +28,7 @@ export const runtime = "nodejs";
const WORKSPACE_ID = "default";
const OFFICE_ID = "studio-world";
const MAX_IMAGE_UPLOAD_BYTES = 8 * 1024 * 1024;
const isRecord = (value: unknown): value is Record<string, unknown> =>
Boolean(value && typeof value === "object" && !Array.isArray(value));
@@ -49,17 +51,43 @@ const parseGenerationInput = (value: unknown): StudioGenerationInput | null => {
if (!isRecord(value)) return null;
const name = asString(value.name) || "Untitled Studio World";
const prompt = asString(value.prompt);
if (!prompt) return null;
const sourceImage = isRecord(value.sourceImage)
? {
id: asString(value.sourceImage.id),
fileName: asString(value.sourceImage.fileName),
mimeType: asString(value.sourceImage.mimeType),
width:
typeof value.sourceImage.width === "number" && Number.isFinite(value.sourceImage.width)
? value.sourceImage.width
: 0,
height:
typeof value.sourceImage.height === "number" && Number.isFinite(value.sourceImage.height)
? value.sourceImage.height
: 0,
sizeBytes:
typeof value.sourceImage.sizeBytes === "number" &&
Number.isFinite(value.sourceImage.sizeBytes)
? value.sourceImage.sizeBytes
: 0,
storagePath: asString(value.sourceImage.storagePath),
dataUrl: asString(value.sourceImage.dataUrl),
palette: Array.isArray(value.sourceImage.palette)
? value.sourceImage.palette.filter((entry): entry is string => typeof entry === "string")
: [],
}
: null;
if (!prompt && !sourceImage) return null;
const rawSeed = value.seed;
const seed =
typeof rawSeed === "number" && Number.isFinite(rawSeed) ? rawSeed : null;
return {
name,
prompt,
prompt: prompt || "Image-guided avatar generation",
style: parseStyle(value.style),
scale: parseScale(value.scale),
focus: parseFocus(value.focus),
seed,
sourceImage,
};
};
@@ -164,6 +192,63 @@ export async function GET(request: Request) {
export async function POST(request: Request) {
try {
const contentType =
typeof request.headers?.get === "function"
? request.headers.get("content-type") ?? ""
: "";
if (contentType.includes("multipart/form-data")) {
const contentLengthHeader = request.headers.get("content-length");
if (contentLengthHeader !== null) {
const contentLength = Number(contentLengthHeader);
if (!Number.isNaN(contentLength) && contentLength > MAX_IMAGE_UPLOAD_BYTES + 2048) {
return NextResponse.json(
{
error: `Image upload exceeds the ${MAX_IMAGE_UPLOAD_BYTES} byte limit.`,
},
{ status: 413 },
);
}
}
const formData = await request.formData();
const image = formData.get("image");
if (
image === null ||
typeof image !== "object" ||
typeof (image as File).arrayBuffer !== "function"
) {
return NextResponse.json({ error: "image file is required." }, { status: 400 });
}
const imageFile = image as File;
const mimeType = imageFile.type.trim().toLowerCase();
if (mimeType !== "image/png" && mimeType !== "image/jpeg" && mimeType !== "image/webp") {
return NextResponse.json(
{ error: "Only PNG, JPEG, and WEBP uploads are supported." },
{ status: 400 },
);
}
const arrayBuffer = await imageFile.arrayBuffer();
if (arrayBuffer.byteLength <= 0) {
return NextResponse.json({ error: "Image upload is empty." }, { status: 400 });
}
if (arrayBuffer.byteLength > MAX_IMAGE_UPLOAD_BYTES) {
return NextResponse.json(
{
error: `Image upload exceeds the ${MAX_IMAGE_UPLOAD_BYTES} byte limit.`,
},
{ status: 413 },
);
}
const requestedFileName = asString(formData.get("fileName"));
const sourceImage = createStudioSourceImage({
fileName: requestedFileName || imageFile.name || "studio-reference",
mimeType,
buffer: Buffer.from(arrayBuffer),
});
return NextResponse.json(
{ sourceImage },
{ headers: { "Cache-Control": "no-store" } },
);
}
const rawBody = await request.text();
if (!rawBody.trim()) {
return NextResponse.json({ error: "Invalid request payload." }, { status: 400 });
@@ -1,12 +1,18 @@
"use client";
import Image from "next/image";
import { Environment, OrbitControls } from "@react-three/drei";
import { Canvas, useFrame } from "@react-three/fiber";
import { useMemo, useRef } from "react";
import * as THREE from "three";
import type { Group } from "three";
import type { StudioWorldAssetDraft, StudioWorldDraft } from "@/lib/studio-world/types";
import type {
StudioProjectRecord,
StudioSourceImageRecord,
StudioWorldAssetDraft,
StudioWorldDraft,
} from "@/lib/studio-world/types";
import { buildAssetGeometry, buildAssetMaterial, buildGlowMaterial } from "@/features/studio-world/preview/scene-utils";
type AssetMeshProps = {
@@ -105,9 +111,15 @@ const SceneContents = ({ sceneDraft }: { sceneDraft: StudioWorldDraft }) => {
type StudioWorldPreviewProps = {
sceneDraft: StudioWorldDraft;
referenceImage?: StudioSourceImageRecord | null;
project?: Pick<StudioProjectRecord, "mode"> | null;
};
export function StudioWorldPreview({ sceneDraft }: StudioWorldPreviewProps) {
export function StudioWorldPreview({
sceneDraft,
referenceImage = null,
project = null,
}: StudioWorldPreviewProps) {
return (
<div className="relative h-full min-h-[360px] w-full overflow-hidden rounded-2xl border border-border/60 bg-black/70">
<Canvas
@@ -130,6 +142,27 @@ export function StudioWorldPreview({ sceneDraft }: StudioWorldPreviewProps) {
{sceneDraft.assets.length} assets
</div>
</div>
{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
src={referenceImage.dataUrl}
alt={referenceImage.fileName}
width={144}
height={144}
className="h-28 w-full object-cover"
unoptimized
/>
<div className="px-3 py-2">
<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 text-[11px] text-white/60">
{project?.mode === "image_avatar" ? "Image-guided avatar proxy." : "Reference image attached."}
</div>
</div>
</div>
) : null}
</div>
);
}
@@ -41,6 +41,24 @@ export const buildAssetGeometry = (kind: StudioWorldAssetDraft["kind"]) => {
if (kind === "portal") {
return new THREE.TorusGeometry(0.6, 0.16, 16, 32);
}
if (kind === "avatar_head") {
return new THREE.SphereGeometry(0.9, 24, 24);
}
if (kind === "avatar_hair") {
return new THREE.ConeGeometry(1, 1.4, 14);
}
if (kind === "avatar_torso") {
return new THREE.BoxGeometry(1, 1, 1);
}
if (kind === "avatar_limb") {
return new THREE.CapsuleGeometry(0.22, 1.1, 8, 14);
}
if (kind === "avatar_accessory") {
return new THREE.BoxGeometry(1, 0.28, 0.12);
}
if (kind === "avatar_orb") {
return new THREE.OctahedronGeometry(0.82, 0);
}
return new THREE.BoxGeometry(1, 1, 1);
};
@@ -59,6 +77,72 @@ const createAssetMesh = (asset: StudioWorldAssetDraft) => {
return mesh;
}
if (asset.kind === "avatar_head") {
const mesh = new THREE.Mesh(buildAssetGeometry(asset.kind), material);
mesh.scale.set(asset.scale[0], asset.scale[1], asset.scale[2]);
return mesh;
}
if (asset.kind === "avatar_hair") {
const mesh = new THREE.Mesh(buildAssetGeometry(asset.kind), material);
mesh.scale.set(asset.scale[0], asset.scale[1], asset.scale[2]);
return mesh;
}
if (asset.kind === "avatar_torso") {
const mesh = new THREE.Mesh(buildAssetGeometry(asset.kind), material);
mesh.scale.set(asset.scale[0], asset.scale[1], asset.scale[2]);
return mesh;
}
if (asset.kind === "avatar_limb") {
const mesh = new THREE.Mesh(buildAssetGeometry(asset.kind), material);
mesh.scale.set(asset.scale[0], asset.scale[1], asset.scale[2]);
return mesh;
}
if (asset.kind === "avatar_accessory") {
const group = new THREE.Group();
const bar = new THREE.Mesh(buildAssetGeometry(asset.kind), material);
const leftLens = new THREE.Mesh(
new THREE.CylinderGeometry(0.26, 0.26, 0.1, 22),
new THREE.MeshStandardMaterial({
color: asset.color,
emissive: asset.emissive ?? asset.color,
emissiveIntensity: 1,
roughness: 0.15,
metalness: 0.42,
}),
);
const rightLens = leftLens.clone();
leftLens.rotation.z = Math.PI / 2;
rightLens.rotation.z = Math.PI / 2;
leftLens.position.x = -0.36;
rightLens.position.x = 0.36;
group.add(bar, leftLens, rightLens);
group.scale.set(asset.scale[0], asset.scale[1], asset.scale[2]);
return group;
}
if (asset.kind === "avatar_orb") {
const group = new THREE.Group();
const core = new THREE.Mesh(buildAssetGeometry(asset.kind), material);
const halo = new THREE.Mesh(
new THREE.TorusGeometry(0.75, 0.06, 12, 30),
new THREE.MeshStandardMaterial({
color: asset.emissive ?? asset.color,
emissive: asset.emissive ?? asset.color,
emissiveIntensity: 1.2,
roughness: 0.12,
metalness: 0.24,
}),
);
halo.rotation.x = Math.PI / 3;
group.add(core, halo);
group.scale.set(asset.scale[0], asset.scale[1], asset.scale[2]);
return group;
}
if (asset.kind === "arch") {
const group = new THREE.Group();
const legGeometry = new THREE.BoxGeometry(0.24, 1, 0.24);
@@ -1,12 +1,14 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import Image from "next/image";
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 type {
StudioProjectRecord,
StudioSourceImageRecord,
StudioWorldFocus,
StudioWorldScale,
StudioWorldStyle,
@@ -39,6 +41,7 @@ type ExportManifestResponse = {
type StudioWorldResponse = {
projects?: StudioProjectRecord[];
project?: StudioProjectRecord;
sourceImage?: StudioSourceImageRecord;
office?: {
workspaceId: string;
officeId: string;
@@ -86,6 +89,9 @@ export function StudioWorldScreen() {
const [scale, setScale] = useState<StudioWorldScale>("medium");
const [focus, setFocus] = useState<StudioWorldFocus>("world");
const [seed, setSeed] = useState("");
const [uploadedImage, setUploadedImage] = useState<StudioSourceImageRecord | null>(null);
const [uploadingImage, setUploadingImage] = useState(false);
const uploadInputRef = useRef<HTMLInputElement | null>(null);
const selectedProject = useMemo(
() => projects.find((entry) => entry.id === selectedProjectId) ?? projects[0] ?? null,
@@ -115,9 +121,46 @@ export function StudioWorldScreen() {
void refreshProjects();
}, [refreshProjects]);
useEffect(() => {
if (!selectedProject) return;
setUploadedImage(selectedProject.sourceImages[0] ?? null);
}, [selectedProject]);
const handleImageUpload = async (file: File) => {
setUploadingImage(true);
setStatusLine("Uploading reference image.");
try {
const formData = new FormData();
formData.append("image", file);
const response = await fetch("/api/studio-world", {
method: "POST",
body: formData,
});
const body = (await response.json()) as StudioWorldResponse;
if (!response.ok || !body.sourceImage) {
throw new Error(body.error || "Failed to upload image.");
}
setUploadedImage(body.sourceImage);
setError(null);
setStatusLine(`Uploaded ${body.sourceImage.fileName}.`);
} catch (uploadError) {
setError(uploadError instanceof Error ? uploadError.message : "Failed to upload image.");
setStatusLine(null);
} finally {
setUploadingImage(false);
if (uploadInputRef.current) {
uploadInputRef.current.value = "";
}
}
};
const handleGenerate = async () => {
setBusy(true);
setStatusLine("Generating clean-room studio draft.");
setStatusLine(
uploadedImage
? "Generating image-guided 3D proxy."
: "Generating clean-room studio draft.",
);
try {
const parsedSeed = seed.trim() ? Number.parseInt(seed.trim(), 10) : null;
const response = await fetch("/api/studio-world", {
@@ -132,6 +175,7 @@ export function StudioWorldScreen() {
scale,
focus,
seed: Number.isFinite(parsedSeed) ? parsedSeed : null,
sourceImage: uploadedImage,
},
}),
});
@@ -271,12 +315,74 @@ export function StudioWorldScreen() {
<div className="font-mono text-[10px] uppercase tracking-[0.2em] text-muted-foreground">
Claw3D Studio
</div>
<h1 className="mt-2 text-2xl font-semibold text-foreground">Generate 3D worlds, assets, and motion.</h1>
<h1 className="mt-2 text-2xl font-semibold text-foreground">Generate 3D worlds, assets, motion, and image-guided avatars.</h1>
<p className="mt-2 text-sm text-muted-foreground">
This clean-room workflow is inspired by modern world-model pipelines, but implemented as Claw3D-native
tooling and data contracts.
This clean-room workflow is inspired by tools like Meshy, but implemented as Claw3D-native tooling and
data contracts.
</p>
<div className="mt-5 space-y-4">
<div className="rounded-2xl border border-border/60 bg-surface-1/35 p-3">
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-sm font-semibold text-foreground">Reference image</div>
<div className="mt-1 text-xs text-muted-foreground">
Upload a PNG, JPEG, or WEBP to generate a stylized 3D avatar proxy inspired by that image.
</div>
</div>
<button
type="button"
className="ui-btn-secondary px-3 py-1.5 text-xs"
onClick={() => uploadInputRef.current?.click()}
disabled={busy || uploadingImage}
>
{uploadingImage ? "Uploading..." : uploadedImage ? "Replace image" : "Upload image"}
</button>
</div>
<input
ref={uploadInputRef}
type="file"
accept="image/png,image/jpeg,image/webp"
className="hidden"
onChange={(event) => {
const file = event.target.files?.[0];
if (!file) return;
void handleImageUpload(file);
}}
/>
{uploadedImage ? (
<div className="mt-3 grid gap-3 sm:grid-cols-[120px_minmax(0,1fr)]">
<div className="overflow-hidden rounded-xl border border-border/60 bg-black/10">
<Image
src={uploadedImage.dataUrl}
alt={uploadedImage.fileName}
width={120}
height={120}
className="h-[120px] w-full object-cover"
unoptimized
/>
</div>
<div className="min-w-0">
<div className="text-sm font-medium text-foreground">{uploadedImage.fileName}</div>
<div className="mt-1 text-xs text-muted-foreground">
{uploadedImage.width} x {uploadedImage.height} {Math.round(uploadedImage.sizeBytes / 1024)} KB
</div>
<div className="mt-3 flex flex-wrap gap-2">
{uploadedImage.palette.map((color) => (
<div key={color} className="flex items-center gap-2 rounded-full border border-border/60 px-2 py-1">
<span
className="inline-block h-3 w-3 rounded-full border border-black/15"
style={{ backgroundColor: color }}
/>
<span className="font-mono text-[10px] uppercase tracking-[0.12em] text-muted-foreground">
{color}
</span>
</div>
))}
</div>
</div>
</div>
) : null}
</div>
<label className="block">
<span className="mb-1.5 block text-xs font-medium text-foreground">Project name</span>
<input
@@ -337,12 +443,31 @@ export function StudioWorldScreen() {
</label>
</div>
<div className="flex flex-wrap gap-2">
<button type="button" className="ui-btn-primary px-4 py-2 text-sm" onClick={() => void handleGenerate()} disabled={busy}>
{busy ? "Working..." : "Generate scene"}
<button
type="button"
className="ui-btn-primary px-4 py-2 text-sm"
onClick={() => void handleGenerate()}
disabled={busy || uploadingImage}
>
{busy
? "Working..."
: uploadedImage
? "Generate from image"
: "Generate scene"}
</button>
<button type="button" className="ui-btn-secondary px-4 py-2 text-sm" onClick={() => void refreshProjects()} disabled={busy}>
<button type="button" className="ui-btn-secondary px-4 py-2 text-sm" onClick={() => void refreshProjects()} disabled={busy || uploadingImage}>
Refresh library
</button>
{uploadedImage ? (
<button
type="button"
className="ui-btn-secondary px-4 py-2 text-sm"
onClick={() => setUploadedImage(null)}
disabled={busy || uploadingImage}
>
Clear image
</button>
) : null}
</div>
</div>
</section>
@@ -434,11 +559,32 @@ export function StudioWorldScreen() {
<span className="rounded-full bg-muted px-2 py-1 font-mono text-[10px] uppercase tracking-[0.14em] text-muted-foreground">
{project.scale}
</span>
<span className="rounded-full bg-muted px-2 py-1 font-mono text-[10px] uppercase tracking-[0.14em] text-muted-foreground">
{project.mode === "image_avatar" ? "image avatar" : "text scene"}
</span>
</div>
</button>
<div className="mt-3 text-[11px] text-muted-foreground">
Updated {formatTimestamp(project.updatedAt)}.
</div>
{project.sourceImages[0] ? (
<div className="mt-3 flex items-center gap-3 rounded-xl border border-border/60 bg-surface-1/35 p-2">
<Image
src={project.sourceImages[0].dataUrl}
alt={project.sourceImages[0].fileName}
width={52}
height={52}
className="h-13 w-13 rounded-lg object-cover"
unoptimized
/>
<div className="min-w-0 text-xs text-muted-foreground">
<div className="truncate text-foreground">{project.sourceImages[0].fileName}</div>
<div>
{project.sourceImages[0].width} x {project.sourceImages[0].height}
</div>
</div>
</div>
) : null}
<div className="mt-3 flex flex-wrap gap-2">
<button
type="button"
+175 -1
View File
@@ -9,6 +9,7 @@ import type {
StudioWorldScale,
StudioWorldStyle,
} from "@/lib/studio-world/types";
import { buildAvatarImageNotes } from "@/lib/studio-world/image-analysis";
const hashString = (value: string) => {
let hash = 0;
@@ -116,6 +117,16 @@ const scaleToBounds = (scale: StudioWorldScale) => {
return { width: 40, depth: 40, assetCount: 18 };
};
const avatarScaleToBounds = (scale: StudioWorldScale) => {
if (scale === "small") {
return { width: 14, depth: 14 };
}
if (scale === "large") {
return { width: 24, depth: 24 };
}
return { width: 18, depth: 18 };
};
const detectBiome = (prompt: string, style: StudioWorldStyle): StudioWorldBiome => {
const normalized = prompt.toLowerCase();
if (/\bforest|wood|tree|grove|nature\b/.test(normalized)) return "forest";
@@ -229,10 +240,172 @@ export const resolveGenerationSeed = (input: StudioGenerationInput) => {
if (typeof input.seed === "number" && Number.isFinite(input.seed)) {
return Math.floor(Math.abs(input.seed));
}
return hashString(`${input.name}:${input.prompt}:${input.style}:${input.scale}:${input.focus}`);
return hashString(
`${input.name}:${input.prompt}:${input.style}:${input.scale}:${input.focus}:${input.sourceImage?.id ?? ""}`,
);
};
const buildAvatarDraft = (input: StudioGenerationInput): StudioWorldDraft => {
const sourceImage = input.sourceImage;
if (!sourceImage) {
throw new Error("Image-guided avatar generation requires a source image.");
}
const seed = resolveGenerationSeed(input);
const bounds = avatarScaleToBounds(input.scale);
const imageNotes = buildAvatarImageNotes(sourceImage);
const palette: StudioWorldPalette = {
ground: imageNotes.backdrop,
structure: imageNotes.outfitMain,
prop: imageNotes.outfitTrim,
accent: imageNotes.accessory,
glow: imageNotes.accessory,
fog: imageNotes.outfitTrim,
sky: imageNotes.backdrop,
};
const notes = [
`Image-guided avatar proxy built from ${sourceImage.fileName}.`,
`Palette sampled from the uploaded reference image.`,
`Generated with ${input.style} styling and seed ${seed}.`,
];
const ratio = sourceImage.height > 0 ? sourceImage.width / sourceImage.height : 1;
const shoulderWidth = clamp(1.8 + ratio * 0.8, 1.8, 3.4);
const headScale = clamp(1.35 + (ratio < 0.9 ? 0.2 : 0), 1.3, 1.7);
const assets: StudioWorldAssetDraft[] = [
{
id: "avatar_base",
name: "Avatar base",
kind: "platform",
position: [0, -0.2, 0],
scale: [6.5, 0.4, 6.5],
rotationY: 0,
color: palette.ground,
emissive: null,
animation: "none",
},
{
id: "avatar_torso",
name: "Avatar torso",
kind: "avatar_torso",
position: [0, 2.4, 0],
scale: [shoulderWidth, 3.2, 1.2],
rotationY: 0,
color: imageNotes.outfitMain,
emissive: null,
animation: input.focus === "animation" ? "bob" : "none",
},
{
id: "avatar_head",
name: "Avatar head",
kind: "avatar_head",
position: [0, 5.4, 0],
scale: [headScale, 1.75, 1.4],
rotationY: 0,
color: imageNotes.skinLike,
emissive: null,
animation: "none",
},
{
id: "avatar_hair",
name: "Avatar hair",
kind: "avatar_hair",
position: [0, 6.6, -0.06],
scale: [1.65, 1.45, 1.1],
rotationY: 0,
color: imageNotes.hairLike,
emissive: null,
animation: input.focus === "animation" ? "pulse" : "none",
},
{
id: "avatar_left_arm",
name: "Avatar left arm",
kind: "avatar_limb",
position: [-2.05, 2.8, 0],
scale: [0.48, 2.2, 0.48],
rotationY: 0.15,
color: imageNotes.outfitTrim,
emissive: null,
animation: "none",
},
{
id: "avatar_right_arm",
name: "Avatar right arm",
kind: "avatar_limb",
position: [2.05, 2.8, 0],
scale: [0.48, 2.2, 0.48],
rotationY: -0.15,
color: imageNotes.outfitTrim,
emissive: null,
animation: "none",
},
{
id: "avatar_left_leg",
name: "Avatar left leg",
kind: "avatar_limb",
position: [-0.65, 0.8, 0],
scale: [0.58, 2.2, 0.58],
rotationY: 0,
color: imageNotes.outfitMain,
emissive: null,
animation: "none",
},
{
id: "avatar_right_leg",
name: "Avatar right leg",
kind: "avatar_limb",
position: [0.65, 0.8, 0],
scale: [0.58, 2.2, 0.58],
rotationY: 0,
color: imageNotes.outfitMain,
emissive: null,
animation: "none",
},
{
id: "avatar_glasses",
name: "Avatar glasses",
kind: "avatar_accessory",
position: [0, 5.45, 1.02],
scale: [1.65, 0.5, 0.18],
rotationY: 0,
color: imageNotes.accessory,
emissive: imageNotes.accessory,
animation: "pulse",
},
{
id: "avatar_companion",
name: "Avatar companion",
kind: "avatar_orb",
position: [4.2, 4.8, -1.2],
scale: [1.05, 1.05, 1.05],
rotationY: 0,
color: imageNotes.outfitTrim,
emissive: imageNotes.accessory,
animation: "spin",
},
];
return {
mode: "image_avatar",
biome: "creative_plaza",
palette,
worldBounds: {
width: bounds.width,
depth: bounds.depth,
},
camera: {
position: [8.8, 6.8, 10.8],
target: [0, 3.2, 0],
},
promptSummary: input.prompt.trim() || `Image-guided avatar from ${sourceImage.fileName}`,
notes,
assets,
};
};
export const buildStudioWorldDraft = (input: StudioGenerationInput): StudioWorldDraft => {
if (input.sourceImage) {
return buildAvatarDraft(input);
}
const seed = resolveGenerationSeed(input);
const random = createSeededRandom(seed);
const biome = detectBiome(input.prompt, input.style);
@@ -254,6 +427,7 @@ export const buildStudioWorldDraft = (input: StudioGenerationInput): StudioWorld
];
return {
mode: "text_scene",
biome,
palette,
worldBounds: {
+152
View File
@@ -0,0 +1,152 @@
import type { StudioSourceImageRecord } from "@/lib/studio-world/types";
const byteToHex = (value: number) => value.toString(16).padStart(2, "0");
const rgbToHex = (red: number, green: number, blue: number) =>
`#${byteToHex(red)}${byteToHex(green)}${byteToHex(blue)}`;
const clamp = (value: number, min: number, max: number) =>
Math.min(max, Math.max(min, value));
type PngInfo = {
width: number;
height: number;
};
const readUInt32 = (buffer: Buffer, offset: number) => buffer.readUInt32BE(offset);
const maybeReadPngInfo = (buffer: Buffer): PngInfo | null => {
if (buffer.length < 24) return null;
const signature = buffer.subarray(0, 8);
const expected = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
if (!signature.equals(expected)) return null;
return {
width: readUInt32(buffer, 16),
height: readUInt32(buffer, 20),
};
};
const maybeReadJpegInfo = (buffer: Buffer): PngInfo | null => {
if (buffer.length < 4 || buffer[0] !== 0xff || buffer[1] !== 0xd8) {
return null;
}
let offset = 2;
while (offset + 9 < buffer.length) {
if (buffer[offset] !== 0xff) {
offset += 1;
continue;
}
const marker = buffer[offset + 1];
const segmentLength = buffer.readUInt16BE(offset + 2);
if (segmentLength < 2 || offset + 2 + segmentLength > buffer.length) {
return null;
}
const isStartOfFrame =
marker === 0xc0 ||
marker === 0xc1 ||
marker === 0xc2 ||
marker === 0xc3 ||
marker === 0xc5 ||
marker === 0xc6 ||
marker === 0xc7 ||
marker === 0xc9 ||
marker === 0xca ||
marker === 0xcb ||
marker === 0xcd ||
marker === 0xce ||
marker === 0xcf;
if (isStartOfFrame) {
return {
height: buffer.readUInt16BE(offset + 5),
width: buffer.readUInt16BE(offset + 7),
};
}
offset += 2 + segmentLength;
}
return null;
};
const hashBucket = (red: number, green: number, blue: number) =>
`${red >> 4}:${green >> 4}:${blue >> 4}`;
const brighten = (hexColor: string, amount: number) => {
const red = Number.parseInt(hexColor.slice(1, 3), 16);
const green = Number.parseInt(hexColor.slice(3, 5), 16);
const blue = Number.parseInt(hexColor.slice(5, 7), 16);
return rgbToHex(
clamp(Math.round(red + (255 - red) * amount), 0, 255),
clamp(Math.round(green + (255 - green) * amount), 0, 255),
clamp(Math.round(blue + (255 - blue) * amount), 0, 255),
);
};
const darken = (hexColor: string, amount: number) => {
const red = Number.parseInt(hexColor.slice(1, 3), 16);
const green = Number.parseInt(hexColor.slice(3, 5), 16);
const blue = Number.parseInt(hexColor.slice(5, 7), 16);
return rgbToHex(
clamp(Math.round(red * (1 - amount)), 0, 255),
clamp(Math.round(green * (1 - amount)), 0, 255),
clamp(Math.round(blue * (1 - amount)), 0, 255),
);
};
export const resolveImageSize = (buffer: Buffer): PngInfo => {
const png = maybeReadPngInfo(buffer);
if (png) return png;
const jpeg = maybeReadJpegInfo(buffer);
if (jpeg) return jpeg;
return { width: 1024, height: 1024 };
};
export const buildImagePaletteFromBuffer = (buffer: Buffer) => {
const buckets = new Map<string, { count: number; red: number; green: number; blue: number }>();
const step = Math.max(3, Math.floor(buffer.length / 1800));
for (let index = 0; index + 2 < buffer.length; index += step) {
const red = buffer[index] ?? 0;
const green = buffer[index + 1] ?? 0;
const blue = buffer[index + 2] ?? 0;
const key = hashBucket(red, green, blue);
const current = buckets.get(key);
if (current) {
current.count += 1;
current.red += red;
current.green += green;
current.blue += blue;
} else {
buckets.set(key, { count: 1, red, green, blue });
}
}
const dominant = Array.from(buckets.values())
.sort((left, right) => right.count - left.count)
.slice(0, 4)
.map((bucket) =>
rgbToHex(
Math.round(bucket.red / bucket.count),
Math.round(bucket.green / bucket.count),
Math.round(bucket.blue / bucket.count),
),
);
if (dominant.length === 0) {
return ["#8b5cf6", "#ec4899", "#22d3ee", "#111827"];
}
while (dominant.length < 4) {
dominant.push(brighten(dominant[dominant.length - 1] ?? "#8b5cf6", 0.12));
}
return dominant;
};
export const buildAvatarImageNotes = (image: StudioSourceImageRecord) => {
const primary = image.palette[0] ?? "#8b5cf6";
const secondary = image.palette[1] ?? brighten(primary, 0.18);
return {
skinLike: brighten(primary, 0.35),
hairLike: darken(primary, 0.72),
outfitMain: secondary,
outfitTrim: brighten(secondary, 0.18),
accessory: image.palette[2] ?? brighten(primary, 0.45),
backdrop: image.palette[3] ?? darken(primary, 0.5),
};
};
+67
View File
@@ -6,15 +6,21 @@ import {
buildStudioWorldDraft,
resolveGenerationSeed,
} from "@/lib/studio-world/generator";
import {
buildImagePaletteFromBuffer,
resolveImageSize,
} from "@/lib/studio-world/image-analysis";
import type {
StudioGenerationInput,
StudioGenerationJobRecord,
StudioProjectRecord,
StudioProjectsStore,
StudioSourceImageRecord,
} from "@/lib/studio-world/types";
const STORE_DIR = "claw3d";
const STORE_FILE = "studio-world-projects.json";
const IMAGE_DIR = "studio-world-images";
const STORE_VERSION = 1;
const ensureDirectory = (dirPath: string) => {
@@ -30,6 +36,13 @@ const resolveStorePath = () => {
return path.join(dir, STORE_FILE);
};
const resolveImageDirectory = () => {
const stateDir = resolveStateDir();
const dir = path.join(stateDir, STORE_DIR, IMAGE_DIR);
ensureDirectory(dir);
return dir;
};
const defaultStore = (): StudioProjectsStore => ({
schemaVersion: STORE_VERSION,
projects: [],
@@ -79,6 +92,7 @@ const normalizeStore = (value: unknown): StudioProjectsStore => {
? entry.focus
: "world",
seed: asNumber(entry.seed, 0),
mode: entry.mode === "image_avatar" ? "image_avatar" : "text_scene",
createdAt,
updatedAt,
latestJob: {
@@ -89,7 +103,24 @@ const normalizeStore = (value: unknown): StudioProjectsStore => {
finishedAt: asString(entry.latestJob.finishedAt, updatedAt),
summary: asString(entry.latestJob.summary, ""),
assetCount: asNumber(entry.latestJob.assetCount, 0),
mode: entry.latestJob.mode === "image_avatar" ? "image_avatar" : "text_scene",
},
sourceImages: Array.isArray(entry.sourceImages)
? entry.sourceImages.filter((image): image is StudioSourceImageRecord => {
if (!isRecord(image)) return false;
return (
typeof image.id === "string" &&
typeof image.fileName === "string" &&
typeof image.mimeType === "string" &&
typeof image.width === "number" &&
typeof image.height === "number" &&
typeof image.sizeBytes === "number" &&
typeof image.storagePath === "string" &&
typeof image.dataUrl === "string" &&
Array.isArray(image.palette)
);
})
: [],
sceneDraft: entry.sceneDraft as StudioProjectRecord["sceneDraft"],
};
})
@@ -143,17 +174,50 @@ const createProjectId = (name: string) =>
const createJobId = () => `job-${Date.now().toString(36)}`;
const createImageId = () => `image-${Date.now().toString(36)}`;
const buildSummary = (params: {
input: StudioGenerationInput;
assetCount: number;
}) =>
`${params.input.style} ${params.input.focus} draft with ${params.assetCount} assets for ${params.input.scale} scope.`;
const extFromMimeType = (mimeType: string) => {
if (mimeType === "image/png") return "png";
if (mimeType === "image/jpeg") return "jpg";
if (mimeType === "image/webp") return "webp";
return "bin";
};
export const listStudioProjects = () => readStore().projects;
export const getStudioProject = (projectId: string) =>
readStore().projects.find((entry) => entry.id === projectId) ?? null;
export const createStudioSourceImage = (params: {
fileName: string;
mimeType: string;
buffer: Buffer;
}): StudioSourceImageRecord => {
const imageId = createImageId();
const extension = extFromMimeType(params.mimeType);
const storagePath = path.join(resolveImageDirectory(), `${imageId}.${extension}`);
fs.writeFileSync(storagePath, params.buffer);
const { width, height } = resolveImageSize(params.buffer);
const palette = buildImagePaletteFromBuffer(params.buffer);
return {
id: imageId,
fileName: params.fileName,
mimeType: params.mimeType,
width,
height,
sizeBytes: params.buffer.byteLength,
storagePath,
dataUrl: `data:${params.mimeType};base64,${params.buffer.toString("base64")}`,
palette,
};
};
export const createStudioProject = (input: StudioGenerationInput) => {
const store = readStore();
const createdAt = new Date().toISOString();
@@ -167,6 +231,7 @@ export const createStudioProject = (input: StudioGenerationInput) => {
finishedAt: createdAt,
summary: buildSummary({ input, assetCount: sceneDraft.assets.length }),
assetCount: sceneDraft.assets.length,
mode: sceneDraft.mode,
};
const project: StudioProjectRecord = {
id: createProjectId(input.name),
@@ -176,9 +241,11 @@ export const createStudioProject = (input: StudioGenerationInput) => {
scale: input.scale,
focus: input.focus,
seed,
mode: sceneDraft.mode,
createdAt,
updatedAt: createdAt,
latestJob,
sourceImages: input.sourceImage ? [input.sourceImage] : [],
sceneDraft,
};
store.projects = [project, ...store.projects].sort((left, right) =>
+26 -1
View File
@@ -26,7 +26,27 @@ export type StudioWorldAssetKind =
| "rock"
| "beacon"
| "crate"
| "portal";
| "portal"
| "avatar_head"
| "avatar_hair"
| "avatar_torso"
| "avatar_limb"
| "avatar_accessory"
| "avatar_orb";
export type StudioWorldGenerationMode = "text_scene" | "image_avatar";
export type StudioSourceImageRecord = {
id: string;
fileName: string;
mimeType: string;
width: number;
height: number;
sizeBytes: number;
storagePath: string;
dataUrl: string;
palette: string[];
};
export type StudioWorldAssetDraft = {
id: string;
@@ -51,6 +71,7 @@ export type StudioWorldPalette = {
};
export type StudioWorldDraft = {
mode: StudioWorldGenerationMode;
biome: StudioWorldBiome;
palette: StudioWorldPalette;
worldBounds: {
@@ -73,6 +94,7 @@ export type StudioGenerationInput = {
scale: StudioWorldScale;
focus: StudioWorldFocus;
seed?: number | null;
sourceImage?: StudioSourceImageRecord | null;
};
export type StudioGenerationJobRecord = {
@@ -83,6 +105,7 @@ export type StudioGenerationJobRecord = {
finishedAt: string;
summary: string;
assetCount: number;
mode: StudioWorldGenerationMode;
};
export type StudioProjectRecord = {
@@ -93,9 +116,11 @@ export type StudioProjectRecord = {
scale: StudioWorldScale;
focus: StudioWorldFocus;
seed: number;
mode: StudioWorldGenerationMode;
createdAt: string;
updatedAt: string;
latestJob: StudioGenerationJobRecord;
sourceImages: StudioSourceImageRecord[];
sceneDraft: StudioWorldDraft;
};
+78
View File
@@ -96,4 +96,82 @@ describe("studio world route", () => {
expect(deleteResponse.status).toBe(200);
expect(deleteBody.deleted).toBe(true);
});
it("uploads an image and creates an image-guided avatar project", async () => {
tempDir = makeTempDir("studio-world-image-route");
process.env.OPENCLAW_STATE_DIR = tempDir;
const pngBytes = Uint8Array.from([
137, 80, 78, 71, 13, 10, 26, 10,
0, 0, 0, 13, 73, 72, 68, 82,
0, 0, 0, 1, 0, 0, 0, 1,
8, 6, 0, 0, 0, 31, 21, 196, 137,
0, 0, 0, 1, 73, 68, 65, 84,
120, 156, 99, 0, 0, 0, 2, 0, 1,
229, 39, 212, 162, 0, 0, 0, 0,
73, 69, 78, 68, 174, 66, 96, 130,
]);
const formData = new FormData();
formData.append(
"image",
new File([pngBytes], "avatar.png", { type: "image/png" }),
);
const uploadResponse = await POST(
new Request("http://localhost/api/studio-world", {
method: "POST",
body: formData,
}),
);
const uploadBody = (await uploadResponse.json()) as {
sourceImage?: {
id?: string;
fileName?: string;
width?: number;
height?: number;
palette?: string[];
};
};
expect(uploadResponse.status).toBe(200);
expect((uploadBody.sourceImage?.fileName ?? "").length).toBeGreaterThan(0);
expect(uploadBody.sourceImage?.id).toBeTruthy();
expect(uploadBody.sourceImage?.width).toBe(1);
expect(uploadBody.sourceImage?.height).toBe(1);
expect((uploadBody.sourceImage?.palette ?? []).length).toBeGreaterThan(0);
const imageProjectResponse = await POST({
text: async () =>
JSON.stringify({
action: "generate",
input: {
name: "Avatar Test",
prompt: "Stylized cyber avatar inspired by the uploaded reference.",
style: "stylized",
scale: "medium",
focus: "assets",
sourceImage: uploadBody.sourceImage,
},
}),
} as unknown as Request);
const imageProjectBody = (await imageProjectResponse.json()) as {
project?: {
mode?: string;
sourceImages?: Array<{ fileName?: string }>;
sceneDraft?: { mode?: string; assets?: Array<{ kind?: string }> };
};
};
expect(imageProjectResponse.status).toBe(200);
expect(imageProjectBody.project?.mode).toBe("image_avatar");
expect((imageProjectBody.project?.sourceImages?.[0]?.fileName ?? "").length).toBeGreaterThan(0);
expect(imageProjectBody.project?.sceneDraft?.mode).toBe("image_avatar");
expect(
imageProjectBody.project?.sceneDraft?.assets?.some(
(asset) => asset.kind === "avatar_head",
),
).toBe(true);
});
});