Spaces:
Paused
Paused
Download app.py from Mike0021/img2threejs-studio: direct link, hf CLI and curl.
- Browser
- Download file 26 kB
-
https://huggingface.co/spaces/Mike0021/img2threejs-studio/resolve/main/app.py
- Command line
-
hf download hf://spaces/Mike0021/img2threejs-studio/app.py
-
curl -L -o app.py https://huggingface.co/spaces/Mike0021/img2threejs-studio/resolve/main/app.py
26 kB
| import os | |
| os.environ.setdefault("HF_HOME", "/tmp/huggingface") | |
| os.environ.setdefault("HF_MODULES_CACHE", "/tmp/hf_modules") | |
| os.environ.setdefault("MPLCONFIGDIR", "/tmp/matplotlib") | |
| import html | |
| import json | |
| import math | |
| import re | |
| import time | |
| from typing import Any | |
| import gradio as gr | |
| from huggingface_hub import InferenceClient | |
| MODEL_ID = "deepseek-ai/DeepSeek-V4-Flash-0731" | |
| THREE_VERSION = "0.180.0" | |
| MAX_PROMPT_LENGTH = 1_200 | |
| MAX_PARTS = 64 | |
| ALLOWED_PRIMITIVES = { | |
| "box", | |
| "sphere", | |
| "cylinder", | |
| "cone", | |
| "torus", | |
| "capsule", | |
| "dodecahedron", | |
| } | |
| DETAIL_BUDGETS = { | |
| "Iconic · 12–20 parts": 20, | |
| "Balanced · 20–36 parts": 36, | |
| "Intricate · 36–64 parts": 64, | |
| } | |
| SYSTEM_PROMPT = """You are an expert procedural 3D art director. Convert a text description into | |
| a compact JSON scene specification for a browser Three.js renderer. This is reconstruction-by-code: | |
| use named primitive parts, observable proportions, explicit materials, and a readable silhouette. | |
| Think through a blockout, structural pass, form pass, material pass, and surface-detail pass silently. | |
| Before emitting JSON, silently inventory the identity-defining parts. Spend parts on silhouette and | |
| recognition before decoration. Do not claim unseen or unspecified details as factual; make tasteful | |
| design inferences instead. | |
| Return exactly one valid JSON object and no markdown. The schema is: | |
| { | |
| "title": "short scene title", | |
| "background": "#RRGGBB", | |
| "ground": "#RRGGBB", | |
| "camera": {"position": [x,y,z], "target": [x,y,z], "fov": 25..65}, | |
| "autoRotate": true, | |
| "parts": [ | |
| { | |
| "name": "descriptive unique part name", | |
| "primitive": "box|sphere|cylinder|cone|torus|capsule|dodecahedron", | |
| "position": [x,y,z], | |
| "rotation": [x,y,z], | |
| "scale": [x,y,z], | |
| "color": "#RRGGBB", | |
| "roughness": 0..1, | |
| "metalness": 0..1, | |
| "emissive": "#RRGGBB", | |
| "emissiveIntensity": 0..2 | |
| } | |
| ] | |
| } | |
| Coordinate rules: Y is up; the ground is Y=0. Box and cylinder base geometries are unit-sized; | |
| scale is the part's final width, height, and depth. Cylinder and cone axes point along Y. Sphere and | |
| dodecahedron start at unit diameter. Torus lies in the XY plane before rotation. Capsule's long axis | |
| is Y. Positions and rotations are [x,y,z], with rotations in radians. Keep the whole object roughly | |
| within a 10 x 10 x 10 volume. Every scale component must be positive and at least 0.05. | |
| Use coherent PBR values. Prefer 2–5 well-separated material families. Use repeated parts for wheels, | |
| buttons, teeth, rivets, windows, or spokes when they define identity. Avoid coplanar overlap. Ensure | |
| the lowest visible geometry meets or slightly enters the ground plane. Do not include comments, | |
| JavaScript, HTML, URLs, textures, external assets, or keys outside the schema.""" | |
| def _clean_text(value: Any, fallback: str, limit: int = 80) -> str: | |
| text = re.sub(r"[^\w .,'&()\-/]", "", str(value or ""), flags=re.UNICODE) | |
| text = re.sub(r"\s+", " ", text).strip() | |
| return (text[:limit] or fallback).strip() | |
| def _color(value: Any, fallback: str) -> str: | |
| candidate = str(value or "").strip() | |
| return candidate.upper() if re.fullmatch(r"#[0-9a-fA-F]{6}", candidate) else fallback | |
| def _number(value: Any, fallback: float, low: float, high: float) -> float: | |
| try: | |
| number = float(value) | |
| except (TypeError, ValueError): | |
| return fallback | |
| if not math.isfinite(number): | |
| return fallback | |
| return round(max(low, min(high, number)), 4) | |
| def _vector( | |
| value: Any, | |
| fallback: list[float], | |
| low: float, | |
| high: float, | |
| positive: bool = False, | |
| ) -> list[float]: | |
| if not isinstance(value, list) or len(value) != 3: | |
| value = fallback | |
| floor = 0.05 if positive else low | |
| return [_number(item, fallback[index], floor, high) for index, item in enumerate(value)] | |
| def _extract_json(raw: str) -> dict[str, Any]: | |
| candidate = (raw or "").strip() | |
| candidate = re.sub(r"^```(?:json)?\s*", "", candidate, flags=re.IGNORECASE) | |
| candidate = re.sub(r"\s*```$", "", candidate) | |
| try: | |
| parsed = json.loads(candidate) | |
| except json.JSONDecodeError: | |
| start = candidate.find("{") | |
| if start < 0: | |
| raise ValueError("The endpoint did not return a JSON scene.") from None | |
| try: | |
| parsed, _ = json.JSONDecoder().raw_decode(candidate[start:]) | |
| except json.JSONDecodeError as exc: | |
| raise ValueError("The endpoint returned an incomplete JSON scene.") from exc | |
| if not isinstance(parsed, dict): | |
| raise ValueError("The endpoint returned JSON, but not a scene object.") | |
| return parsed | |
| def _validate_scene(raw_scene: dict[str, Any], part_budget: int) -> dict[str, Any]: | |
| raw_parts = raw_scene.get("parts", raw_scene.get("objects", [])) | |
| if not isinstance(raw_parts, list) or not raw_parts: | |
| raise ValueError("The generated scene has no renderable parts.") | |
| parts: list[dict[str, Any]] = [] | |
| for index, raw_part in enumerate(raw_parts[: min(part_budget, MAX_PARTS)]): | |
| if not isinstance(raw_part, dict): | |
| continue | |
| primitive = str(raw_part.get("primitive", "box")).lower().strip() | |
| if primitive not in ALLOWED_PRIMITIVES: | |
| primitive = "box" | |
| parts.append( | |
| { | |
| "name": _clean_text(raw_part.get("name"), f"part {index + 1}"), | |
| "primitive": primitive, | |
| "position": _vector(raw_part.get("position"), [0, 0.5, 0], -12, 12), | |
| "rotation": _vector(raw_part.get("rotation"), [0, 0, 0], -math.tau, math.tau), | |
| "scale": _vector(raw_part.get("scale"), [1, 1, 1], 0.05, 10, positive=True), | |
| "color": _color(raw_part.get("color"), "#8A8A8A"), | |
| "roughness": _number(raw_part.get("roughness"), 0.55, 0, 1), | |
| "metalness": _number(raw_part.get("metalness"), 0.05, 0, 1), | |
| "emissive": _color(raw_part.get("emissive"), "#000000"), | |
| "emissiveIntensity": _number(raw_part.get("emissiveIntensity"), 0, 0, 2), | |
| } | |
| ) | |
| if not parts: | |
| raise ValueError("The generated parts could not be normalized safely.") | |
| camera = raw_scene.get("camera") if isinstance(raw_scene.get("camera"), dict) else {} | |
| return { | |
| "title": _clean_text(raw_scene.get("title"), "Untitled object"), | |
| "background": _color(raw_scene.get("background"), "#A9B7C0"), | |
| "ground": _color(raw_scene.get("ground"), "#D7D4C8"), | |
| "camera": { | |
| "position": _vector(camera.get("position"), [5.5, 4.2, 7.5], -20, 20), | |
| "target": _vector(camera.get("target"), [0, 1.5, 0], -12, 12), | |
| "fov": _number(camera.get("fov"), 38, 25, 65), | |
| }, | |
| "autoRotate": bool(raw_scene.get("autoRotate", True)), | |
| "parts": parts, | |
| } | |
| def _model_scene(prompt: str, detail: str, finish: str) -> tuple[dict[str, Any], float]: | |
| token = os.environ.get("HF_TOKEN") | |
| if not token: | |
| raise gr.Error("HF_TOKEN is not configured on this Space.") | |
| part_budget = DETAIL_BUDGETS.get(detail, 36) | |
| request = f"""Build this object or scene: {prompt} | |
| Target detail budget: at most {part_budget} parts. | |
| Art direction: {finish}. | |
| Make one coherent, presentation-ready composition. Return JSON only.""" | |
| client = InferenceClient(model=MODEL_ID, provider="auto", token=token, timeout=180) | |
| started = time.perf_counter() | |
| try: | |
| response = client.chat_completion( | |
| messages=[ | |
| {"role": "system", "content": SYSTEM_PROMPT}, | |
| {"role": "user", "content": request}, | |
| ], | |
| max_tokens=8_000, | |
| temperature=0.25, | |
| top_p=0.95, | |
| response_format={"type": "json_object"}, | |
| extra_body={"reasoning_effort": "low"}, | |
| ) | |
| except Exception as exc: | |
| message = re.sub(r"hf_[A-Za-z0-9]+", "[redacted]", str(exc)) | |
| raise gr.Error(f"Hugging Face inference failed: {message[:300]}") from exc | |
| content = response.choices[0].message.content or "" | |
| scene = _validate_scene(_extract_json(content), part_budget) | |
| return scene, time.perf_counter() - started | |
| RENDER_DOCUMENT = r"""<!doctype html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8"> | |
| <meta name="viewport" content="width=device-width,initial-scale=1"> | |
| <style> | |
| * { box-sizing: border-box; } | |
| html, body { margin: 0; height: 100%; overflow: hidden; background: #111; } | |
| #stage { width: 100%; height: 100%; } | |
| #hud { position: fixed; left: 12px; bottom: 10px; z-index: 2; padding: 5px 8px; | |
| border: 1px solid rgba(255,255,255,.5); border-radius: 3px; color: #fff; | |
| background: rgba(0,0,0,.46); font: 11px Geneva, Arial, sans-serif; letter-spacing: .02em; } | |
| #error { display: none; position: fixed; inset: 18px; padding: 16px; color: #fff; | |
| background: #652c2c; border: 2px solid #fff; font: 13px monospace; white-space: pre-wrap; } | |
| </style> | |
| <script type="importmap">{"imports":{"three":"https://cdn.jsdelivr.net/npm/three@__THREE_VERSION__/build/three.module.js","three/addons/":"https://cdn.jsdelivr.net/npm/three@__THREE_VERSION__/examples/jsm/"}}</script> | |
| </head> | |
| <body> | |
| <div id="stage"></div> | |
| <div id="hud">Drag to orbit · Wheel to zoom · Generated from safe primitives</div> | |
| <div id="error"></div> | |
| <script type="module"> | |
| const spec = __SCENE_JSON__; | |
| try { | |
| const THREE = await import('three'); | |
| const { OrbitControls } = await import('three/addons/controls/OrbitControls.js'); | |
| const host = document.getElementById('stage'); | |
| const scene = new THREE.Scene(); | |
| scene.background = new THREE.Color(spec.background); | |
| scene.fog = new THREE.Fog(spec.background, 18, 34); | |
| const camera = new THREE.PerspectiveCamera(spec.camera.fov, 1, 0.05, 100); | |
| const renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: 'high-performance' }); | |
| renderer.setPixelRatio(Math.min(devicePixelRatio, 2)); | |
| renderer.outputColorSpace = THREE.SRGBColorSpace; | |
| renderer.toneMapping = THREE.ACESFilmicToneMapping; | |
| renderer.toneMappingExposure = 1.08; | |
| renderer.shadowMap.enabled = true; | |
| renderer.shadowMap.type = THREE.PCFSoftShadowMap; | |
| host.appendChild(renderer.domElement); | |
| scene.add(new THREE.HemisphereLight(0xffffff, 0x425060, 1.8)); | |
| const key = new THREE.DirectionalLight(0xfff2dc, 3.6); | |
| key.position.set(7, 10, 8); | |
| key.castShadow = true; | |
| key.shadow.mapSize.set(2048, 2048); | |
| key.shadow.camera.left = key.shadow.camera.bottom = -9; | |
| key.shadow.camera.right = key.shadow.camera.top = 9; | |
| scene.add(key); | |
| const rim = new THREE.DirectionalLight(0x8fc7ff, 2.0); | |
| rim.position.set(-7, 5, -6); | |
| scene.add(rim); | |
| const ground = new THREE.Mesh( | |
| new THREE.PlaneGeometry(60, 60), | |
| new THREE.MeshStandardMaterial({ color: spec.ground, roughness: .92, metalness: 0 }) | |
| ); | |
| ground.rotation.x = -Math.PI / 2; | |
| ground.receiveShadow = true; | |
| scene.add(ground); | |
| const grid = new THREE.GridHelper(30, 30, 0x5b6470, 0x9299a0); | |
| grid.material.opacity = .18; | |
| grid.material.transparent = true; | |
| grid.position.y = .004; | |
| scene.add(grid); | |
| const root = new THREE.Group(); | |
| scene.add(root); | |
| const geometryFor = (part) => { | |
| switch (part.primitive) { | |
| case 'sphere': return new THREE.SphereGeometry(.5, 32, 20); | |
| case 'cylinder': return new THREE.CylinderGeometry(.5, .5, 1, 32); | |
| case 'cone': return new THREE.ConeGeometry(.5, 1, 32); | |
| case 'torus': return new THREE.TorusGeometry(.5, .14, 16, 40); | |
| case 'capsule': return new THREE.CapsuleGeometry(.32, .36, 8, 20); | |
| case 'dodecahedron': return new THREE.DodecahedronGeometry(.5, 0); | |
| default: return new THREE.BoxGeometry(1, 1, 1, 2, 2, 2); | |
| } | |
| }; | |
| for (const part of spec.parts) { | |
| const material = new THREE.MeshStandardMaterial({ | |
| color: part.color, | |
| roughness: part.roughness, | |
| metalness: part.metalness, | |
| emissive: part.emissive, | |
| emissiveIntensity: part.emissiveIntensity | |
| }); | |
| const mesh = new THREE.Mesh(geometryFor(part), material); | |
| mesh.name = part.name; | |
| mesh.position.fromArray(part.position); | |
| mesh.rotation.set(...part.rotation); | |
| mesh.scale.fromArray(part.scale); | |
| mesh.castShadow = true; | |
| mesh.receiveShadow = true; | |
| root.add(mesh); | |
| } | |
| let bounds = new THREE.Box3().setFromObject(root); | |
| if (!bounds.isEmpty()) { | |
| const center = bounds.getCenter(new THREE.Vector3()); | |
| root.position.x -= center.x; | |
| root.position.z -= center.z; | |
| root.position.y -= bounds.min.y; | |
| } | |
| bounds = new THREE.Box3().setFromObject(root); | |
| const size = bounds.getSize(new THREE.Vector3()); | |
| const center = bounds.getCenter(new THREE.Vector3()); | |
| const radius = Math.max(size.x, size.y, size.z, 2); | |
| const direction = new THREE.Vector3(...spec.camera.position).normalize(); | |
| if (direction.lengthSq() < .1) direction.set(.6, .45, .8).normalize(); | |
| camera.position.copy(center).addScaledVector(direction, radius * 2.25); | |
| camera.near = Math.max(.02, radius / 100); | |
| camera.far = Math.max(100, radius * 20); | |
| camera.updateProjectionMatrix(); | |
| const controls = new OrbitControls(camera, renderer.domElement); | |
| controls.target.copy(center); | |
| controls.enableDamping = true; | |
| controls.dampingFactor = .06; | |
| controls.autoRotate = Boolean(spec.autoRotate); | |
| controls.autoRotateSpeed = .55; | |
| controls.minDistance = radius * .65; | |
| controls.maxDistance = radius * 6; | |
| controls.update(); | |
| const resize = () => { | |
| const width = Math.max(1, host.clientWidth); | |
| const height = Math.max(1, host.clientHeight); | |
| renderer.setSize(width, height, false); | |
| camera.aspect = width / height; | |
| camera.updateProjectionMatrix(); | |
| }; | |
| new ResizeObserver(resize).observe(host); | |
| resize(); | |
| renderer.setAnimationLoop(() => { controls.update(); renderer.render(scene, camera); }); | |
| } catch (error) { | |
| const node = document.getElementById('error'); | |
| node.style.display = 'block'; | |
| node.textContent = 'Preview could not start.\n' + String(error); | |
| } | |
| </script> | |
| </body> | |
| </html>""" | |
| def render_preview(scene: dict[str, Any]) -> str: | |
| scene_json = json.dumps(scene, separators=(",", ":"), ensure_ascii=True) | |
| scene_json = scene_json.replace("<", "\\u003c").replace(">", "\\u003e").replace("&", "\\u0026") | |
| document = RENDER_DOCUMENT.replace("__THREE_VERSION__", THREE_VERSION).replace( | |
| "__SCENE_JSON__", scene_json | |
| ) | |
| return ( | |
| '<iframe title="Interactive Three.js preview" ' | |
| 'sandbox="allow-scripts" referrerpolicy="no-referrer" ' | |
| 'style="display:block;width:100%;height:520px;border:0;background:#111" ' | |
| f'srcdoc="{html.escape(document, quote=True)}"></iframe>' | |
| ) | |
| WELCOME_SCENE = { | |
| "title": "Scene Machine", | |
| "background": "#8E99A8", | |
| "ground": "#D9D5C8", | |
| "camera": {"position": [5.5, 4.2, 7.5], "target": [0, 1.5, 0], "fov": 38}, | |
| "autoRotate": True, | |
| "parts": [ | |
| {"name": "monitor", "primitive": "box", "position": [0, 1.8, 0], "rotation": [0, 0, 0], "scale": [2.6, 2.1, 1.6], "color": "#D8D3C4", "roughness": .6, "metalness": 0, "emissive": "#000000", "emissiveIntensity": 0}, | |
| {"name": "screen", "primitive": "box", "position": [0, 2.05, .83], "rotation": [0, 0, 0], "scale": [1.85, 1.25, .06], "color": "#1C2834", "roughness": .25, "metalness": .1, "emissive": "#24384A", "emissiveIntensity": .45}, | |
| {"name": "base", "primitive": "box", "position": [0, .45, .25], "rotation": [-.08, 0, 0], "scale": [2.8, .28, 1.4], "color": "#D8D3C4", "roughness": .65, "metalness": 0, "emissive": "#000000", "emissiveIntensity": 0}, | |
| {"name": "power button", "primitive": "sphere", "position": [.95, 1.25, .84], "rotation": [0, 0, 0], "scale": [.16, .16, .08], "color": "#6F7A71", "roughness": .4, "metalness": .05, "emissive": "#78C878", "emissiveIntensity": .35}, | |
| ], | |
| } | |
| def generate_scene(prompt: str, detail: str, finish: str) -> tuple[str, str, str]: | |
| prompt = re.sub(r"\s+", " ", str(prompt or "")).strip() | |
| if not prompt: | |
| raise gr.Error("Describe an object or small scene first.") | |
| if len(prompt) > MAX_PROMPT_LENGTH: | |
| raise gr.Error(f"Keep the description under {MAX_PROMPT_LENGTH:,} characters.") | |
| scene, elapsed = _model_scene(prompt, detail, finish) | |
| spec_text = json.dumps(scene, indent=2, ensure_ascii=False) | |
| status = ( | |
| f"**Built {len(scene['parts'])} safe primitive parts** · " | |
| f"{elapsed:.1f}s · `{MODEL_ID}` via Hugging Face Inference Providers" | |
| ) | |
| return render_preview(scene), spec_text, status | |
| CSS = r""" | |
| :root { | |
| --os9-gray: #c8c8c8; | |
| --os9-light: #ffffff; | |
| --os9-mid: #999999; | |
| --os9-dark: #222222; | |
| --os9-blue: #1f4ea8; | |
| } | |
| body, .gradio-container { | |
| background: #777f8c !important; | |
| color: #111 !important; | |
| font-family: Charcoal, Chicago, Geneva, "Lucida Grande", Arial, sans-serif !important; | |
| } | |
| .gradio-container { max-width: none !important; padding: 0 !important; } | |
| #menu-bar { | |
| background: #f4f4f4; border-bottom: 2px solid #111; min-height: 29px; | |
| padding: 4px 12px; font-size: 13px; box-shadow: 0 1px 0 #fff; | |
| } | |
| #menu-bar p { margin: 0 !important; } | |
| #menu-bar strong { margin-right: 24px; } | |
| .rainbow-mark { display:inline-block; width:13px; height:13px; margin-right:8px; vertical-align:-2px; | |
| border:1px solid #111; background:linear-gradient(180deg,#69b34c 0 17%,#f6c445 17% 34%,#f6921e 34% 51%,#e83f36 51% 68%,#8f4aa8 68% 84%,#326db3 84%); } | |
| #desktop { max-width: 1260px; margin: 24px auto 28px; padding: 0 18px; } | |
| .os-window { | |
| border: 2px solid #111 !important; border-radius: 0 !important; background: var(--os9-gray) !important; | |
| padding: 5px !important; box-shadow: 3px 3px 0 rgba(0,0,0,.75) !important; | |
| } | |
| .titlebar { | |
| border: 1px solid #111; min-height: 22px; padding: 2px 32px; margin-bottom: 7px; | |
| text-align: center; font-size: 12px; line-height: 16px; font-weight: 700; | |
| background: repeating-linear-gradient(0deg,#fff 0,#fff 1px,#777 1px,#777 2px); | |
| position: relative; | |
| } | |
| .titlebar:before { content:""; position:absolute; left:3px; top:3px; width:12px; height:12px; | |
| background:#d0d0d0; border:1px solid #111; box-shadow:inset 1px 1px #fff; } | |
| .titlebar span { background:var(--os9-gray); padding:0 8px; } | |
| #hero-copy { padding: 12px 14px 4px; } | |
| #hero-copy h1 { font-size: 25px; letter-spacing: -.03em; margin: 0 0 6px; } | |
| #hero-copy p { font-size: 13px; line-height: 1.5; margin: 0; max-width: 720px; } | |
| .gradio-container .form, .gradio-container .block { | |
| border-radius: 0 !important; | |
| } | |
| .gradio-container label, .gradio-container .label-wrap { font-size: 12px !important; font-weight: 700 !important; } | |
| .gradio-container textarea, .gradio-container input, .gradio-container select { | |
| border: 2px inset #eee !important; border-radius: 0 !important; background: #fff !important; | |
| color: #111 !important; font-family: Geneva, Arial, sans-serif !important; | |
| } | |
| .gradio-container button { | |
| border: 1px solid #111 !important; border-radius: 4px !important; color: #111 !important; | |
| background: linear-gradient(#fff,#bcbcbc) !important; box-shadow: inset 1px 1px #fff, 1px 1px #555 !important; | |
| font-family: Charcoal, Geneva, Arial, sans-serif !important; font-weight: 700 !important; | |
| } | |
| .gradio-container button.primary { | |
| border: 2px solid #111 !important; background: linear-gradient(#fff,#bfc9dd) !important; | |
| } | |
| .gradio-container button:active { transform: translate(1px,1px); box-shadow: inset 1px 1px #777 !important; } | |
| #preview-frame { border: 2px inset #eee !important; padding: 0 !important; background: #111 !important; } | |
| #status-strip { border:1px inset #eee; background:#dedede; padding:6px 8px; min-height:31px; font-size:11px; } | |
| #status-strip p { margin:0 !important; } | |
| .footer-note { text-align:center; color:#f2f2f2; font-size:11px; text-shadow:1px 1px #222; } | |
| .footer-note a { color:#fff !important; text-decoration:underline; } | |
| details { border-radius:0 !important; } | |
| @media (max-width: 720px) { | |
| #desktop { margin-top: 12px; padding: 0 9px; } | |
| #menu-bar { font-size: 11px; } | |
| #menu-bar strong { margin-right: 10px; } | |
| } | |
| """ | |
| with gr.Blocks(title="Scene Machine 9") as demo: | |
| gr.HTML( | |
| '<div id="menu-bar"><p><span class="rainbow-mark"></span><strong>Scene Machine</strong>' | |
| "File Edit Model View Help</p></div>" | |
| ) | |
| with gr.Column(elem_id="desktop"): | |
| with gr.Column(elem_classes="os-window"): | |
| gr.HTML('<div class="titlebar"><span>Scene Machine 9</span></div>') | |
| gr.Markdown( | |
| "# Describe it. Build it. Orbit it.\n" | |
| "Turn a short description into a procedural Three.js study. DeepSeek V4 plans the " | |
| "parts; a strict renderer accepts only safe geometry and PBR material fields.", | |
| elem_id="hero-copy", | |
| ) | |
| with gr.Row(equal_height=False): | |
| with gr.Column(scale=4, min_width=300, elem_classes="os-window"): | |
| gr.HTML('<div class="titlebar"><span>New Object</span></div>') | |
| prompt = gr.Textbox( | |
| label="Object description", | |
| placeholder="Example: a translucent tangerine desktop computer from 1999…", | |
| lines=6, | |
| max_lines=9, | |
| max_length=MAX_PROMPT_LENGTH, | |
| autofocus=True, | |
| ) | |
| detail = gr.Radio( | |
| choices=list(DETAIL_BUDGETS), | |
| value="Balanced · 20–36 parts", | |
| label="Geometry budget", | |
| ) | |
| finish = gr.Dropdown( | |
| choices=[ | |
| "Faithful product visualization", | |
| "Playful low-poly diorama", | |
| "Polished retro-futurist collectible", | |
| "Industrial design maquette", | |
| ], | |
| value="Faithful product visualization", | |
| label="Art direction", | |
| ) | |
| build = gr.Button("Build 3D Object", variant="primary") | |
| clear = gr.ClearButton([prompt], value="Clear") | |
| with gr.Column(scale=7, min_width=420, elem_classes="os-window"): | |
| gr.HTML('<div class="titlebar"><span>Untitled 3D View</span></div>') | |
| preview = gr.HTML(render_preview(WELCOME_SCENE), elem_id="preview-frame") | |
| status = gr.Markdown( | |
| "**Ready.** The preview runs in a sandboxed iframe; generated code is never executed.", | |
| elem_id="status-strip", | |
| ) | |
| with gr.Accordion("Scene specification · JSON", open=False, elem_classes="os-window"): | |
| spec = gr.Code(label="Normalized safe scene", language="json", interactive=False, lines=22) | |
| with gr.Column(elem_classes="os-window"): | |
| gr.HTML('<div class="titlebar"><span>Try an Example</span></div>') | |
| gr.Examples( | |
| examples=[ | |
| [ | |
| "A translucent tangerine all-in-one desktop computer from 1999, with a curved CRT shell, handle, keyboard, round mouse, vents, ports, and softly glowing screen", | |
| "Intricate · 36–64 parts", | |
| "Faithful product visualization", | |
| ], | |
| [ | |
| "A compact yellow construction excavator with tracked undercarriage, articulated boom, hydraulic cylinders, cab glass, bucket teeth, and safety rails", | |
| "Balanced · 20–36 parts", | |
| "Industrial design maquette", | |
| ], | |
| [ | |
| "A tabletop brass orrery with the sun, six planets, fine orbital rings, geared supports, and a dark walnut base", | |
| "Intricate · 36–64 parts", | |
| "Polished retro-futurist collectible", | |
| ], | |
| [ | |
| "A tiny cedar cabin diorama with a steep roof, stone chimney, porch, glowing windows, pine trees, and stepping stones", | |
| "Balanced · 20–36 parts", | |
| "Playful low-poly diorama", | |
| ], | |
| ], | |
| inputs=[prompt, detail, finish], | |
| outputs=[preview, spec, status], | |
| fn=generate_scene, | |
| cache_examples=True, | |
| cache_mode="lazy", | |
| ) | |
| gr.HTML( | |
| '<p class="footer-note">Text inference by ' | |
| '<a href="https://huggingface.co/deepseek-ai/DeepSeek-V4-Flash-0731" target="_blank">' | |
| "DeepSeek V4 Flash</a> on Hugging Face · Rendering by Three.js · " | |
| '<a href="https://github.com/img2threejs/img2threejs" target="_blank">' | |
| "Inspired by img2threejs</a></p>" | |
| ) | |
| generation = build.click( | |
| fn=generate_scene, | |
| inputs=[prompt, detail, finish], | |
| outputs=[preview, spec, status], | |
| api_name="generate", | |
| api_description="Generate a normalized procedural Three.js scene from text.", | |
| show_progress="full", | |
| concurrency_limit=3, | |
| ) | |
| prompt.submit( | |
| fn=generate_scene, | |
| inputs=[prompt, detail, finish], | |
| outputs=[preview, spec, status], | |
| api_name="_submit", | |
| api_visibility="private", | |
| show_progress="full", | |
| concurrency_limit=3, | |
| ) | |
| if __name__ == "__main__": | |
| demo.queue(default_concurrency_limit=3, max_size=24).launch(css=CSS) | |