mirror of
https://github.com/iamlukethedev/Claw3D.git
synced 2026-08-14 00:58:04 +00:00
Add Claw3D Studio clean-room workspace
Co-authored-by: Luke The Dev <iamlukethedev@users.noreply.github.com>
This commit is contained in:
co-authored by
Luke The Dev
parent
e59dcbe520
commit
7ae768c6a5
@@ -0,0 +1,229 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import {
|
||||
createEmptyOfficeMap,
|
||||
normalizeOfficeMap,
|
||||
} from "@/lib/office/schema";
|
||||
import {
|
||||
publishOfficeVersion,
|
||||
saveOfficeVersion,
|
||||
upsertOffice,
|
||||
} from "@/lib/office/store";
|
||||
import { buildOfficeMapFromStudioProject } from "@/lib/studio-world/office";
|
||||
import {
|
||||
createStudioProject,
|
||||
deleteStudioProject,
|
||||
getStudioProject,
|
||||
listStudioProjects,
|
||||
} from "@/lib/studio-world/store";
|
||||
import type {
|
||||
StudioGenerationInput,
|
||||
StudioWorldFocus,
|
||||
StudioWorldScale,
|
||||
StudioWorldStyle,
|
||||
} from "@/lib/studio-world/types";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const WORKSPACE_ID = "default";
|
||||
const OFFICE_ID = "studio-world";
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
Boolean(value && typeof value === "object" && !Array.isArray(value));
|
||||
|
||||
const asString = (value: unknown) =>
|
||||
typeof value === "string" ? value.trim() : "";
|
||||
|
||||
const parseStyle = (value: unknown): StudioWorldStyle =>
|
||||
value === "realistic" || value === "cinematic" || value === "low-poly"
|
||||
? value
|
||||
: "stylized";
|
||||
|
||||
const parseScale = (value: unknown): StudioWorldScale =>
|
||||
value === "small" || value === "large" ? value : "medium";
|
||||
|
||||
const parseFocus = (value: unknown): StudioWorldFocus =>
|
||||
value === "assets" || value === "animation" ? value : "world";
|
||||
|
||||
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 rawSeed = value.seed;
|
||||
const seed =
|
||||
typeof rawSeed === "number" && Number.isFinite(rawSeed) ? rawSeed : null;
|
||||
return {
|
||||
name,
|
||||
prompt,
|
||||
style: parseStyle(value.style),
|
||||
scale: parseScale(value.scale),
|
||||
focus: parseFocus(value.focus),
|
||||
seed,
|
||||
};
|
||||
};
|
||||
|
||||
const buildExportManifest = (projectId: string) => {
|
||||
const project = getStudioProject(projectId);
|
||||
if (!project) {
|
||||
throw new Error("Studio project not found.");
|
||||
}
|
||||
return {
|
||||
project: {
|
||||
id: project.id,
|
||||
name: project.name,
|
||||
prompt: project.prompt,
|
||||
style: project.style,
|
||||
scale: project.scale,
|
||||
focus: project.focus,
|
||||
seed: project.seed,
|
||||
updatedAt: project.updatedAt,
|
||||
},
|
||||
export: {
|
||||
format: "glb_manifest",
|
||||
generator: "claw3d-studio-clean-room-v1",
|
||||
summary: project.latestJob.summary,
|
||||
},
|
||||
sceneDraft: project.sceneDraft,
|
||||
};
|
||||
};
|
||||
|
||||
const applyProjectToOffice = (projectId: string) => {
|
||||
const project = getStudioProject(projectId);
|
||||
if (!project) {
|
||||
throw new Error("Studio project not found.");
|
||||
}
|
||||
upsertOffice({
|
||||
workspaceId: WORKSPACE_ID,
|
||||
officeId: OFFICE_ID,
|
||||
name: `Studio Export - ${project.name}`,
|
||||
});
|
||||
const officeVersionId = `studio-${Date.now().toString(36)}`;
|
||||
const fallback = createEmptyOfficeMap({
|
||||
workspaceId: WORKSPACE_ID,
|
||||
officeVersionId,
|
||||
width: 1200,
|
||||
height: 900,
|
||||
});
|
||||
const map = normalizeOfficeMap(
|
||||
buildOfficeMapFromStudioProject(project, officeVersionId),
|
||||
fallback,
|
||||
);
|
||||
const version = saveOfficeVersion({
|
||||
workspaceId: WORKSPACE_ID,
|
||||
officeId: OFFICE_ID,
|
||||
versionId: officeVersionId,
|
||||
createdBy: "studio-world",
|
||||
notes: `Generated from studio project ${project.id}.`,
|
||||
map,
|
||||
});
|
||||
const published = publishOfficeVersion({
|
||||
workspaceId: WORKSPACE_ID,
|
||||
officeId: OFFICE_ID,
|
||||
officeVersionId: version.id,
|
||||
publishedBy: "studio-world",
|
||||
});
|
||||
return {
|
||||
office: {
|
||||
workspaceId: WORKSPACE_ID,
|
||||
officeId: OFFICE_ID,
|
||||
officeVersionId: version.id,
|
||||
publishedAt: published.publishedAt,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const action = asString(url.searchParams.get("action"));
|
||||
const projectId = asString(url.searchParams.get("projectId"));
|
||||
if (action === "export") {
|
||||
if (!projectId) {
|
||||
return NextResponse.json(
|
||||
{ error: "projectId is required for export." },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ exportManifest: buildExportManifest(projectId) },
|
||||
{ headers: { "Cache-Control": "no-store" } },
|
||||
);
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ projects: listStudioProjects() },
|
||||
{ headers: { "Cache-Control": "no-store" } },
|
||||
);
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Failed to load studio world data.";
|
||||
console.error(message);
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const rawBody = await request.text();
|
||||
if (!rawBody.trim()) {
|
||||
return NextResponse.json({ error: "Invalid request payload." }, { status: 400 });
|
||||
}
|
||||
const body = JSON.parse(rawBody) as unknown;
|
||||
if (!isRecord(body)) {
|
||||
return NextResponse.json({ error: "Invalid request payload." }, { status: 400 });
|
||||
}
|
||||
const action = asString(body.action) || "generate";
|
||||
if (action === "generate") {
|
||||
const input = parseGenerationInput(body.input);
|
||||
if (!input) {
|
||||
return NextResponse.json(
|
||||
{ error: "Valid generation input is required." },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ project: createStudioProject(input) },
|
||||
{ headers: { "Cache-Control": "no-store" } },
|
||||
);
|
||||
}
|
||||
if (action === "apply_to_office") {
|
||||
const projectId = asString(body.projectId);
|
||||
if (!projectId) {
|
||||
return NextResponse.json(
|
||||
{ error: "projectId is required for office export." },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
return NextResponse.json(
|
||||
applyProjectToOffice(projectId),
|
||||
{ headers: { "Cache-Control": "no-store" } },
|
||||
);
|
||||
}
|
||||
return NextResponse.json({ error: "Unsupported studio world action." }, { status: 400 });
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Failed to handle studio world request.";
|
||||
console.error(message);
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request) {
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const projectId = asString(url.searchParams.get("projectId"));
|
||||
if (!projectId) {
|
||||
return NextResponse.json({ error: "projectId is required." }, { status: 400 });
|
||||
}
|
||||
const deleted = deleteStudioProject(projectId);
|
||||
return NextResponse.json(
|
||||
{ deleted },
|
||||
{ headers: { "Cache-Control": "no-store" } },
|
||||
);
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Failed to delete studio world project.";
|
||||
console.error(message);
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { StudioWorldScreen } from "@/features/studio-world/screens/StudioWorldScreen";
|
||||
|
||||
export default function StudioPage() {
|
||||
return <StudioWorldScreen />;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import Link from "next/link";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ThemeToggle } from "@/components/theme-toggle";
|
||||
import type { GatewayStatus } from "@/lib/gateway/GatewayClient";
|
||||
@@ -8,12 +9,14 @@ type HeaderBarProps = {
|
||||
status: GatewayStatus;
|
||||
onConnectionSettings: () => void;
|
||||
showConnectionSettings?: boolean;
|
||||
currentSection?: "office" | "studio";
|
||||
};
|
||||
|
||||
export const HeaderBar = ({
|
||||
status,
|
||||
onConnectionSettings,
|
||||
showConnectionSettings = true,
|
||||
currentSection,
|
||||
}: HeaderBarProps) => {
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -40,7 +43,31 @@ export const HeaderBar = ({
|
||||
<div className="ui-topbar relative z-[180]">
|
||||
<div className="grid h-10 grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center px-3 sm:px-4 md:px-5">
|
||||
<div aria-hidden="true" />
|
||||
<p className="truncate text-sm font-semibold tracking-[0.01em] text-foreground">Claw3D</p>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<p className="truncate text-sm font-semibold tracking-[0.01em] text-foreground">Claw3D</p>
|
||||
<div className="hidden items-center gap-1 rounded-full border border-border/70 bg-card/80 p-1 md:flex">
|
||||
<Link
|
||||
href="/office"
|
||||
className={`rounded-full px-3 py-1 font-mono text-[10px] font-semibold uppercase tracking-[0.14em] transition-colors ${
|
||||
currentSection === "office"
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "text-muted-foreground hover:bg-muted/60 hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
Office
|
||||
</Link>
|
||||
<Link
|
||||
href="/studio"
|
||||
className={`rounded-full px-3 py-1 font-mono text-[10px] font-semibold uppercase tracking-[0.14em] transition-colors ${
|
||||
currentSection === "studio"
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "text-muted-foreground hover:bg-muted/60 hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
Studio
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{status !== "disconnected" ? (
|
||||
<span
|
||||
|
||||
@@ -4199,6 +4199,22 @@ export function OfficeScreen({
|
||||
|
||||
return (
|
||||
<main className="relative h-full w-full overflow-hidden bg-black">
|
||||
<div className="pointer-events-none fixed left-4 top-4 z-40">
|
||||
<div className="pointer-events-auto flex items-center gap-2 rounded-full border border-cyan-400/25 bg-black/72 px-2 py-2 shadow-2xl backdrop-blur">
|
||||
<div className="pl-1 font-mono text-[10px] uppercase tracking-[0.18em] text-cyan-100/75">
|
||||
Claw3D Studio
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="ui-btn-secondary px-3 py-1.5 text-xs font-semibold tracking-[0.05em] text-foreground"
|
||||
onClick={() => {
|
||||
router.push("/studio");
|
||||
}}
|
||||
>
|
||||
Open
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{showGatewayLoadingOverlay ? (
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 z-40 flex items-center justify-center bg-[#120a05]/76"
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
"use client";
|
||||
|
||||
import { GLTFExporter } from "three/examples/jsm/exporters/GLTFExporter.js";
|
||||
|
||||
import { buildPreviewSceneGroup } from "@/features/studio-world/preview/scene-utils";
|
||||
import type { StudioProjectRecord } from "@/lib/studio-world/types";
|
||||
|
||||
const downloadBlob = (filename: string, blob: Blob) => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
link.click();
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 0);
|
||||
};
|
||||
|
||||
const sanitizeFilename = (value: string) =>
|
||||
value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 48) || "claw3d-studio-world";
|
||||
|
||||
export const exportStudioProjectGlb = async (project: StudioProjectRecord) => {
|
||||
const exporter = new GLTFExporter();
|
||||
const sceneGroup = buildPreviewSceneGroup(project.sceneDraft);
|
||||
sceneGroup.updateMatrixWorld(true);
|
||||
|
||||
const result = await new Promise<ArrayBuffer>((resolve, reject) => {
|
||||
exporter.parse(
|
||||
sceneGroup,
|
||||
(value) => {
|
||||
if (value instanceof ArrayBuffer) {
|
||||
resolve(value);
|
||||
return;
|
||||
}
|
||||
reject(new Error("GLB export did not return binary output."));
|
||||
},
|
||||
(error) => {
|
||||
reject(error instanceof Error ? error : new Error("Failed to export GLB."));
|
||||
},
|
||||
{
|
||||
binary: true,
|
||||
trs: false,
|
||||
onlyVisible: true,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
downloadBlob(
|
||||
`${sanitizeFilename(project.name)}.glb`,
|
||||
new Blob([result], { type: "model/gltf-binary" }),
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,135 @@
|
||||
"use client";
|
||||
|
||||
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 { buildAssetGeometry, buildAssetMaterial, buildGlowMaterial } from "@/features/studio-world/preview/scene-utils";
|
||||
|
||||
type AssetMeshProps = {
|
||||
asset: StudioWorldAssetDraft;
|
||||
};
|
||||
|
||||
const AssetMesh = ({ asset }: AssetMeshProps) => {
|
||||
const groupRef = useRef<Group>(null);
|
||||
const geometry = useMemo(() => buildAssetGeometry(asset.kind, asset.scale), [asset.kind, asset.scale]);
|
||||
const material = useMemo(
|
||||
() => buildAssetMaterial(asset.color, asset.emissive ?? null),
|
||||
[asset.color, asset.emissive],
|
||||
);
|
||||
const glowMaterial = useMemo(
|
||||
() => (asset.emissive ? buildGlowMaterial(asset.emissive) : null),
|
||||
[asset.emissive],
|
||||
);
|
||||
|
||||
useFrame(({ clock }) => {
|
||||
if (!groupRef.current) return;
|
||||
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);
|
||||
} else if (asset.animation === "pulse") {
|
||||
const scale = 1 + Math.sin(elapsed * 2.4 + z) * 0.06;
|
||||
groupRef.current.position.set(x, y, z);
|
||||
groupRef.current.scale.setScalar(scale);
|
||||
} else {
|
||||
groupRef.current.position.set(x, y, z);
|
||||
groupRef.current.scale.setScalar(1);
|
||||
}
|
||||
groupRef.current.rotation.y =
|
||||
asset.rotationY + (asset.animation === "spin" ? elapsed * 0.75 : 0);
|
||||
});
|
||||
|
||||
return (
|
||||
<group ref={groupRef} position={asset.position}>
|
||||
<mesh geometry={geometry} material={material} castShadow receiveShadow />
|
||||
{glowMaterial ? (
|
||||
<mesh
|
||||
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]} />
|
||||
</mesh>
|
||||
) : null}
|
||||
</group>
|
||||
);
|
||||
};
|
||||
|
||||
const SceneContents = ({ sceneDraft }: { sceneDraft: StudioWorldDraft }) => {
|
||||
const fogColor = sceneDraft.palette.fog;
|
||||
return (
|
||||
<>
|
||||
<color attach="background" args={[sceneDraft.palette.sky]} />
|
||||
<fog attach="fog" args={[fogColor, 18, 78]} />
|
||||
<ambientLight intensity={0.8} color="#f4f7ff" />
|
||||
<directionalLight
|
||||
castShadow
|
||||
position={[14, 18, 12]}
|
||||
intensity={1.1}
|
||||
color="#fff3dd"
|
||||
shadow-mapSize-width={2048}
|
||||
shadow-mapSize-height={2048}
|
||||
/>
|
||||
<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} />
|
||||
</mesh>
|
||||
<gridHelper
|
||||
args={[
|
||||
Math.max(sceneDraft.worldBounds.width, sceneDraft.worldBounds.depth) * 2,
|
||||
24,
|
||||
new THREE.Color(sceneDraft.palette.glow),
|
||||
new THREE.Color(sceneDraft.palette.structure),
|
||||
]}
|
||||
position={[0, 0.02, 0]}
|
||||
/>
|
||||
{sceneDraft.assets.map((asset) => (
|
||||
<AssetMesh key={asset.id} asset={asset} />
|
||||
))}
|
||||
<Environment preset="city" />
|
||||
<OrbitControls
|
||||
enablePan={false}
|
||||
minDistance={10}
|
||||
maxDistance={72}
|
||||
minPolarAngle={0.35}
|
||||
maxPolarAngle={1.35}
|
||||
target={sceneDraft.camera.target}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
type StudioWorldPreviewProps = {
|
||||
sceneDraft: StudioWorldDraft;
|
||||
};
|
||||
|
||||
export function StudioWorldPreview({ sceneDraft }: StudioWorldPreviewProps) {
|
||||
return (
|
||||
<div className="relative h-full min-h-[360px] w-full overflow-hidden rounded-2xl border border-border/60 bg-black/70">
|
||||
<Canvas
|
||||
shadows
|
||||
camera={{
|
||||
position: sceneDraft.camera.position,
|
||||
fov: 42,
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import * as THREE from "three";
|
||||
|
||||
import type { StudioWorldAssetDraft, StudioWorldDraft } from "@/lib/studio-world/types";
|
||||
|
||||
export const buildAssetMaterial = (
|
||||
color: string,
|
||||
emissive?: string | null,
|
||||
) =>
|
||||
new THREE.MeshStandardMaterial({
|
||||
color,
|
||||
emissive: emissive ?? "#000000",
|
||||
emissiveIntensity: emissive ? 0.85 : 0,
|
||||
roughness: 0.62,
|
||||
metalness: emissive ? 0.22 : 0.08,
|
||||
});
|
||||
|
||||
export const buildGlowMaterial = (color: string) =>
|
||||
new THREE.MeshStandardMaterial({
|
||||
color,
|
||||
emissive: color,
|
||||
emissiveIntensity: 1.2,
|
||||
transparent: true,
|
||||
opacity: 0.85,
|
||||
roughness: 0.18,
|
||||
metalness: 0.28,
|
||||
});
|
||||
|
||||
export const buildAssetGeometry = (kind: StudioWorldAssetDraft["kind"]) => {
|
||||
if (kind === "platform" || kind === "crate") {
|
||||
return new THREE.BoxGeometry(1, 1, 1);
|
||||
}
|
||||
if (kind === "tower" || kind === "beacon") {
|
||||
return new THREE.CylinderGeometry(0.5, 0.7, 1, 8);
|
||||
}
|
||||
if (kind === "rock") {
|
||||
return new THREE.DodecahedronGeometry(0.8, 0);
|
||||
}
|
||||
if (kind === "tree") {
|
||||
return new THREE.ConeGeometry(0.8, 1.4, 10);
|
||||
}
|
||||
if (kind === "portal") {
|
||||
return new THREE.TorusGeometry(0.6, 0.16, 16, 32);
|
||||
}
|
||||
return new THREE.BoxGeometry(1, 1, 1);
|
||||
};
|
||||
|
||||
const createAssetMesh = (asset: StudioWorldAssetDraft) => {
|
||||
const material = buildAssetMaterial(asset.color, asset.emissive ?? null);
|
||||
|
||||
if (asset.kind === "platform") {
|
||||
const mesh = new THREE.Mesh(buildAssetGeometry(asset.kind, asset.scale), material);
|
||||
mesh.scale.set(asset.scale[0], asset.scale[1], asset.scale[2]);
|
||||
return mesh;
|
||||
}
|
||||
|
||||
if (asset.kind === "tower") {
|
||||
const mesh = new THREE.Mesh(buildAssetGeometry(asset.kind, asset.scale), material);
|
||||
mesh.scale.set(asset.scale[0], asset.scale[1], asset.scale[2]);
|
||||
return mesh;
|
||||
}
|
||||
|
||||
if (asset.kind === "arch") {
|
||||
const group = new THREE.Group();
|
||||
const legGeometry = new THREE.BoxGeometry(0.24, 1, 0.24);
|
||||
const beamGeometry = new THREE.BoxGeometry(1.35, 0.22, 0.3);
|
||||
const leftLeg = new THREE.Mesh(legGeometry, material);
|
||||
const rightLeg = new THREE.Mesh(legGeometry, material);
|
||||
const beam = new THREE.Mesh(beamGeometry, material);
|
||||
leftLeg.position.set(-0.42, 0.5, 0);
|
||||
rightLeg.position.set(0.42, 0.5, 0);
|
||||
beam.position.set(0, 1.02, 0);
|
||||
group.add(leftLeg, rightLeg, beam);
|
||||
group.scale.set(asset.scale[0], asset.scale[1], asset.scale[2]);
|
||||
return group;
|
||||
}
|
||||
|
||||
if (asset.kind === "tree") {
|
||||
const group = new THREE.Group();
|
||||
const trunk = new THREE.Mesh(
|
||||
new THREE.CylinderGeometry(0.18, 0.2, 1, 8),
|
||||
new THREE.MeshStandardMaterial({
|
||||
color: "#6a4325",
|
||||
roughness: 0.86,
|
||||
metalness: 0.02,
|
||||
}),
|
||||
);
|
||||
const canopy = new THREE.Mesh(buildAssetGeometry(asset.kind, asset.scale), material);
|
||||
trunk.position.y = 0.5;
|
||||
canopy.position.y = 1.5;
|
||||
group.add(trunk, canopy);
|
||||
group.scale.set(asset.scale[0] * 0.55, asset.scale[1] * 0.65, asset.scale[2] * 0.55);
|
||||
return group;
|
||||
}
|
||||
|
||||
if (asset.kind === "rock") {
|
||||
const mesh = new THREE.Mesh(buildAssetGeometry(asset.kind, asset.scale), material);
|
||||
mesh.scale.set(asset.scale[0], asset.scale[1], asset.scale[2]);
|
||||
return mesh;
|
||||
}
|
||||
|
||||
if (asset.kind === "beacon") {
|
||||
const group = new THREE.Group();
|
||||
const base = new THREE.Mesh(buildAssetGeometry(asset.kind, asset.scale), material);
|
||||
const cap = new THREE.Mesh(
|
||||
new THREE.SphereGeometry(0.34, 18, 18),
|
||||
new THREE.MeshStandardMaterial({
|
||||
color: asset.emissive ?? asset.color,
|
||||
emissive: asset.emissive ?? asset.color,
|
||||
emissiveIntensity: 1.2,
|
||||
roughness: 0.18,
|
||||
metalness: 0.28,
|
||||
}),
|
||||
);
|
||||
base.position.y = 0.55;
|
||||
cap.position.y = 1.25;
|
||||
group.add(base, cap);
|
||||
group.scale.set(asset.scale[0], asset.scale[1], asset.scale[2]);
|
||||
return group;
|
||||
}
|
||||
|
||||
if (asset.kind === "crate") {
|
||||
const mesh = new THREE.Mesh(buildAssetGeometry(asset.kind, asset.scale), material);
|
||||
mesh.scale.set(asset.scale[0], asset.scale[1], asset.scale[2]);
|
||||
return mesh;
|
||||
}
|
||||
|
||||
const ring = new THREE.Mesh(
|
||||
buildAssetGeometry(asset.kind, asset.scale),
|
||||
new THREE.MeshStandardMaterial({
|
||||
color: asset.color,
|
||||
emissive: asset.emissive ?? asset.color,
|
||||
emissiveIntensity: 1.1,
|
||||
roughness: 0.18,
|
||||
metalness: 0.32,
|
||||
}),
|
||||
);
|
||||
ring.scale.set(asset.scale[0], asset.scale[1], asset.scale[2]);
|
||||
return ring;
|
||||
};
|
||||
|
||||
const applySharedMeshProperties = (root: THREE.Object3D) => {
|
||||
root.traverse((child) => {
|
||||
if ((child as THREE.Mesh).isMesh) {
|
||||
const mesh = child as THREE.Mesh;
|
||||
mesh.castShadow = true;
|
||||
mesh.receiveShadow = true;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const applyAnimationPose = (object: THREE.Object3D, asset: StudioWorldAssetDraft) => {
|
||||
if (asset.animation === "bob") {
|
||||
object.position.y += 0.45;
|
||||
} else if (asset.animation === "pulse") {
|
||||
object.scale.multiplyScalar(1.08);
|
||||
} else if (asset.animation === "spin") {
|
||||
object.rotation.z += Math.PI * 0.08;
|
||||
}
|
||||
};
|
||||
|
||||
export const buildPreviewSceneGroup = (draft: StudioWorldDraft) => {
|
||||
const root = new THREE.Group();
|
||||
root.name = "claw3d_studio_world";
|
||||
|
||||
const ground = new THREE.Mesh(
|
||||
new THREE.CylinderGeometry(
|
||||
Math.max(draft.worldBounds.width, draft.worldBounds.depth) * 0.62,
|
||||
Math.max(draft.worldBounds.width, draft.worldBounds.depth) * 0.7,
|
||||
0.8,
|
||||
36,
|
||||
),
|
||||
new THREE.MeshStandardMaterial({
|
||||
color: draft.palette.ground,
|
||||
roughness: 0.95,
|
||||
metalness: 0.02,
|
||||
}),
|
||||
);
|
||||
ground.position.y = -0.42;
|
||||
ground.receiveShadow = true;
|
||||
ground.name = "ground";
|
||||
root.add(ground);
|
||||
|
||||
for (const asset of draft.assets) {
|
||||
const node = createAssetMesh(asset);
|
||||
node.name = asset.id;
|
||||
node.position.set(asset.position[0], asset.position[1], asset.position[2]);
|
||||
node.rotation.y = asset.rotationY;
|
||||
applyAnimationPose(node, asset);
|
||||
applySharedMeshProperties(node);
|
||||
root.add(node);
|
||||
}
|
||||
|
||||
return root;
|
||||
};
|
||||
@@ -0,0 +1,491 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, 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,
|
||||
StudioWorldFocus,
|
||||
StudioWorldScale,
|
||||
StudioWorldStyle,
|
||||
} from "@/lib/studio-world/types";
|
||||
|
||||
const STYLE_OPTIONS: Array<{ value: StudioWorldStyle; label: string }> = [
|
||||
{ value: "stylized", label: "Stylized" },
|
||||
{ value: "realistic", label: "Realistic" },
|
||||
{ value: "cinematic", label: "Cinematic" },
|
||||
{ value: "low-poly", label: "Low-poly" },
|
||||
];
|
||||
|
||||
const SCALE_OPTIONS: Array<{ value: StudioWorldScale; label: string }> = [
|
||||
{ value: "small", label: "Small" },
|
||||
{ value: "medium", label: "Medium" },
|
||||
{ value: "large", label: "Large" },
|
||||
];
|
||||
|
||||
const FOCUS_OPTIONS: Array<{ value: StudioWorldFocus; label: string }> = [
|
||||
{ value: "world", label: "World" },
|
||||
{ value: "assets", label: "Assets" },
|
||||
{ value: "animation", label: "Animation" },
|
||||
];
|
||||
|
||||
type ExportManifestResponse = {
|
||||
exportManifest?: unknown;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
type StudioWorldResponse = {
|
||||
projects?: StudioProjectRecord[];
|
||||
project?: StudioProjectRecord;
|
||||
office?: {
|
||||
workspaceId: string;
|
||||
officeId: string;
|
||||
officeVersionId: string;
|
||||
publishedAt: string;
|
||||
};
|
||||
deleted?: boolean;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
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 downloadJson = (filename: string, data: unknown) => {
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], {
|
||||
type: "application/json",
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
export function StudioWorldScreen() {
|
||||
const [projects, setProjects] = useState<StudioProjectRecord[]>([]);
|
||||
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [statusLine, setStatusLine] = useState<string | null>(null);
|
||||
const [showConnectionPanel, setShowConnectionPanel] = useState(false);
|
||||
const [name, setName] = useState("Studio Prototype");
|
||||
const [prompt, setPrompt] = useState(
|
||||
"A stylized collaborative 3D studio district with hero props, modular landmarks, and export-ready assets.",
|
||||
);
|
||||
const [style, setStyle] = useState<StudioWorldStyle>("stylized");
|
||||
const [scale, setScale] = useState<StudioWorldScale>("medium");
|
||||
const [focus, setFocus] = useState<StudioWorldFocus>("world");
|
||||
const [seed, setSeed] = useState("");
|
||||
|
||||
const selectedProject = useMemo(
|
||||
() => projects.find((entry) => entry.id === selectedProjectId) ?? projects[0] ?? null,
|
||||
[projects, selectedProjectId],
|
||||
);
|
||||
|
||||
const refreshProjects = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch("/api/studio-world", { cache: "no-store" });
|
||||
const body = (await response.json()) as StudioWorldResponse;
|
||||
if (!response.ok) {
|
||||
throw new Error(body.error || "Failed to load studio projects.");
|
||||
}
|
||||
const nextProjects = body.projects ?? [];
|
||||
setProjects(nextProjects);
|
||||
setSelectedProjectId((current) => current ?? nextProjects[0]?.id ?? null);
|
||||
setError(null);
|
||||
} catch (loadError) {
|
||||
setError(loadError instanceof Error ? loadError.message : "Failed to load studio projects.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refreshProjects();
|
||||
}, [refreshProjects]);
|
||||
|
||||
const handleGenerate = async () => {
|
||||
setBusy(true);
|
||||
setStatusLine("Generating clean-room studio draft.");
|
||||
try {
|
||||
const parsedSeed = seed.trim() ? Number.parseInt(seed.trim(), 10) : null;
|
||||
const response = await fetch("/api/studio-world", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
action: "generate",
|
||||
input: {
|
||||
name,
|
||||
prompt,
|
||||
style,
|
||||
scale,
|
||||
focus,
|
||||
seed: Number.isFinite(parsedSeed) ? parsedSeed : null,
|
||||
},
|
||||
}),
|
||||
});
|
||||
const body = (await response.json()) as StudioWorldResponse;
|
||||
if (!response.ok || !body.project) {
|
||||
throw new Error(body.error || "Failed to generate studio project.");
|
||||
}
|
||||
setProjects((current) => [body.project!, ...current.filter((entry) => entry.id !== body.project!.id)]);
|
||||
setSelectedProjectId(body.project.id);
|
||||
setError(null);
|
||||
setStatusLine(`Generated ${body.project.name}.`);
|
||||
} catch (generationError) {
|
||||
setError(
|
||||
generationError instanceof Error
|
||||
? generationError.message
|
||||
: "Failed to generate studio project.",
|
||||
);
|
||||
setStatusLine(null);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (projectId: string) => {
|
||||
setBusy(true);
|
||||
setStatusLine("Deleting studio draft.");
|
||||
try {
|
||||
const response = await fetch(`/api/studio-world?projectId=${encodeURIComponent(projectId)}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
const body = (await response.json()) as StudioWorldResponse;
|
||||
if (!response.ok || !body.deleted) {
|
||||
throw new Error(body.error || "Failed to delete studio project.");
|
||||
}
|
||||
const nextProjects = projects.filter((entry) => entry.id !== projectId);
|
||||
setProjects(nextProjects);
|
||||
setSelectedProjectId(nextProjects[0]?.id ?? null);
|
||||
setError(null);
|
||||
setStatusLine("Studio draft deleted.");
|
||||
} catch (deleteError) {
|
||||
setError(deleteError instanceof Error ? deleteError.message : "Failed to delete studio project.");
|
||||
setStatusLine(null);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExportManifest = async (projectId: string) => {
|
||||
setBusy(true);
|
||||
setStatusLine("Preparing export manifest.");
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/studio-world?action=export&projectId=${encodeURIComponent(projectId)}`,
|
||||
{ cache: "no-store" },
|
||||
);
|
||||
const body = (await response.json()) as ExportManifestResponse;
|
||||
if (!response.ok || !body.exportManifest) {
|
||||
throw new Error(body.error || "Failed to export studio project.");
|
||||
}
|
||||
downloadJson(`${projectId}.glb.json`, body.exportManifest);
|
||||
setError(null);
|
||||
setStatusLine("Export manifest downloaded.");
|
||||
} catch (exportError) {
|
||||
setError(exportError instanceof Error ? exportError.message : "Failed to export studio project.");
|
||||
setStatusLine(null);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExportGlb = async (project: StudioProjectRecord) => {
|
||||
setBusy(true);
|
||||
setStatusLine("Building GLB export.");
|
||||
try {
|
||||
await exportStudioProjectGlb(project);
|
||||
setError(null);
|
||||
setStatusLine("GLB downloaded.");
|
||||
} catch (exportError) {
|
||||
setError(exportError instanceof Error ? exportError.message : "Failed to export GLB.");
|
||||
setStatusLine(null);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleApplyToOffice = async (projectId: string) => {
|
||||
setBusy(true);
|
||||
setStatusLine("Publishing generated layout to Claw3D office.");
|
||||
try {
|
||||
const response = await fetch("/api/studio-world", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
action: "apply_to_office",
|
||||
projectId,
|
||||
}),
|
||||
});
|
||||
const body = (await response.json()) as StudioWorldResponse;
|
||||
if (!response.ok || !body.office) {
|
||||
throw new Error(body.error || "Failed to apply studio project to office.");
|
||||
}
|
||||
setError(null);
|
||||
setStatusLine(`Published office version ${body.office.officeVersionId}.`);
|
||||
} catch (applyError) {
|
||||
setError(applyError instanceof Error ? applyError.message : "Failed to apply studio project to office.");
|
||||
setStatusLine(null);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative h-full w-full overflow-hidden bg-background">
|
||||
<div className="relative z-10 flex h-full flex-col">
|
||||
<HeaderBar
|
||||
status="disconnected"
|
||||
currentSection="studio"
|
||||
onConnectionSettings={() => setShowConnectionPanel((current) => !current)}
|
||||
/>
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-3 px-3 pb-3 pt-2 sm:px-4 sm:pb-4 sm:pt-3 md:px-5 md:pb-5 md:pt-3">
|
||||
{showConnectionPanel ? (
|
||||
<div className="ui-card px-4 py-3 text-sm text-muted-foreground">
|
||||
Claw3D Studio uses local clean-room generation today. Remote model workers can be attached behind
|
||||
`/api/studio-world` in a future provider.
|
||||
</div>
|
||||
) : null}
|
||||
{error ? (
|
||||
<div className="ui-alert-danger rounded-md px-4 py-2 text-sm">{error}</div>
|
||||
) : null}
|
||||
{statusLine ? (
|
||||
<div className="ui-card px-4 py-2 font-mono text-[11px] tracking-[0.07em] text-muted-foreground">
|
||||
{statusLine}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="grid min-h-0 flex-1 gap-3 xl:grid-cols-[340px_minmax(0,1fr)_320px]">
|
||||
<section className="ui-panel ui-depth-workspace min-h-0 overflow-auto p-4">
|
||||
<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>
|
||||
<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.
|
||||
</p>
|
||||
<div className="mt-5 space-y-4">
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-xs font-medium text-foreground">Project name</span>
|
||||
<input
|
||||
className="ui-input w-full"
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
placeholder="Studio Prototype"
|
||||
/>
|
||||
</label>
|
||||
<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"
|
||||
value={prompt}
|
||||
onChange={(event) => setPrompt(event.target.value)}
|
||||
placeholder="Describe the world, hero assets, camera mood, and export intent."
|
||||
/>
|
||||
</label>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<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)}>
|
||||
{STYLE_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</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)}>
|
||||
{SCALE_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</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)}>
|
||||
{FOCUS_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-xs font-medium text-foreground">Seed</span>
|
||||
<input
|
||||
className="ui-input w-full"
|
||||
value={seed}
|
||||
onChange={(event) => setSeed(event.target.value)}
|
||||
placeholder="Optional deterministic seed"
|
||||
/>
|
||||
</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>
|
||||
<button type="button" className="ui-btn-secondary px-4 py-2 text-sm" onClick={() => void refreshProjects()} disabled={busy}>
|
||||
Refresh library
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="ui-panel ui-depth-workspace min-h-0 overflow-hidden p-3">
|
||||
{selectedProject ? (
|
||||
<div className="flex h-full min-h-0 flex-col gap-3">
|
||||
<StudioWorldPreview sceneDraft={selectedProject.sceneDraft} />
|
||||
<div className="grid gap-3 lg:grid-cols-[minmax(0,1fr)_280px]">
|
||||
<div className="ui-card max-h-52 overflow-auto p-4">
|
||||
<div className="font-mono text-[10px] uppercase tracking-[0.16em] text-muted-foreground">
|
||||
Scene notes
|
||||
</div>
|
||||
<ul className="mt-3 space-y-2 text-sm text-muted-foreground">
|
||||
{selectedProject.sceneDraft.notes.map((note) => (
|
||||
<li key={note} className="rounded-lg border border-border/50 bg-surface-1/50 px-3 py-2">
|
||||
{note}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="ui-card max-h-52 overflow-auto p-4">
|
||||
<div className="font-mono text-[10px] uppercase tracking-[0.16em] text-muted-foreground">
|
||||
Export targets
|
||||
</div>
|
||||
<div className="mt-3 space-y-2 text-sm text-muted-foreground">
|
||||
<div className="rounded-lg border border-border/50 bg-surface-1/50 px-3 py-2">Direct GLB download.</div>
|
||||
<div className="rounded-lg border border-border/50 bg-surface-1/50 px-3 py-2">GLB manifest download.</div>
|
||||
<div className="rounded-lg border border-border/50 bg-surface-1/50 px-3 py-2">Publish to Claw3D office layout.</div>
|
||||
<div className="rounded-lg border border-border/50 bg-surface-1/50 px-3 py-2">Future provider seam for remote model workers.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center rounded-2xl border border-dashed border-border/70 text-sm text-muted-foreground">
|
||||
{loading ? "Loading studio projects..." : "Generate a scene to open the preview."}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<aside className="ui-panel ui-depth-workspace min-h-0 overflow-auto p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="font-mono text-[10px] uppercase tracking-[0.2em] text-muted-foreground">
|
||||
Project library
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-muted-foreground">
|
||||
Generated drafts persist locally in Studio.
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-full border border-border/60 px-2 py-1 font-mono text-[10px] uppercase tracking-[0.16em] text-muted-foreground">
|
||||
{projects.length}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 space-y-3">
|
||||
{projects.map((project) => {
|
||||
const isSelected = project.id === selectedProject?.id;
|
||||
return (
|
||||
<div
|
||||
key={project.id}
|
||||
className={`rounded-2xl border p-3 transition-colors ${
|
||||
isSelected
|
||||
? "border-primary/50 bg-primary/8"
|
||||
: "border-border/60 bg-surface-1/35"
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full text-left"
|
||||
onClick={() => setSelectedProjectId(project.id)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-foreground">{project.name}</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">{project.latestJob.summary}</div>
|
||||
</div>
|
||||
<div className="rounded-full border border-border/60 px-2 py-1 font-mono text-[10px] uppercase tracking-[0.16em] text-muted-foreground">
|
||||
{project.sceneDraft.assets.length}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-1.5">
|
||||
<span className="rounded-full bg-muted px-2 py-1 font-mono text-[10px] uppercase tracking-[0.14em] text-muted-foreground">
|
||||
{project.style}
|
||||
</span>
|
||||
<span className="rounded-full bg-muted px-2 py-1 font-mono text-[10px] uppercase tracking-[0.14em] text-muted-foreground">
|
||||
{project.focus}
|
||||
</span>
|
||||
<span className="rounded-full bg-muted px-2 py-1 font-mono text-[10px] uppercase tracking-[0.14em] text-muted-foreground">
|
||||
{project.scale}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
<div className="mt-3 text-[11px] text-muted-foreground">
|
||||
Updated {formatTimestamp(project.updatedAt)}.
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="ui-btn-secondary px-3 py-1.5 text-xs"
|
||||
onClick={() => void handleExportGlb(project)}
|
||||
disabled={busy}
|
||||
>
|
||||
Export GLB
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="ui-btn-secondary px-3 py-1.5 text-xs"
|
||||
onClick={() => void handleExportManifest(project.id)}
|
||||
disabled={busy}
|
||||
>
|
||||
Export manifest
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="ui-btn-secondary px-3 py-1.5 text-xs"
|
||||
onClick={() => void handleApplyToOffice(project.id)}
|
||||
disabled={busy}
|
||||
>
|
||||
Apply to office
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="ui-btn-secondary px-3 py-1.5 text-xs text-red-300"
|
||||
onClick={() => void handleDelete(project.id)}
|
||||
disabled={busy}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{!loading && projects.length === 0 ? (
|
||||
<div className="rounded-2xl border border-dashed border-border/70 px-4 py-6 text-sm text-muted-foreground">
|
||||
No studio drafts yet.
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
import type {
|
||||
StudioGenerationInput,
|
||||
StudioWorldAnimationKind,
|
||||
StudioWorldAssetDraft,
|
||||
StudioWorldAssetKind,
|
||||
StudioWorldBiome,
|
||||
StudioWorldDraft,
|
||||
StudioWorldPalette,
|
||||
StudioWorldScale,
|
||||
StudioWorldStyle,
|
||||
} from "@/lib/studio-world/types";
|
||||
|
||||
const hashString = (value: string) => {
|
||||
let hash = 0;
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
hash = (hash * 31 + value.charCodeAt(index)) | 0;
|
||||
}
|
||||
return Math.abs(hash);
|
||||
};
|
||||
|
||||
const createSeededRandom = (seed: number) => {
|
||||
let state = seed >>> 0;
|
||||
return () => {
|
||||
state = (state * 1664525 + 1013904223) >>> 0;
|
||||
return state / 0x100000000;
|
||||
};
|
||||
};
|
||||
|
||||
const clamp = (value: number, min: number, max: number) =>
|
||||
Math.min(max, Math.max(min, value));
|
||||
|
||||
const pick = <T>(items: readonly T[], random: () => number): T =>
|
||||
items[Math.floor(random() * items.length)] ?? items[0];
|
||||
|
||||
const round2 = (value: number) => Math.round(value * 100) / 100;
|
||||
|
||||
const normalizePrompt = (value: string) =>
|
||||
value
|
||||
.trim()
|
||||
.replace(/\s+/g, " ")
|
||||
.slice(0, 220);
|
||||
|
||||
const STYLE_PALETTES: Record<StudioWorldStyle, StudioWorldPalette> = {
|
||||
stylized: {
|
||||
ground: "#395b4b",
|
||||
structure: "#85b6ff",
|
||||
prop: "#d6c38f",
|
||||
accent: "#ff8c61",
|
||||
glow: "#7be7ff",
|
||||
fog: "#7aa7ff",
|
||||
sky: "#14233c",
|
||||
},
|
||||
realistic: {
|
||||
ground: "#4f5e46",
|
||||
structure: "#9c9388",
|
||||
prop: "#726556",
|
||||
accent: "#d9974f",
|
||||
glow: "#efe4b8",
|
||||
fog: "#b0b7c8",
|
||||
sky: "#7893b5",
|
||||
},
|
||||
cinematic: {
|
||||
ground: "#2b3146",
|
||||
structure: "#6c78a8",
|
||||
prop: "#b38d71",
|
||||
accent: "#ff5e7a",
|
||||
glow: "#8fd9ff",
|
||||
fog: "#7568d8",
|
||||
sky: "#090d18",
|
||||
},
|
||||
"low-poly": {
|
||||
ground: "#4c6d56",
|
||||
structure: "#7bb2a6",
|
||||
prop: "#d7a36a",
|
||||
accent: "#ffcf5d",
|
||||
glow: "#fff1a3",
|
||||
fog: "#b9d8ff",
|
||||
sky: "#203247",
|
||||
},
|
||||
};
|
||||
|
||||
const BIOME_NOTES: Record<StudioWorldBiome, string[]> = {
|
||||
creative_plaza: [
|
||||
"Central plaza layout supports showcase scenes and collaborative props.",
|
||||
"Sight lines stay open so exported assets remain easy to stage in Claw3D.",
|
||||
],
|
||||
forest: [
|
||||
"Organic prop spacing creates a traversal loop with layered silhouettes.",
|
||||
"Trees and rocks remain modular so they can be reused as asset packs.",
|
||||
],
|
||||
desert: [
|
||||
"Large negative space preserves readability for vehicles and hero props.",
|
||||
"Beacon accents define landmarks and work as navigation anchors.",
|
||||
],
|
||||
coast: [
|
||||
"Shoreline composition balances broad terrain planes with landmark structures.",
|
||||
"Accent lighting suggests a path from beach props toward a hub space.",
|
||||
],
|
||||
neo_city: [
|
||||
"Vertical massing creates skyline depth and supports cinematic camera moves.",
|
||||
"Repeated modular towers and portals form a reusable sci-fi kit.",
|
||||
],
|
||||
fantasy: [
|
||||
"Arches and beacons imply traversal goals and magical interaction points.",
|
||||
"Pulse-driven accents help animation previews read at a glance.",
|
||||
],
|
||||
};
|
||||
|
||||
const scaleToBounds = (scale: StudioWorldScale) => {
|
||||
if (scale === "small") {
|
||||
return { width: 28, depth: 28, assetCount: 12 };
|
||||
}
|
||||
if (scale === "large") {
|
||||
return { width: 58, depth: 58, assetCount: 28 };
|
||||
}
|
||||
return { width: 40, depth: 40, assetCount: 18 };
|
||||
};
|
||||
|
||||
const detectBiome = (prompt: string, style: StudioWorldStyle): StudioWorldBiome => {
|
||||
const normalized = prompt.toLowerCase();
|
||||
if (/\bforest|wood|tree|grove|nature\b/.test(normalized)) return "forest";
|
||||
if (/\bdesert|dune|sand|canyon\b/.test(normalized)) return "desert";
|
||||
if (/\bcoast|beach|ocean|harbor|shore\b/.test(normalized)) return "coast";
|
||||
if (/\bcity|cyber|neon|urban|street\b/.test(normalized)) return "neo_city";
|
||||
if (/\bfantasy|magic|ruin|temple|myth\b/.test(normalized)) return "fantasy";
|
||||
if (style === "cinematic") return "neo_city";
|
||||
return "creative_plaza";
|
||||
};
|
||||
|
||||
const buildAssetKindSet = (
|
||||
biome: StudioWorldBiome,
|
||||
focus: StudioGenerationInput["focus"],
|
||||
): StudioWorldAssetKind[] => {
|
||||
const base: StudioWorldAssetKind[] = ["platform", "arch", "crate", "beacon"];
|
||||
if (biome === "forest") base.push("tree", "rock", "tree");
|
||||
if (biome === "desert") base.push("rock", "tower", "arch");
|
||||
if (biome === "coast") base.push("rock", "portal", "platform");
|
||||
if (biome === "neo_city") base.push("tower", "portal", "tower");
|
||||
if (biome === "fantasy") base.push("arch", "portal", "beacon");
|
||||
if (focus === "assets") base.push("crate", "tree", "rock", "arch");
|
||||
if (focus === "animation") base.push("beacon", "portal", "tower");
|
||||
if (focus === "world") base.push("platform", "tower", "arch");
|
||||
return base;
|
||||
};
|
||||
|
||||
const resolveAnimation = (
|
||||
kind: StudioWorldAssetKind,
|
||||
focus: StudioGenerationInput["focus"],
|
||||
random: () => number,
|
||||
): StudioWorldAnimationKind => {
|
||||
if (focus === "animation") {
|
||||
if (kind === "portal" || kind === "beacon") return "pulse";
|
||||
return random() > 0.45 ? "bob" : "spin";
|
||||
}
|
||||
if (kind === "portal") return "spin";
|
||||
if (kind === "beacon") return "pulse";
|
||||
return random() > 0.82 ? "bob" : "none";
|
||||
};
|
||||
|
||||
const buildAssetName = (kind: StudioWorldAssetKind, index: number) =>
|
||||
`${kind.replace(/_/g, " ")} ${index + 1}`;
|
||||
|
||||
const buildAssetColor = (
|
||||
kind: StudioWorldAssetKind,
|
||||
palette: StudioWorldPalette,
|
||||
random: () => number,
|
||||
) => {
|
||||
if (kind === "platform" || kind === "tower" || kind === "arch") return palette.structure;
|
||||
if (kind === "portal" || kind === "beacon") return random() > 0.5 ? palette.accent : palette.glow;
|
||||
return palette.prop;
|
||||
};
|
||||
|
||||
const buildAssetScale = (kind: StudioWorldAssetKind, random: () => number): [number, number, number] => {
|
||||
if (kind === "platform") return [3 + random() * 4, 0.6 + random() * 0.4, 3 + random() * 4];
|
||||
if (kind === "tower") return [1.2 + random() * 1.6, 4 + random() * 5, 1.2 + random() * 1.6];
|
||||
if (kind === "arch") return [2.4 + random() * 1.8, 2 + random() * 1.3, 0.8 + random() * 0.5];
|
||||
if (kind === "tree") return [0.9 + random() * 1.5, 2.8 + random() * 2.8, 0.9 + random() * 1.5];
|
||||
if (kind === "rock") return [0.8 + random() * 2.2, 0.6 + random() * 1.4, 0.8 + random() * 2.2];
|
||||
if (kind === "beacon") return [0.7 + random() * 0.6, 2 + random() * 1.4, 0.7 + random() * 0.6];
|
||||
if (kind === "portal") return [1.6 + random() * 1.2, 2.8 + random() * 1.4, 0.45 + random() * 0.35];
|
||||
return [0.9 + random() * 1.2, 0.9 + random() * 1.2, 0.9 + random() * 1.2];
|
||||
};
|
||||
|
||||
const buildAssetPosition = (
|
||||
index: number,
|
||||
total: number,
|
||||
bounds: { width: number; depth: number },
|
||||
random: () => number,
|
||||
): [number, number, number] => {
|
||||
const radius = Math.min(bounds.width, bounds.depth) * (0.16 + 0.34 * (index / Math.max(total - 1, 1)));
|
||||
const angle = index * 0.74 + random() * 0.9;
|
||||
const x = Math.cos(angle) * radius + (random() - 0.5) * 4;
|
||||
const z = Math.sin(angle) * radius + (random() - 0.5) * 4;
|
||||
return [round2(x), 0, round2(z)];
|
||||
};
|
||||
|
||||
const buildSceneAssets = (params: {
|
||||
biome: StudioWorldBiome;
|
||||
focus: StudioGenerationInput["focus"];
|
||||
palette: StudioWorldPalette;
|
||||
bounds: { width: number; depth: number; assetCount: number };
|
||||
random: () => number;
|
||||
}) => {
|
||||
const kindSet = buildAssetKindSet(params.biome, params.focus);
|
||||
const assets: StudioWorldAssetDraft[] = [];
|
||||
for (let index = 0; index < params.bounds.assetCount; index += 1) {
|
||||
const kind = pick(kindSet, params.random);
|
||||
const animation = resolveAnimation(kind, params.focus, params.random);
|
||||
const color = buildAssetColor(kind, params.palette, params.random);
|
||||
assets.push({
|
||||
id: `asset_${index + 1}`,
|
||||
name: buildAssetName(kind, index),
|
||||
kind,
|
||||
position: buildAssetPosition(index, params.bounds.assetCount, params.bounds, params.random),
|
||||
scale: buildAssetScale(kind, params.random),
|
||||
rotationY: round2(params.random() * Math.PI * 2),
|
||||
color,
|
||||
emissive:
|
||||
kind === "portal" || kind === "beacon"
|
||||
? (params.random() > 0.5 ? params.palette.glow : params.palette.accent)
|
||||
: null,
|
||||
animation,
|
||||
});
|
||||
}
|
||||
return assets;
|
||||
};
|
||||
|
||||
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}`);
|
||||
};
|
||||
|
||||
export const buildStudioWorldDraft = (input: StudioGenerationInput): StudioWorldDraft => {
|
||||
const seed = resolveGenerationSeed(input);
|
||||
const random = createSeededRandom(seed);
|
||||
const biome = detectBiome(input.prompt, input.style);
|
||||
const bounds = scaleToBounds(input.scale);
|
||||
const palette = STYLE_PALETTES[input.style];
|
||||
const assets = buildSceneAssets({
|
||||
biome,
|
||||
focus: input.focus,
|
||||
palette,
|
||||
bounds,
|
||||
random,
|
||||
});
|
||||
const normalizedPrompt = normalizePrompt(input.prompt);
|
||||
const promptSummary = normalizedPrompt || "Untitled generated scene";
|
||||
const notes = [
|
||||
...BIOME_NOTES[biome],
|
||||
`Primary focus: ${input.focus}.`,
|
||||
`Generated with ${input.style} styling and seed ${seed}.`,
|
||||
];
|
||||
|
||||
return {
|
||||
biome,
|
||||
palette,
|
||||
worldBounds: {
|
||||
width: bounds.width,
|
||||
depth: bounds.depth,
|
||||
},
|
||||
camera: {
|
||||
position: [bounds.width * 0.65, clamp(bounds.width * 0.7, 18, 40), bounds.depth * 0.65],
|
||||
target: [0, 0, 0],
|
||||
},
|
||||
promptSummary,
|
||||
notes,
|
||||
assets,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,138 @@
|
||||
import {
|
||||
createEmptyOfficeMap,
|
||||
type OfficeCollision,
|
||||
type OfficeMap,
|
||||
type OfficeMapObject,
|
||||
type OfficeZone,
|
||||
} from "@/lib/office/schema";
|
||||
import type { StudioProjectRecord, StudioWorldAssetDraft } from "@/lib/studio-world/types";
|
||||
|
||||
const WORLD_SCALE_TO_CANVAS = 28;
|
||||
const HALF_PADDING = 120;
|
||||
|
||||
const clampRotationToQuarterTurns = (rotationY: number) =>
|
||||
(((Math.round(rotationY / (Math.PI / 2)) % 4) + 4) % 4) * 90;
|
||||
|
||||
const toCanvasX = (worldX: number, width: number) =>
|
||||
Math.round(width / 2 + worldX * WORLD_SCALE_TO_CANVAS);
|
||||
|
||||
const toCanvasY = (worldZ: number, height: number) =>
|
||||
Math.round(height / 2 + worldZ * WORLD_SCALE_TO_CANVAS);
|
||||
|
||||
const resolveObjectAssetId = (asset: StudioWorldAssetDraft): OfficeMapObject["assetId"] => {
|
||||
if (asset.kind === "platform") return "floor_tile";
|
||||
if (asset.kind === "tower") return "arcade_machine";
|
||||
if (asset.kind === "portal") return "tv_wall";
|
||||
if (asset.kind === "crate") return "coffee_station";
|
||||
if (asset.kind === "beacon") return "arcade_machine";
|
||||
if (asset.kind === "arch") return "meeting_table";
|
||||
if (asset.kind === "tree") return "coffee_station";
|
||||
if (asset.kind === "rock") return "desk_modern";
|
||||
return "desk_modern";
|
||||
};
|
||||
|
||||
const resolveLayerId = (asset: StudioWorldAssetDraft): OfficeMapObject["layerId"] => {
|
||||
if (asset.kind === "platform") return "floor";
|
||||
if (asset.kind === "portal" || asset.kind === "beacon") return "decor";
|
||||
return "furniture";
|
||||
};
|
||||
|
||||
const createCollisionForAsset = (
|
||||
asset: StudioWorldAssetDraft,
|
||||
mapWidth: number,
|
||||
mapHeight: number,
|
||||
): OfficeCollision | null => {
|
||||
if (asset.kind === "platform") return null;
|
||||
const width = Math.max(20, Math.round(asset.scale[0] * WORLD_SCALE_TO_CANVAS));
|
||||
const depth = Math.max(20, Math.round(asset.scale[2] * WORLD_SCALE_TO_CANVAS));
|
||||
const cx = toCanvasX(asset.position[0], mapWidth);
|
||||
const cy = toCanvasY(asset.position[2], mapHeight);
|
||||
return {
|
||||
id: `collision_${asset.id}`,
|
||||
blocked: true,
|
||||
shape: {
|
||||
points: [
|
||||
{ x: cx - width / 2, y: cy - depth / 2 },
|
||||
{ x: cx + width / 2, y: cy - depth / 2 },
|
||||
{ x: cx + width / 2, y: cy + depth / 2 },
|
||||
{ x: cx - width / 2, y: cy + depth / 2 },
|
||||
],
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const buildObjects = (
|
||||
project: StudioProjectRecord,
|
||||
width: number,
|
||||
height: number,
|
||||
): OfficeMapObject[] =>
|
||||
project.sceneDraft.assets.map((asset, index) => ({
|
||||
id: asset.id,
|
||||
assetId: resolveObjectAssetId(asset),
|
||||
layerId: resolveLayerId(asset),
|
||||
x: toCanvasX(asset.position[0], width),
|
||||
y: toCanvasY(asset.position[2], height),
|
||||
rotation: clampRotationToQuarterTurns(asset.rotationY),
|
||||
flipX: false,
|
||||
flipY: false,
|
||||
zIndex: 100 + index,
|
||||
tags: [
|
||||
"studio-generated",
|
||||
`studio-kind:${asset.kind}`,
|
||||
`studio-project:${project.id}`,
|
||||
],
|
||||
}));
|
||||
|
||||
const buildZone = (project: StudioProjectRecord, width: number, height: number): OfficeZone => {
|
||||
const halfWidth = Math.round(project.sceneDraft.worldBounds.width * WORLD_SCALE_TO_CANVAS * 0.5);
|
||||
const halfDepth = Math.round(project.sceneDraft.worldBounds.depth * WORLD_SCALE_TO_CANVAS * 0.5);
|
||||
const centerX = Math.round(width / 2);
|
||||
const centerY = Math.round(height / 2);
|
||||
return {
|
||||
id: "zone_studio_generated",
|
||||
type: "hallway",
|
||||
name: project.name,
|
||||
ambienceTags: [project.style, project.focus],
|
||||
shape: {
|
||||
points: [
|
||||
{ x: centerX - halfWidth, y: centerY - halfDepth },
|
||||
{ x: centerX + halfWidth, y: centerY - halfDepth },
|
||||
{ x: centerX + halfWidth, y: centerY + halfDepth },
|
||||
{ x: centerX - halfWidth, y: centerY + halfDepth },
|
||||
],
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const buildOfficeMapFromStudioProject = (
|
||||
project: StudioProjectRecord,
|
||||
officeVersionId: string,
|
||||
): OfficeMap => {
|
||||
const width =
|
||||
Math.round(project.sceneDraft.worldBounds.width * WORLD_SCALE_TO_CANVAS + HALF_PADDING * 2);
|
||||
const height =
|
||||
Math.round(project.sceneDraft.worldBounds.depth * WORLD_SCALE_TO_CANVAS + HALF_PADDING * 2);
|
||||
const map = createEmptyOfficeMap({
|
||||
workspaceId: "default",
|
||||
officeVersionId,
|
||||
width,
|
||||
height,
|
||||
});
|
||||
map.canvas.backgroundColor = project.sceneDraft.palette.sky;
|
||||
map.theme = {
|
||||
mood: project.style === "cinematic" ? "night" : project.style === "realistic" ? "focus" : "cozy",
|
||||
enableThoughtBubbles: true,
|
||||
};
|
||||
map.objects = buildObjects(project, width, height);
|
||||
map.zones = [buildZone(project, width, height)];
|
||||
map.collisions = project.sceneDraft.assets
|
||||
.map((asset) => createCollisionForAsset(asset, width, height))
|
||||
.filter((entry): entry is OfficeCollision => Boolean(entry));
|
||||
map.spawnPoints = [{ id: "spawn-main", x: Math.round(width / 2), y: Math.round(height / 2) }];
|
||||
map.lightingOverlay = {
|
||||
enabled: true,
|
||||
baseDarkness: project.style === "cinematic" ? 0.34 : 0.18,
|
||||
roomDarkness: {},
|
||||
};
|
||||
return map;
|
||||
};
|
||||
@@ -0,0 +1,220 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import { resolveStateDir } from "@/lib/clawdbot/paths";
|
||||
import {
|
||||
buildStudioWorldDraft,
|
||||
resolveGenerationSeed,
|
||||
} from "@/lib/studio-world/generator";
|
||||
import type {
|
||||
StudioGenerationInput,
|
||||
StudioGenerationJobRecord,
|
||||
StudioProjectRecord,
|
||||
StudioProjectsStore,
|
||||
} from "@/lib/studio-world/types";
|
||||
|
||||
const STORE_DIR = "claw3d";
|
||||
const STORE_FILE = "studio-world-projects.json";
|
||||
const STORE_VERSION = 1;
|
||||
|
||||
const ensureDirectory = (dirPath: string) => {
|
||||
if (!fs.existsSync(dirPath)) {
|
||||
fs.mkdirSync(dirPath, { recursive: true });
|
||||
}
|
||||
};
|
||||
|
||||
const resolveStorePath = () => {
|
||||
const stateDir = resolveStateDir();
|
||||
const dir = path.join(stateDir, STORE_DIR);
|
||||
ensureDirectory(dir);
|
||||
return path.join(dir, STORE_FILE);
|
||||
};
|
||||
|
||||
const defaultStore = (): StudioProjectsStore => ({
|
||||
schemaVersion: STORE_VERSION,
|
||||
projects: [],
|
||||
});
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
Boolean(value && typeof value === "object" && !Array.isArray(value));
|
||||
|
||||
const asString = (value: unknown, fallback = "") =>
|
||||
typeof value === "string" ? value : fallback;
|
||||
|
||||
const asNumber = (value: unknown, fallback = 0) =>
|
||||
typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
||||
|
||||
const normalizeStore = (value: unknown): StudioProjectsStore => {
|
||||
if (!isRecord(value)) {
|
||||
return defaultStore();
|
||||
}
|
||||
const rawProjects = Array.isArray(value.projects) ? value.projects : [];
|
||||
const projects = rawProjects
|
||||
.map((entry): StudioProjectRecord | null => {
|
||||
if (!isRecord(entry)) return null;
|
||||
if (!isRecord(entry.latestJob)) return null;
|
||||
if (!isRecord(entry.sceneDraft)) return null;
|
||||
const id = asString(entry.id).trim();
|
||||
const name = asString(entry.name).trim();
|
||||
const prompt = asString(entry.prompt).trim();
|
||||
const createdAt = asString(entry.createdAt).trim();
|
||||
const updatedAt = asString(entry.updatedAt).trim();
|
||||
if (!id || !name || !createdAt || !updatedAt) return null;
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
prompt,
|
||||
style:
|
||||
entry.style === "realistic" ||
|
||||
entry.style === "cinematic" ||
|
||||
entry.style === "low-poly"
|
||||
? entry.style
|
||||
: "stylized",
|
||||
scale:
|
||||
entry.scale === "small" || entry.scale === "large"
|
||||
? entry.scale
|
||||
: "medium",
|
||||
focus:
|
||||
entry.focus === "assets" || entry.focus === "animation"
|
||||
? entry.focus
|
||||
: "world",
|
||||
seed: asNumber(entry.seed, 0),
|
||||
createdAt,
|
||||
updatedAt,
|
||||
latestJob: {
|
||||
id: asString(entry.latestJob.id, "job"),
|
||||
provider: asString(entry.latestJob.provider, "clean-room-procedural"),
|
||||
status: "completed",
|
||||
createdAt: asString(entry.latestJob.createdAt, createdAt),
|
||||
finishedAt: asString(entry.latestJob.finishedAt, updatedAt),
|
||||
summary: asString(entry.latestJob.summary, ""),
|
||||
assetCount: asNumber(entry.latestJob.assetCount, 0),
|
||||
},
|
||||
sceneDraft: entry.sceneDraft as StudioProjectRecord["sceneDraft"],
|
||||
};
|
||||
})
|
||||
.filter((entry): entry is StudioProjectRecord => Boolean(entry))
|
||||
.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
|
||||
return {
|
||||
schemaVersion: STORE_VERSION,
|
||||
projects,
|
||||
};
|
||||
};
|
||||
|
||||
const readStore = (): StudioProjectsStore => {
|
||||
const storePath = resolveStorePath();
|
||||
if (!fs.existsSync(storePath)) {
|
||||
return defaultStore();
|
||||
}
|
||||
const raw = fs.readFileSync(storePath, "utf8");
|
||||
return normalizeStore(JSON.parse(raw));
|
||||
};
|
||||
|
||||
const writeStore = (store: StudioProjectsStore) => {
|
||||
const storePath = resolveStorePath();
|
||||
fs.writeFileSync(storePath, JSON.stringify(store, null, 2), "utf8");
|
||||
};
|
||||
|
||||
const slugify = (value: string) =>
|
||||
value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 48) || "studio-world";
|
||||
|
||||
const createProjectId = (name: string) =>
|
||||
`${slugify(name)}-${Date.now().toString(36)}`;
|
||||
|
||||
const createJobId = () => `job-${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.`;
|
||||
|
||||
export const listStudioProjects = () => readStore().projects;
|
||||
|
||||
export const getStudioProject = (projectId: string) =>
|
||||
readStore().projects.find((entry) => entry.id === projectId) ?? null;
|
||||
|
||||
export const createStudioProject = (input: StudioGenerationInput) => {
|
||||
const store = readStore();
|
||||
const createdAt = new Date().toISOString();
|
||||
const seed = resolveGenerationSeed(input);
|
||||
const sceneDraft = buildStudioWorldDraft(input);
|
||||
const latestJob: StudioGenerationJobRecord = {
|
||||
id: createJobId(),
|
||||
provider: "clean-room-procedural",
|
||||
status: "completed",
|
||||
createdAt,
|
||||
finishedAt: createdAt,
|
||||
summary: buildSummary({ input, assetCount: sceneDraft.assets.length }),
|
||||
assetCount: sceneDraft.assets.length,
|
||||
};
|
||||
const project: StudioProjectRecord = {
|
||||
id: createProjectId(input.name),
|
||||
name: input.name.trim() || "Untitled Studio World",
|
||||
prompt: input.prompt.trim(),
|
||||
style: input.style,
|
||||
scale: input.scale,
|
||||
focus: input.focus,
|
||||
seed,
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
latestJob,
|
||||
sceneDraft,
|
||||
};
|
||||
store.projects = [project, ...store.projects].sort((left, right) =>
|
||||
right.updatedAt.localeCompare(left.updatedAt),
|
||||
);
|
||||
writeStore(store);
|
||||
return project;
|
||||
};
|
||||
|
||||
export const updateStudioProject = (
|
||||
projectId: string,
|
||||
patch: Partial<
|
||||
Pick<StudioProjectRecord, "name" | "prompt" | "style" | "scale" | "focus" | "sceneDraft">
|
||||
>,
|
||||
) => {
|
||||
const store = readStore();
|
||||
const target = store.projects.find((entry) => entry.id === projectId);
|
||||
if (!target) {
|
||||
throw new Error("Studio project not found.");
|
||||
}
|
||||
if (typeof patch.name === "string") {
|
||||
target.name = patch.name.trim() || target.name;
|
||||
}
|
||||
if (typeof patch.prompt === "string") {
|
||||
target.prompt = patch.prompt.trim();
|
||||
}
|
||||
if (patch.style) {
|
||||
target.style = patch.style;
|
||||
}
|
||||
if (patch.scale) {
|
||||
target.scale = patch.scale;
|
||||
}
|
||||
if (patch.focus) {
|
||||
target.focus = patch.focus;
|
||||
}
|
||||
if (patch.sceneDraft) {
|
||||
target.sceneDraft = patch.sceneDraft;
|
||||
}
|
||||
target.updatedAt = new Date().toISOString();
|
||||
store.projects.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
|
||||
writeStore(store);
|
||||
return target;
|
||||
};
|
||||
|
||||
export const deleteStudioProject = (projectId: string) => {
|
||||
const store = readStore();
|
||||
const nextProjects = store.projects.filter((entry) => entry.id !== projectId);
|
||||
const deleted = nextProjects.length !== store.projects.length;
|
||||
if (deleted) {
|
||||
store.projects = nextProjects;
|
||||
writeStore(store);
|
||||
}
|
||||
return deleted;
|
||||
};
|
||||
@@ -0,0 +1,105 @@
|
||||
export type StudioWorldStyle =
|
||||
| "stylized"
|
||||
| "realistic"
|
||||
| "cinematic"
|
||||
| "low-poly";
|
||||
|
||||
export type StudioWorldScale = "small" | "medium" | "large";
|
||||
|
||||
export type StudioWorldFocus = "world" | "assets" | "animation";
|
||||
|
||||
export type StudioWorldBiome =
|
||||
| "creative_plaza"
|
||||
| "forest"
|
||||
| "desert"
|
||||
| "coast"
|
||||
| "neo_city"
|
||||
| "fantasy";
|
||||
|
||||
export type StudioWorldAnimationKind = "none" | "bob" | "spin" | "pulse";
|
||||
|
||||
export type StudioWorldAssetKind =
|
||||
| "platform"
|
||||
| "tower"
|
||||
| "arch"
|
||||
| "tree"
|
||||
| "rock"
|
||||
| "beacon"
|
||||
| "crate"
|
||||
| "portal";
|
||||
|
||||
export type StudioWorldAssetDraft = {
|
||||
id: string;
|
||||
name: string;
|
||||
kind: StudioWorldAssetKind;
|
||||
position: [number, number, number];
|
||||
scale: [number, number, number];
|
||||
rotationY: number;
|
||||
color: string;
|
||||
emissive?: string | null;
|
||||
animation: StudioWorldAnimationKind;
|
||||
};
|
||||
|
||||
export type StudioWorldPalette = {
|
||||
ground: string;
|
||||
structure: string;
|
||||
prop: string;
|
||||
accent: string;
|
||||
glow: string;
|
||||
fog: string;
|
||||
sky: string;
|
||||
};
|
||||
|
||||
export type StudioWorldDraft = {
|
||||
biome: StudioWorldBiome;
|
||||
palette: StudioWorldPalette;
|
||||
worldBounds: {
|
||||
width: number;
|
||||
depth: number;
|
||||
};
|
||||
camera: {
|
||||
position: [number, number, number];
|
||||
target: [number, number, number];
|
||||
};
|
||||
promptSummary: string;
|
||||
notes: string[];
|
||||
assets: StudioWorldAssetDraft[];
|
||||
};
|
||||
|
||||
export type StudioGenerationInput = {
|
||||
name: string;
|
||||
prompt: string;
|
||||
style: StudioWorldStyle;
|
||||
scale: StudioWorldScale;
|
||||
focus: StudioWorldFocus;
|
||||
seed?: number | null;
|
||||
};
|
||||
|
||||
export type StudioGenerationJobRecord = {
|
||||
id: string;
|
||||
provider: string;
|
||||
status: "completed";
|
||||
createdAt: string;
|
||||
finishedAt: string;
|
||||
summary: string;
|
||||
assetCount: number;
|
||||
};
|
||||
|
||||
export type StudioProjectRecord = {
|
||||
id: string;
|
||||
name: string;
|
||||
prompt: string;
|
||||
style: StudioWorldStyle;
|
||||
scale: StudioWorldScale;
|
||||
focus: StudioWorldFocus;
|
||||
seed: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
latestJob: StudioGenerationJobRecord;
|
||||
sceneDraft: StudioWorldDraft;
|
||||
};
|
||||
|
||||
export type StudioProjectsStore = {
|
||||
schemaVersion: number;
|
||||
projects: StudioProjectRecord[];
|
||||
};
|
||||
@@ -0,0 +1,99 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { DELETE, GET, POST } from "@/app/api/studio-world/route";
|
||||
|
||||
const makeTempDir = (name: string) => fs.mkdtempSync(path.join(os.tmpdir(), `${name}-`));
|
||||
|
||||
describe("studio world route", () => {
|
||||
const priorStateDir = process.env.OPENCLAW_STATE_DIR;
|
||||
let tempDir: string | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
process.env.OPENCLAW_STATE_DIR = priorStateDir;
|
||||
if (tempDir) {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
tempDir = null;
|
||||
}
|
||||
});
|
||||
|
||||
it("creates, lists, exports, applies, and deletes a studio project", async () => {
|
||||
tempDir = makeTempDir("studio-world-route");
|
||||
process.env.OPENCLAW_STATE_DIR = tempDir;
|
||||
|
||||
const createResponse = await POST({
|
||||
text: async () =>
|
||||
JSON.stringify({
|
||||
action: "generate",
|
||||
input: {
|
||||
name: "Forest Lab",
|
||||
prompt: "A stylized forest lab with modular props and export-ready landmarks.",
|
||||
style: "stylized",
|
||||
scale: "medium",
|
||||
focus: "world",
|
||||
seed: 42,
|
||||
},
|
||||
}),
|
||||
} as unknown as Request);
|
||||
const createBody = (await createResponse.json()) as {
|
||||
project?: {
|
||||
id: string;
|
||||
name: string;
|
||||
sceneDraft: { assets: Array<unknown> };
|
||||
};
|
||||
};
|
||||
|
||||
expect(createResponse.status).toBe(200);
|
||||
expect(createBody.project?.name).toBe("Forest Lab");
|
||||
expect(createBody.project?.sceneDraft.assets.length).toBeGreaterThan(0);
|
||||
|
||||
const projectId = createBody.project?.id ?? "";
|
||||
expect(projectId.length).toBeGreaterThan(0);
|
||||
|
||||
const listResponse = await GET(
|
||||
new Request("http://localhost/api/studio-world"),
|
||||
);
|
||||
const listBody = (await listResponse.json()) as {
|
||||
projects?: Array<{ id: string }>;
|
||||
};
|
||||
expect(listResponse.status).toBe(200);
|
||||
expect(listBody.projects?.some((project) => project.id === projectId)).toBe(true);
|
||||
|
||||
const exportResponse = await GET(
|
||||
new Request(`http://localhost/api/studio-world?action=export&projectId=${encodeURIComponent(projectId)}`),
|
||||
);
|
||||
const exportBody = (await exportResponse.json()) as {
|
||||
exportManifest?: {
|
||||
project?: { id?: string };
|
||||
export?: { format?: string };
|
||||
};
|
||||
};
|
||||
expect(exportResponse.status).toBe(200);
|
||||
expect(exportBody.exportManifest?.project?.id).toBe(projectId);
|
||||
expect(exportBody.exportManifest?.export?.format).toBe("glb_manifest");
|
||||
|
||||
const applyResponse = await POST({
|
||||
text: async () =>
|
||||
JSON.stringify({
|
||||
action: "apply_to_office",
|
||||
projectId,
|
||||
}),
|
||||
} as unknown as Request);
|
||||
const applyBody = (await applyResponse.json()) as {
|
||||
office?: { officeId?: string; officeVersionId?: string };
|
||||
};
|
||||
expect(applyResponse.status).toBe(200);
|
||||
expect(applyBody.office?.officeId).toBe("studio-world");
|
||||
expect((applyBody.office?.officeVersionId ?? "").length).toBeGreaterThan(0);
|
||||
|
||||
const deleteResponse = await DELETE(
|
||||
new Request(`http://localhost/api/studio-world?projectId=${encodeURIComponent(projectId)}`),
|
||||
);
|
||||
const deleteBody = (await deleteResponse.json()) as { deleted?: boolean };
|
||||
expect(deleteResponse.status).toBe(200);
|
||||
expect(deleteBody.deleted).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user