mirror of
https://github.com/iamlukethedev/Claw3D.git
synced 2026-07-30 03:02:37 +00:00
Add image-guided studio avatar generation
Co-authored-by: Luke The Dev <iamlukethedev@users.noreply.github.com>
This commit is contained in:
co-authored by
Luke The Dev
parent
e556a7bb6e
commit
43e9f0a75a
@@ -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"
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
};
|
||||
@@ -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,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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user