glb-studio / standalone.html
GLB Studio Deploy
🎬 Initial release: GLB Animation Studio
cad548d
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<title>GLB Animation Studio</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Space+Mono:ital,wght@0,400;0,700;1,400&family=Orbitron:wght@400;700;900&display=swap" rel="stylesheet">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body, #root { width: 100%; height: 100%; overflow: hidden; }
body { background: #080810; color: #fff; font-family: 'Space Mono', monospace; }
input[type=range] { accent-color: #00f5ff; cursor: pointer; }
input[type=number], input[type=text] { outline: none; }
select { outline: none; cursor: pointer; }
button { outline: none; }
::-webkit-scrollbar { width: 4px; height: 4px; }
::-webkit-scrollbar-track { background: rgba(255,255,255,0.02); }
::-webkit-scrollbar-thumb { background: rgba(0,245,255,0.25); border-radius: 2px; }
* { -webkit-tap-highlight-color: transparent; }
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.4} }
@keyframes fadeIn { from{opacity:0;transform:translateY(8px)} to{opacity:1;transform:none} }
</style>
</head>
<body>
<div id="root">
<div style="display:flex;align-items:center;justify-content:center;height:100vh;flex-direction:column;gap:16px;">
<div style="font-family:Orbitron,monospace;font-size:28px;font-weight:900;color:#00f5ff;text-shadow:0 0 30px rgba(0,245,255,0.6);letter-spacing:0.1em;">
GLB<span style="color:#ff4080">STUDIO</span>
</div>
<div style="color:#00f5ff;font-size:13px;animation:pulse 1.5s ease infinite;letter-spacing:0.2em;">LOADING ENGINE...</div>
</div>
</div>
<!-- Three.js + React via CDN -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/18.3.1/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/18.3.1/umd/react-dom.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script type="module">
// ─── Minimal Zustand-like store ──────────────────────────────────────────────
const { useState, useEffect, useRef, useCallback, useMemo, Suspense, createContext, useContext } = React;
function createStore(initializer) {
let state;
const listeners = new Set();
const getState = () => state;
const setState = (updater) => {
const next = typeof updater === 'function' ? updater(state) : updater;
state = { ...state, ...next };
listeners.forEach(l => l(state));
};
state = initializer(setState, getState);
const useStore = (selector = s => s) => {
const [, setTick] = useState(0);
const sel = useRef(selector);
sel.current = selector;
const prev = useRef(selector(state));
useEffect(() => {
const listener = (s) => {
const next = sel.current(s);
if (next !== prev.current) { prev.current = next; setTick(t => t + 1); }
};
listeners.add(listener);
return () => listeners.delete(listener);
}, []);
return selector(state);
};
useStore.getState = getState;
useStore.setState = setState;
return useStore;
}
// ─── Store ────────────────────────────────────────────────────────────────────
const uid = () => Math.random().toString(36).substr(2, 9);
const useStore = createStore((set, get) => ({
models: [],
selectedModelId: null,
transformMode: 'translate',
totalFrames: 300,
currentFrame: 0,
fps: 30,
isPlaying: false,
keyframes: {},
lightingPreset: 'studio',
activePanel: 'models',
showTimeline: true,
isExporting: false,
exportProgress: 0,
exportedVideoUrl: null,
addModel: (url, name) => {
const id = uid();
const model = {
id, url, name: name || url.split('/').pop().split('?')[0],
position: [0,0,0], rotation: [0,0,0], scale: [1,1,1],
visible: true, animations: [], activeAnimation: null,
animationSpeed: 1, animationPlaying: false,
};
set(s => ({ models: [...s.models, model], selectedModelId: id }));
return id;
},
removeModel: (id) => set(s => ({
models: s.models.filter(m => m.id !== id),
selectedModelId: s.selectedModelId === id ? null : s.selectedModelId,
})),
selectModel: (id) => set(() => ({ selectedModelId: id })),
updateModelTransform: (id, type, value) => set(s => ({
models: s.models.map(m => m.id === id ? { ...m, [type]: value } : m)
})),
setModelAnimations: (id, anims) => set(s => ({
models: s.models.map(m => m.id === id ? { ...m, animations: anims, activeAnimation: anims[0] || null } : m)
})),
setModelActiveAnimation: (id, anim) => set(s => ({
models: s.models.map(m => m.id === id ? { ...m, activeAnimation: anim } : m)
})),
setModelAnimSpeed: (id, speed) => set(s => ({
models: s.models.map(m => m.id === id ? { ...m, animationSpeed: speed } : m)
})),
toggleModelVisibility: (id) => set(s => ({
models: s.models.map(m => m.id === id ? { ...m, visible: !m.visible } : m)
})),
setTransformMode: (mode) => set(() => ({ transformMode: mode })),
setCurrentFrame: (f) => set(s => ({ currentFrame: Math.max(0, Math.min(f, s.totalFrames - 1)) })),
setIsPlaying: (v) => set(() => ({ isPlaying: v })),
setTotalFrames: (v) => set(() => ({ totalFrames: v })),
setLightingPreset: (p) => set(() => ({ lightingPreset: p })),
setActivePanel: (p) => set(() => ({ activePanel: p })),
setShowTimeline: (v) => set(() => ({ showTimeline: v })),
setIsExporting: (v) => set(() => ({ isExporting: v })),
setExportProgress: (v) => set(() => ({ exportProgress: v })),
setExportedVideoUrl: (url) => set(() => ({ exportedVideoUrl: url })),
addKeyframe: (frame, modelId) => {
const s = get();
const model = s.models.find(m => m.id === modelId);
if (!model) return;
const kf = {
...s.keyframes,
[frame]: {
...(s.keyframes[frame] || {}),
[modelId]: {
position: [...model.position],
rotation: [...model.rotation],
scale: [...model.scale],
animation: model.activeAnimation,
animationSpeed: model.animationSpeed,
}
}
};
set(() => ({ keyframes: kf }));
},
removeKeyframe: (frame, modelId) => set(s => {
const kf = { ...s.keyframes };
if (kf[frame]) {
kf[frame] = { ...kf[frame] };
delete kf[frame][modelId];
if (!Object.keys(kf[frame]).length) delete kf[frame];
}
return { keyframes: kf };
}),
moveKeyframe: (oldFrame, newFrame, modelId) => set(s => {
if (!s.keyframes[oldFrame]?.[modelId]) return {};
const kf = JSON.parse(JSON.stringify(s.keyframes));
const data = kf[oldFrame][modelId];
delete kf[oldFrame][modelId];
if (!Object.keys(kf[oldFrame]).length) delete kf[oldFrame];
if (!kf[newFrame]) kf[newFrame] = {};
kf[newFrame][modelId] = data;
return { keyframes: kf };
}),
getKeyframesForModel: (modelId) => {
const { keyframes } = get();
return Object.entries(keyframes)
.filter(([, kf]) => kf[modelId])
.map(([f, kf]) => ({ frame: parseInt(f), data: kf[modelId] }))
.sort((a, b) => a.frame - b.frame);
},
interpolateAtFrame: (modelId, frame) => {
const { keyframes } = get();
const allFrames = Object.keys(keyframes).map(Number).sort((a,b) => a-b);
const mf = allFrames.filter(f => keyframes[f]?.[modelId]);
if (!mf.length) return null;
const before = mf.filter(f => f <= frame);
const after = mf.filter(f => f > frame);
if (!before.length) return keyframes[mf[0]][modelId];
if (!after.length) return keyframes[mf[mf.length-1]][modelId];
const f0 = before[before.length-1], f1 = after[0];
const t = (frame - f0) / (f1 - f0);
const k0 = keyframes[f0][modelId], k1 = keyframes[f1][modelId];
const lerp = (a,b,t) => a + (b-a)*t;
const lerpArr = (a,b,t) => a.map((v,i) => lerp(v, b[i], t));
return {
position: lerpArr(k0.position, k1.position, t),
rotation: lerpArr(k0.rotation, k1.rotation, t),
scale: lerpArr(k0.scale, k1.scale, t),
animation: t < 0.5 ? k0.animation : k1.animation,
};
},
}));
// ─── Three.js Canvas Scene ─────────────────────────────────────────────────
// We implement a custom R3F-lite approach using Three.js directly
class ThreeScene {
constructor(container) {
this.container = container;
this.models = new Map(); // id -> { group, mixer, actions }
this.animFrameId = null;
this.clock = new THREE.Clock();
this.init();
}
init() {
const W = this.container.clientWidth, H = this.container.clientHeight;
// Renderer
this.renderer = new THREE.WebGLRenderer({
antialias: true, alpha: false,
preserveDrawingBuffer: true,
});
this.renderer.setSize(W, H);
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.5));
this.renderer.shadowMap.enabled = true;
this.renderer.shadowMap.type = THREE.PCFSoftShadowMap;
this.renderer.toneMapping = THREE.ACESFilmicToneMapping;
this.renderer.toneMappingExposure = 1.2;
this.renderer.outputColorSpace = THREE.SRGBColorSpace || 'srgb';
this.container.appendChild(this.renderer.domElement);
// Scene
this.scene = new THREE.Scene();
this.scene.background = new THREE.Color(0x080810);
this.scene.fog = new THREE.FogExp2(0x080810, 0.02);
// Camera
this.camera = new THREE.PerspectiveCamera(50, W/H, 0.01, 1000);
this.camera.position.set(5, 3, 5);
this.camera.lookAt(0, 0, 0);
// Grid
const grid = new THREE.GridHelper(30, 30, 0x1a1a3a, 0x111128);
grid.material.opacity = 0.6;
grid.material.transparent = true;
this.scene.add(grid);
// Floor
const floorGeo = new THREE.PlaneGeometry(50, 50);
const floorMat = new THREE.MeshStandardMaterial({ color: 0x0d0d1a, roughness: 0.9, metalness: 0.1 });
const floor = new THREE.Mesh(floorGeo, floorMat);
floor.rotation.x = -Math.PI / 2;
floor.position.y = -0.001;
floor.receiveShadow = true;
this.scene.add(floor);
// Lights
this.lights = {};
this.setupLights('studio');
// Controls (custom orbit)
this.setupOrbitControls();
// Raycaster for selection
this.raycaster = new THREE.Raycaster();
this.mouse = new THREE.Vector2();
this.container.addEventListener('click', this.onSceneClick.bind(this), false);
// Resize observer
this.ro = new ResizeObserver(() => this.onResize());
this.ro.observe(this.container);
this.render();
}
setupLights(preset) {
// Remove old lights
Object.values(this.lights).forEach(l => this.scene.remove(l));
this.lights = {};
const configs = {
studio: { amb: [0x1a1a2e, 0.4], key: [0xfff5e0, 2, [5,8,3]], fill: [0xc0d8ff, 0.6, [-4,4,-2]], rim: [0xffefcc, 0.8, [0,6,-6]] },
outdoor: { amb: [0xb0c8ff, 0.6], key: [0xfff8e1, 3, [10,20,5]], fill: [0xd0e8ff, 0.4, [-8,5,-3]], rim: [0x90b8ff, 0.3, [0,8,-8]] },
dramatic: { amb: [0x0a0a1a, 0.1], key: [0xff6020, 4, [3,10,2]], fill: [0x200840, 0.2, [-6,2,-2]], rim: [0x8040ff, 1.2, [0,4,-8]] },
neon: { amb: [0x0a0020, 0.15], key: [0x00ffff, 2, [5,6,3]], fill: [0xff00aa, 1.5, [-5,3,-3]], rim: [0xaaff00, 1, [0,8,-6]] },
};
const cfg = configs[preset] || configs.studio;
const amb = new THREE.AmbientLight(cfg.amb[0], cfg.amb[1]);
this.scene.add(amb); this.lights.amb = amb;
const key = new THREE.DirectionalLight(cfg.key[0], cfg.key[1]);
key.position.set(...cfg.key[2]);
key.castShadow = true;
key.shadow.mapSize.set(1024, 1024);
key.shadow.camera.near = 0.1; key.shadow.camera.far = 100;
key.shadow.camera.left = key.shadow.camera.bottom = -20;
key.shadow.camera.right = key.shadow.camera.top = 20;
this.scene.add(key); this.lights.key = key;
const fill = new THREE.DirectionalLight(cfg.fill[0], cfg.fill[1]);
fill.position.set(...cfg.fill[2]); this.scene.add(fill); this.lights.fill = fill;
const rim = new THREE.DirectionalLight(cfg.rim[0], cfg.rim[1]);
rim.position.set(...cfg.rim[2]); this.scene.add(rim); this.lights.rim = rim;
}
setupOrbitControls() {
const canvas = this.renderer.domElement;
let isDragging = false, lastX = 0, lastY = 0;
let isRightClick = false;
let spherical = { theta: Math.PI / 4, phi: Math.PI / 3, radius: 7 };
let target = new THREE.Vector3(0, 0, 0);
const updateCamera = () => {
const x = spherical.radius * Math.sin(spherical.phi) * Math.sin(spherical.theta);
const y = spherical.radius * Math.cos(spherical.phi);
const z = spherical.radius * Math.sin(spherical.phi) * Math.cos(spherical.theta);
this.camera.position.set(target.x + x, target.y + y, target.z + z);
this.camera.lookAt(target);
};
updateCamera();
canvas.addEventListener('contextmenu', e => e.preventDefault());
canvas.addEventListener('mousedown', e => {
isDragging = true; lastX = e.clientX; lastY = e.clientY;
isRightClick = e.button === 2;
});
window.addEventListener('mouseup', () => { isDragging = false; });
window.addEventListener('mousemove', e => {
if (!isDragging) return;
const dx = e.clientX - lastX, dy = e.clientY - lastY;
lastX = e.clientX; lastY = e.clientY;
if (isRightClick) {
// Pan
const panSpeed = spherical.radius * 0.001;
const right = new THREE.Vector3();
right.crossVectors(this.camera.getWorldDirection(new THREE.Vector3()), this.camera.up).normalize();
target.addScaledVector(right, -dx * panSpeed);
target.y += dy * panSpeed;
} else {
spherical.theta -= dx * 0.008;
spherical.phi = Math.max(0.05, Math.min(Math.PI - 0.05, spherical.phi - dy * 0.008));
}
updateCamera();
});
canvas.addEventListener('wheel', e => {
e.preventDefault();
spherical.radius = Math.max(0.5, Math.min(100, spherical.radius * (1 + e.deltaY * 0.001)));
updateCamera();
}, { passive: false });
// Touch controls
let touches = [], pinchDist = 0;
canvas.addEventListener('touchstart', e => {
touches = Array.from(e.touches);
if (touches.length === 2) pinchDist = Math.hypot(touches[0].clientX - touches[1].clientX, touches[0].clientY - touches[1].clientY);
}, { passive: true });
canvas.addEventListener('touchmove', e => {
const newTouches = Array.from(e.touches);
if (newTouches.length === 1 && touches.length === 1) {
const dx = newTouches[0].clientX - touches[0].clientX;
const dy = newTouches[0].clientY - touches[0].clientY;
spherical.theta -= dx * 0.008;
spherical.phi = Math.max(0.05, Math.min(Math.PI - 0.05, spherical.phi - dy * 0.008));
updateCamera();
} else if (newTouches.length === 2 && touches.length === 2) {
const newDist = Math.hypot(newTouches[0].clientX - newTouches[1].clientX, newTouches[0].clientY - newTouches[1].clientY);
spherical.radius = Math.max(0.5, Math.min(100, spherical.radius * (pinchDist / newDist)));
pinchDist = newDist;
updateCamera();
}
touches = newTouches;
}, { passive: true });
this.updateCamera = updateCamera;
this.spherical = spherical;
this.orbitTarget = target;
}
onSceneClick(e) {
// Convert click to normalized device coords
const rect = this.container.getBoundingClientRect();
this.mouse.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
this.mouse.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
this.raycaster.setFromCamera(this.mouse, this.camera);
// Collect all meshes
const meshes = [];
this.models.forEach(({ group }, id) => {
group.traverse(child => {
if (child.isMesh) { child.userData.modelId = id; meshes.push(child); }
});
});
const hits = this.raycaster.intersectObjects(meshes, false);
if (hits.length > 0) {
useStore.setState({ selectedModelId: hits[0].object.userData.modelId });
} else {
useStore.setState({ selectedModelId: null });
}
}
onResize() {
const W = this.container.clientWidth, H = this.container.clientHeight;
this.camera.aspect = W / H;
this.camera.updateProjectionMatrix();
this.renderer.setSize(W, H);
}
loadModel(url, id) {
// Use GLTFLoader via dynamic import fallback
const loader = new THREE.GLTFLoader ? new THREE.GLTFLoader() : null;
if (!loader) {
console.warn('GLTFLoader not available via CDN THREE.js r128, using a placeholder cube');
this.addPlaceholder(id);
return;
}
loader.load(url,
(gltf) => {
const group = gltf.scene;
group.traverse(child => {
if (child.isMesh) { child.castShadow = true; child.receiveShadow = true; }
});
// Auto-center
const box = new THREE.Box3().setFromObject(group);
const center = box.getCenter(new THREE.Vector3());
const size = box.getSize(new THREE.Vector3());
const maxDim = Math.max(size.x, size.y, size.z);
if (maxDim > 0) {
const scale = 2 / maxDim;
group.scale.setScalar(scale);
group.position.set(-center.x * scale, -box.min.y * scale, -center.z * scale);
useStore.setState({ models: useStore.getState().models.map(m =>
m.id === id ? { ...m, scale: [scale,scale,scale], position: [-center.x*scale, -box.min.y*scale, -center.z*scale] } : m
)});
}
this.scene.add(group);
// Animations
const mixer = new THREE.AnimationMixer(group);
const actions = {};
gltf.animations.forEach(clip => {
actions[clip.name] = mixer.clipAction(clip);
});
if (gltf.animations.length > 0) {
const firstName = gltf.animations[0].name;
actions[firstName]?.play();
useStore.getState().setModelAnimations(id, gltf.animations.map(a => a.name));
}
this.models.set(id, { group, mixer, actions });
},
undefined,
(err) => {
console.error('Failed to load model:', err);
this.addPlaceholder(id);
}
);
}
addPlaceholder(id) {
const colors = [0x00f5ff, 0xff4080, 0x40ff80, 0xffaa00, 0xaa40ff];
const idx = this.models.size % colors.length;
const geo = new THREE.BoxGeometry(1, 1, 1);
const mat = new THREE.MeshStandardMaterial({ color: colors[idx], roughness: 0.3, metalness: 0.6 });
const mesh = new THREE.Mesh(geo, mat);
mesh.castShadow = true;
const group = new THREE.Group();
group.add(mesh);
this.scene.add(group);
this.models.set(id, { group, mixer: null, actions: {} });
}
removeModel(id) {
const entry = this.models.get(id);
if (entry) {
this.scene.remove(entry.group);
this.models.delete(id);
}
}
updateModelFromState(model, interpolated) {
const entry = this.models.get(model.id);
if (!entry) return;
const { group, mixer, actions } = entry;
const data = interpolated || model;
group.position.set(...data.position);
group.rotation.set(...data.rotation);
group.scale.set(...data.scale);
group.visible = model.visible;
// Handle animation
if (mixer && model.activeAnimation && actions[model.activeAnimation]) {
// Only switch if needed
if (!group._activeAnim || group._activeAnim !== model.activeAnimation) {
Object.values(actions).forEach(a => a.stop());
const action = actions[model.activeAnimation];
action.setEffectiveTimeScale(model.animationSpeed);
action.play();
group._activeAnim = model.activeAnimation;
}
}
// Selection indicator
if (entry.selectionRing) entry.selectionRing.visible = model.id === useStore.getState().selectedModelId;
if (!entry.selectionRing) {
const ring = new THREE.Mesh(
new THREE.TorusGeometry(0.8, 0.03, 8, 32),
new THREE.MeshBasicMaterial({ color: 0x00f5ff })
);
ring.rotation.x = Math.PI / 2;
ring.visible = false;
group.add(ring);
entry.selectionRing = ring;
}
entry.selectionRing.visible = model.id === useStore.getState().selectedModelId;
}
syncFromStore() {
const { models, currentFrame, isPlaying } = useStore.getState();
const loadedIds = new Set(this.models.keys());
models.forEach(model => {
if (!this.models.has(model.id)) {
this.loadModel(model.url, model.id);
} else {
const interpolated = useStore.getState().interpolateAtFrame(model.id, currentFrame);
this.updateModelFromState(model, interpolated);
}
loadedIds.delete(model.id);
});
// Remove deleted models
loadedIds.forEach(id => this.removeModel(id));
}
render() {
this.animFrameId = requestAnimationFrame(() => this.render());
const delta = this.clock.getDelta();
// Update mixers
this.models.forEach(({ mixer }) => mixer?.update(delta));
// Sync from store
this.syncFromStore();
this.renderer.render(this.scene, this.camera);
}
getCanvas() { return this.renderer.domElement; }
destroy() {
if (this.animFrameId) cancelAnimationFrame(this.animFrameId);
this.ro?.disconnect();
this.renderer.dispose();
}
}
// ─── THREE.GLTFLoader dynamic loading ─────────────────────────────────────────
// We need to load GLTFLoader which isn't in the base THREE CDN build
// Load it from another source
const GLTFLoaderScript = document.createElement('script');
GLTFLoaderScript.src = 'https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/loaders/GLTFLoader.js';
document.head.appendChild(GLTFLoaderScript);
const DRACOLoaderScript = document.createElement('script');
DRACOLoaderScript.src = 'https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/loaders/DRACOLoader.js';
document.head.appendChild(DRACOLoaderScript);
// ─── React Components ────────────────────────────────────────────────────────
function SceneView() {
const containerRef = useRef(null);
const threeRef = useRef(null);
useEffect(() => {
if (!containerRef.current || threeRef.current) return;
// Wait for loaders to be available
const tryInit = () => {
if (!THREE.GLTFLoader) { setTimeout(tryInit, 200); return; }
threeRef.current = new ThreeScene(containerRef.current);
};
setTimeout(tryInit, 300);
return () => {
threeRef.current?.destroy();
threeRef.current = null;
};
}, []);
// Playback timer
const { isPlaying, currentFrame, totalFrames, fps, setCurrentFrame, setIsPlaying } = {
isPlaying: useStore(s => s.isPlaying),
currentFrame: useStore(s => s.currentFrame),
totalFrames: useStore(s => s.totalFrames),
fps: useStore(s => s.fps),
setCurrentFrame: useStore.getState().setCurrentFrame,
setIsPlaying: useStore.getState().setIsPlaying,
};
useEffect(() => {
if (!isPlaying) return;
const interval = setInterval(() => {
const cur = useStore.getState().currentFrame;
const tot = useStore.getState().totalFrames;
if (cur + 1 >= tot) { setIsPlaying(false); setCurrentFrame(0); }
else setCurrentFrame(cur + 1);
}, 1000 / fps);
return () => clearInterval(interval);
}, [isPlaying, fps]);
return (
<div
ref={containerRef}
style={{ position: 'absolute', inset: 0, background: '#080810' }}
/>
);
}
// ─── UI Components ────────────────────────────────────────────────────────────
const COLORS = ['#00f5ff','#ff4080','#40ff80','#ffaa00','#aa40ff','#ff8040'];
const DEG = 180 / Math.PI;
function VecInput({ label, value, onChange, step=0.05, scale=1, decimals=2 }) {
const axes = ['X','Y','Z'];
const axColors = ['#ff5060','#60ff80','#4080ff'];
return (
<div style={{ marginBottom: 10 }}>
<div style={{ fontSize: 10, color: '#555', marginBottom: 4, letterSpacing: '0.1em' }}>{label}</div>
<div style={{ display: 'flex', gap: 4 }}>
{axes.map((ax, i) => (
<div key={ax} style={{ flex: 1 }}>
<div style={{ fontSize: 9, color: axColors[i], marginBottom: 2 }}>{ax}</div>
<input
type="number" step={step}
value={(value[i] * scale).toFixed(decimals)}
onChange={e => {
const v = parseFloat(e.target.value) || 0;
const arr = [...value]; arr[i] = v / scale; onChange(arr);
}}
style={{
width: '100%', background: 'rgba(255,255,255,0.04)',
border: `1px solid ${axColors[i]}44`,
color: '#ddd', padding: '5px 4px',
borderRadius: 4, fontSize: 11,
fontFamily: 'Space Mono', display: 'block',
}}
/>
</div>
))}
</div>
</div>
);
}
function PropertiesPanel() {
const models = useStore(s => s.models);
const selectedId = useStore(s => s.selectedModelId);
const currentFrame = useStore(s => s.currentFrame);
const keyframes = useStore(s => s.keyframes);
const { updateModelTransform, setModelActiveAnimation, setModelAnimSpeed, addKeyframe, removeKeyframe, removeModel, selectModel } = useStore.getState();
const model = models.find(m => m.id === selectedId);
const kfList = selectedId ? useStore.getState().getKeyframesForModel(selectedId) : [];
const hasKfNow = keyframes[currentFrame]?.[selectedId];
if (!model) return (
<div style={{ padding: 20, textAlign: 'center', color: '#333', fontSize: 12, lineHeight: 1.8 }}>
<div style={{ fontSize: 28, marginBottom: 8, opacity: 0.2 }}></div>
Tap a model in the scene to select
</div>
);
return (
<div style={{ padding: 10, overflow: 'auto', maxHeight: '100%' }}>
<div style={{ marginBottom: 10 }}>
<div style={{ fontSize: 10, color: '#00f5ff', letterSpacing: '0.15em', marginBottom: 3 }}>SELECTED</div>
<div style={{ fontSize: 12, color: '#fff', fontWeight: 700, overflow: 'hidden', textOverflow: 'ellipsis' }}>{model.name}</div>
</div>
<hr style={{ border: 'none', borderTop: '1px solid rgba(255,255,255,0.07)', margin: '8px 0' }} />
<VecInput label="POSITION" value={model.position} step={0.1}
onChange={v => updateModelTransform(model.id, 'position', v)} />
<VecInput label="ROTATION °" value={model.rotation} step={1} scale={DEG}
onChange={v => updateModelTransform(model.id, 'rotation', v)} />
<VecInput label="SCALE" value={model.scale} step={0.05}
onChange={v => updateModelTransform(model.id, 'scale', v)} />
{model.animations.length > 0 && <>
<hr style={{ border: 'none', borderTop: '1px solid rgba(255,255,255,0.07)', margin: '8px 0' }} />
<div style={{ fontSize: 10, color: '#555', marginBottom: 4, letterSpacing: '0.1em' }}>ANIMATION</div>
<select value={model.activeAnimation || ''} onChange={e => setModelActiveAnimation(model.id, e.target.value)}
style={{ width: '100%', background: '#0d0d1a', border: '1px solid rgba(255,255,255,0.1)', color: '#ddd', padding: '6px', borderRadius: 4, fontSize: 11, fontFamily: 'Space Mono', marginBottom: 6 }}>
{model.animations.map(a => <option key={a} value={a}>{a}</option>)}
</select>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: 8 }}>
<input type="range" min={0.1} max={3} step={0.1} value={model.animationSpeed}
onChange={e => setModelAnimSpeed(model.id, parseFloat(e.target.value))} style={{ flex: 1 }} />
<span style={{ color: '#00f5ff', fontSize: 11 }}>{model.animationSpeed.toFixed(1)}x</span>
</div>
</>}
<hr style={{ border: 'none', borderTop: '1px solid rgba(255,255,255,0.07)', margin: '8px 0' }} />
<div style={{ fontSize: 10, color: '#555', marginBottom: 6 }}>KEYFRAME @ {currentFrame}</div>
<div style={{ display: 'flex', gap: 6, marginBottom: 8 }}>
<button onClick={() => addKeyframe(currentFrame, model.id)} style={{
flex: 1, padding: '7px 0',
background: hasKfNow ? 'rgba(255,170,0,0.15)' : 'rgba(0,245,255,0.1)',
border: `1px solid ${hasKfNow ? '#ffaa00' : '#00f5ff'}`,
color: hasKfNow ? '#ffaa00' : '#00f5ff',
borderRadius: 5, cursor: 'pointer', fontSize: 11, fontFamily: 'Space Mono',
}}>{hasKfNow ? '◆ UPDATE' : '◆ ADD KF'}</button>
{hasKfNow && <button onClick={() => removeKeyframe(currentFrame, model.id)} style={{
padding: '7px 10px', background: 'rgba(255,64,96,0.1)',
border: '1px solid rgba(255,64,96,0.3)', color: '#ff4060',
borderRadius: 5, cursor: 'pointer', fontSize: 11,
}}>✕</button>}
</div>
{kfList.length > 0 && <>
<div style={{ fontSize: 10, color: '#555', marginBottom: 4 }}>KEYFRAMES ({kfList.length})</div>
<div style={{ maxHeight: 100, overflow: 'auto', marginBottom: 8 }}>
{kfList.map(({ frame }) => (
<div key={frame} onClick={() => useStore.getState().setCurrentFrame(frame)}
style={{ display: 'flex', justifyContent: 'space-between', padding: '4px 6px', marginBottom: 2,
background: frame === currentFrame ? 'rgba(255,170,0,0.1)' : 'rgba(255,255,255,0.03)',
border: `1px solid ${frame === currentFrame ? 'rgba(255,170,0,0.3)' : 'transparent'}`,
borderRadius: 4, cursor: 'pointer' }}>
<span style={{ fontSize: 11, color: '#aaa' }}>Frame {frame}</span>
<button onClick={e => { e.stopPropagation(); removeKeyframe(frame, model.id); }}
style={{ background: 'none', border: 'none', color: '#444', cursor: 'pointer', fontSize: 11 }}>✕</button>
</div>
))}
</div>
</>}
<hr style={{ border: 'none', borderTop: '1px solid rgba(255,255,255,0.07)', margin: '8px 0' }} />
<button onClick={() => { removeModel(model.id); selectModel(null); }} style={{
width: '100%', padding: '8px 0',
background: 'rgba(255,64,96,0.08)', border: '1px solid rgba(255,64,96,0.3)',
color: '#ff4060', borderRadius: 5, cursor: 'pointer', fontSize: 11, fontFamily: 'Space Mono',
}}>🗑 REMOVE MODEL</button>
</div>
);
}
const SAMPLES = [
{ name: 'Fox', url: 'https://threejs.org/examples/models/gltf/Fox/glTF/Fox.gltf' },
{ name: 'Robot', url: 'https://threejs.org/examples/models/gltf/RobotExpressive/RobotExpressive.glb' },
{ name: 'Soldier', url: 'https://threejs.org/examples/models/gltf/Soldier.glb' },
{ name: 'Flamingo', url: 'https://threejs.org/examples/models/gltf/Flamingo.glb' },
{ name: 'Horse', url: 'https://threejs.org/examples/models/gltf/Horse.glb' },
{ name: 'Parrot', url: 'https://threejs.org/examples/models/gltf/Parrot.glb' },
];
function ModelsPanel() {
const models = useStore(s => s.models);
const selectedId = useStore(s => s.selectedModelId);
const [url, setUrl] = useState('');
const [name, setName] = useState('');
const [showSamples, setShowSamples] = useState(false);
const fileRef = useRef();
const { addModel, removeModel, selectModel, toggleModelVisibility } = useStore.getState();
const handleAdd = () => {
if (!url.trim()) return;
addModel(url.trim(), name.trim() || null);
setUrl(''); setName('');
};
return (
<div style={{ padding: 10, overflow: 'auto', maxHeight: '100%' }}>
<div style={{ fontSize: 10, color: '#555', marginBottom: 6, letterSpacing: '0.1em' }}>ADD MODEL</div>
<input value={name} onChange={e => setName(e.target.value)} placeholder="Name (optional)" style={IS} />
<input value={url} onChange={e => setUrl(e.target.value)} onKeyDown={e => e.key==='Enter' && handleAdd()}
placeholder="GLB / GLTF URL..." style={{ ...IS, marginTop: 4 }} />
<div style={{ display: 'flex', gap: 6, marginTop: 6 }}>
<button onClick={handleAdd} style={PB}>+ URL</button>
<button onClick={() => fileRef.current?.click()} style={SB}>📁 FILE</button>
<button onClick={() => setShowSamples(!showSamples)} style={{ ...SB, color: showSamples ? '#00f5ff' : '#777' }}>◈ DEMO</button>
</div>
<input ref={fileRef} type="file" accept=".glb,.gltf" style={{ display: 'none' }}
onChange={e => { const f = e.target.files[0]; if(f) addModel(URL.createObjectURL(f), f.name.replace(/\.[^.]+$/,'')); }} />
{showSamples && <div style={{ marginTop: 8 }}>
<div style={{ fontSize: 10, color: '#555', marginBottom: 4 }}>SAMPLE MODELS</div>
{SAMPLES.map(s => (
<button key={s.url} onClick={() => { addModel(s.url, s.name); setShowSamples(false); }}
style={{ display: 'block', width: '100%', textAlign: 'left', marginBottom: 3, padding: '6px 8px',
background: 'rgba(0,245,255,0.05)', border: '1px solid rgba(0,245,255,0.12)',
color: '#9dd', borderRadius: 4, cursor: 'pointer', fontSize: 11, fontFamily: 'Space Mono' }}>
◈ {s.name}
</button>
))}
</div>}
<hr style={{ border: 'none', borderTop: '1px solid rgba(255,255,255,0.07)', margin: '8px 0' }} />
<div style={{ fontSize: 10, color: '#555', marginBottom: 4 }}>MODELS ({models.length})</div>
{models.length === 0 && <div style={{ color: '#333', fontSize: 11, textAlign: 'center', padding: 16 }}>No models loaded</div>}
{models.map((m, i) => {
const sel = m.id === selectedId;
const c = COLORS[i % COLORS.length];
return (
<div key={m.id} onClick={() => selectModel(m.id)} style={{
display: 'flex', alignItems: 'center', gap: 8, padding: '7px 8px', marginBottom: 3,
background: sel ? `${c}11` : 'rgba(255,255,255,0.03)',
border: `1px solid ${sel ? c+'44' : 'rgba(255,255,255,0.06)'}`,
borderRadius: 5, cursor: 'pointer',
}}>
<div style={{ width: 8, height: 8, borderRadius: '50%', background: m.visible ? c : '#333', boxShadow: m.visible ? `0 0 6px ${c}` : 'none', flexShrink: 0 }} />
<span style={{ flex: 1, fontSize: 11, color: sel ? '#fff' : '#aaa', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{m.name}</span>
<button onClick={e => { e.stopPropagation(); toggleModelVisibility(m.id); }} style={{ background: 'none', border: 'none', color: m.visible ? '#aaa' : '#333', cursor: 'pointer', fontSize: 12, padding: 2 }}>{m.visible ? '👁' : '🙈'}</button>
<button onClick={e => { e.stopPropagation(); removeModel(m.id); }} style={{ background: 'none', border: 'none', color: '#333', cursor: 'pointer', fontSize: 11, padding: 2 }}>✕</button>
</div>
);
})}
</div>
);
}
function ExportPanel() {
const isExporting = useStore(s => s.isExporting);
const exportProgress = useStore(s => s.exportProgress);
const exportedUrl = useStore(s => s.exportedVideoUrl);
const totalFrames = useStore(s => s.totalFrames);
const fps = useStore(s => s.fps);
const { setIsExporting, setExportProgress, setExportedVideoUrl, setCurrentFrame } = useStore.getState();
const [quality, setQuality] = useState(0.9);
const [outFps, setOutFps] = useState(30);
const [status, setStatus] = useState('');
const cancelRef = useRef(false);
const framesRef = useRef([]);
const sleep = ms => new Promise(r => setTimeout(r, ms));
const captureFrame = () => {
const canvas = document.querySelector('canvas');
if (!canvas) return null;
return canvas.toDataURL('image/jpeg', quality);
};
const startExport = async () => {
cancelRef.current = false;
setIsExporting(true);
setExportedVideoUrl(null);
framesRef.current = [];
setStatus('Capturing frames...');
for (let f = 0; f < totalFrames; f++) {
if (cancelRef.current) break;
setCurrentFrame(f);
await sleep(1000 / fps + 16);
const frame = captureFrame();
if (frame) framesRef.current.push(frame);
setExportProgress(Math.round((f / totalFrames) * 80));
}
if (!cancelRef.current && framesRef.current.length > 0) {
setStatus('Encoding video...');
setExportProgress(85);
try {
const blob = await encode(framesRef.current, outFps);
const url = URL.createObjectURL(blob);
setExportedVideoUrl(url);
setExportProgress(100);
setStatus('Done!');
} catch(e) { setStatus('Error: ' + e.message); }
}
setIsExporting(false);
setCurrentFrame(0);
};
const encode = (frames, fps) => new Promise((res, rej) => {
if (!frames.length) { rej(new Error('No frames')); return; }
const img = new Image();
img.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = img.width; canvas.height = img.height;
const ctx = canvas.getContext('2d');
const stream = canvas.captureStream(fps);
const rec = new MediaRecorder(stream, { mimeType: 'video/webm', videoBitsPerSecond: 6_000_000 });
const chunks = [];
rec.ondataavailable = e => chunks.push(e.data);
rec.onstop = () => res(new Blob(chunks, { type: 'video/webm' }));
rec.start();
let i = 0;
const iv = setInterval(() => {
if (i >= frames.length) { clearInterval(iv); rec.stop(); return; }
const fi = new Image(); fi.onload = () => ctx.drawImage(fi, 0, 0); fi.src = frames[i++];
setExportProgress(85 + Math.round((i/frames.length)*14));
}, 1000/fps);
};
img.onerror = rej;
img.src = frames[0];
});
return (
<div style={{ padding: 10, overflow: 'auto', maxHeight: '100%' }}>
<div style={{ fontSize: 10, color: '#555', marginBottom: 10, letterSpacing: '0.1em' }}>EXPORT VIDEO</div>
<div style={{ background: 'rgba(0,245,255,0.04)', border: '1px solid rgba(0,245,255,0.1)', borderRadius: 5, padding: 10, marginBottom: 10, fontSize: 11, color: '#888', lineHeight: 1.8 }}>
<div>Frames: <span style={{ color: '#00f5ff' }}>{totalFrames}</span></div>
<div>Duration: <span style={{ color: '#00f5ff' }}>{(totalFrames/fps).toFixed(1)}s</span></div>
<div>Format: <span style={{ color: '#00f5ff' }}>WebM VP8</span></div>
</div>
<div style={{ marginBottom: 8 }}>
<div style={{ fontSize: 10, color: '#555', marginBottom: 4 }}>QUALITY</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<input type="range" min={0.5} max={1} step={0.05} value={quality} onChange={e => setQuality(+e.target.value)} style={{ flex: 1 }} />
<span style={{ color: '#00f5ff', fontSize: 11 }}>{Math.round(quality*100)}%</span>
</div>
</div>
<div style={{ marginBottom: 12 }}>
<div style={{ fontSize: 10, color: '#555', marginBottom: 4 }}>FPS</div>
<div style={{ display: 'flex', gap: 4 }}>
{[15,24,30].map(f => (
<button key={f} onClick={() => setOutFps(f)} style={{
flex: 1, padding: '6px 0',
background: outFps===f ? 'rgba(0,245,255,0.15)' : 'rgba(255,255,255,0.04)',
border: `1px solid ${outFps===f ? '#00f5ff' : 'rgba(255,255,255,0.1)'}`,
color: outFps===f ? '#00f5ff' : '#555',
borderRadius: 4, cursor: 'pointer', fontSize: 11, fontFamily: 'Space Mono',
}}>{f}</button>
))}
</div>
</div>
{isExporting && <div style={{ marginBottom: 10 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
<span style={{ fontSize: 10, color: '#777' }}>{status}</span>
<span style={{ fontSize: 10, color: '#00f5ff' }}>{exportProgress}%</span>
</div>
<div style={{ height: 4, background: 'rgba(255,255,255,0.08)', borderRadius: 2 }}>
<div style={{ height: '100%', width: `${exportProgress}%`, background: 'linear-gradient(90deg,#00f5ff,#0080ff)', borderRadius: 2, transition: 'width 0.3s' }} />
</div>
</div>}
{status && !isExporting && <div style={{ fontSize: 11, color: status.includes('Error') ? '#ff4060' : '#40ff80', marginBottom: 10, padding: '6px 8px', background: 'rgba(255,255,255,0.04)', borderRadius: 4 }}>{status}</div>}
{!isExporting
? <button onClick={startExport} style={{ width: '100%', padding: '10px 0', background: 'linear-gradient(135deg,rgba(0,245,255,0.2),rgba(0,128,255,0.2))', border: '1px solid rgba(0,245,255,0.4)', color: '#00f5ff', borderRadius: 5, cursor: 'pointer', fontSize: 12, fontFamily: 'Space Mono', fontWeight: 700, letterSpacing: '0.1em' }}>▶ RENDER & EXPORT</button>
: <button onClick={() => { cancelRef.current = true; }} style={{ width: '100%', padding: '10px 0', background: 'rgba(255,64,96,0.1)', border: '1px solid rgba(255,64,96,0.3)', color: '#ff4060', borderRadius: 5, cursor: 'pointer', fontSize: 12, fontFamily: 'Space Mono' }}>⏹ CANCEL</button>
}
{exportedUrl && <div style={{ marginTop: 10 }}>
<video src={exportedUrl} controls style={{ width: '100%', borderRadius: 5, marginBottom: 8 }} />
<a href={exportedUrl} download={`anim_${Date.now()}.webm`} style={{ display: 'block', padding: '8px 0', background: 'rgba(64,255,128,0.1)', border: '1px solid rgba(64,255,128,0.3)', color: '#40ff80', borderRadius: 5, textAlign: 'center', textDecoration: 'none', fontSize: 11, fontFamily: 'Space Mono' }}>⬇ DOWNLOAD VIDEO</a>
</div>}
</div>
);
}
function Timeline() {
const models = useStore(s => s.models);
const keyframes = useStore(s => s.keyframes);
const currentFrame = useStore(s => s.currentFrame);
const totalFrames = useStore(s => s.totalFrames);
const isPlaying = useStore(s => s.isPlaying);
const showTimeline = useStore(s => s.showTimeline);
const selectedId = useStore(s => s.selectedModelId);
const { setCurrentFrame, setIsPlaying, addKeyframe, removeKeyframe, moveKeyframe, setShowTimeline } = useStore.getState();
const trackAreaRef = useRef();
const [trackW, setTrackW] = useState(600);
useEffect(() => {
if (!trackAreaRef.current) return;
const ro = new ResizeObserver(e => setTrackW(e[0].contentRect.width));
ro.observe(trackAreaRef.current);
return () => ro.disconnect();
}, []);
const scrub = useCallback((clientX) => {
if (!trackAreaRef.current) return;
const rect = trackAreaRef.current.getBoundingClientRect();
const x = clientX - rect.left;
setCurrentFrame(Math.round((x / trackW) * totalFrames));
}, [trackW, totalFrames]);
const handlePointerDown = useCallback((e) => {
scrub(e.touches ? e.touches[0].clientX : e.clientX);
const move = me => scrub(me.touches ? me.touches[0].clientX : me.clientX);
const up = () => { window.removeEventListener('pointermove', move); window.removeEventListener('pointerup', up); };
window.addEventListener('pointermove', move);
window.addEventListener('pointerup', up);
}, [scrub]);
if (!showTimeline) return (
<div style={{ position: 'absolute', bottom: 4, left: '50%', transform: 'translateX(-50%)', zIndex: 100 }}>
<button onClick={() => setShowTimeline(true)} style={{ background: '#1a1a2e', border: '1px solid #333', color: '#00f5ff', padding: '4px 16px', borderRadius: 8, cursor: 'pointer', fontSize: 11, fontFamily: 'Space Mono' }}>SHOW TIMELINE</button>
</div>
);
const playheadX = (currentFrame / totalFrames) * trackW;
return (
<div style={{ position: 'absolute', bottom: 0, left: 0, right: 0, background: 'rgba(8,8,20,0.97)', borderTop: '1px solid rgba(0,245,255,0.15)', zIndex: 100, userSelect: 'none' }}>
{/* Controls bar */}
<div style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '5px 8px', borderBottom: '1px solid rgba(255,255,255,0.06)' }}>
<button onClick={() => setCurrentFrame(0)} style={TB}>⏮</button>
<button onClick={() => setCurrentFrame(Math.max(0, currentFrame-1))} style={TB}>◀</button>
<button onClick={() => setIsPlaying(!isPlaying)} style={{ ...TB, background: isPlaying ? 'rgba(255,64,96,0.2)' : 'rgba(0,245,255,0.15)', borderColor: isPlaying ? '#ff4060' : '#00f5ff', color: isPlaying ? '#ff4060' : '#00f5ff', minWidth: 36 }}>{isPlaying ? '⏸' : '▶'}</button>
<button onClick={() => setCurrentFrame(Math.min(totalFrames-1, currentFrame+1))} style={TB}>▶</button>
<button onClick={() => setCurrentFrame(totalFrames-1)} style={TB}>⏭</button>
<span style={{ color: '#00f5ff', fontSize: 12, fontFamily: 'Space Mono', minWidth: 90 }}>
{String(currentFrame).padStart(4,'0')} / {totalFrames}
</span>
{selectedId && <button onClick={() => addKeyframe(currentFrame, selectedId)} style={{ ...TB, color: '#ffaa00', borderColor: 'rgba(255,170,0,0.4)', background: 'rgba(255,170,0,0.1)' }}>◆ ADD KF</button>}
<div style={{ flex: 1 }} />
<button onClick={() => setShowTimeline(false)} style={TB}>✕</button>
</div>
{/* Track area */}
<div style={{ display: 'flex', maxHeight: 120 }}>
{/* Labels */}
<div style={{ width: 90, flexShrink: 0, borderRight: '1px solid rgba(255,255,255,0.07)' }}>
<div style={{ height: 18, borderBottom: '1px solid rgba(255,255,255,0.05)', padding: '1px 6px', fontSize: 9, color: '#333' }}>TRACKS</div>
{models.map((m, i) => (
<div key={m.id} style={{ height: 30, display: 'flex', alignItems: 'center', padding: '0 6px', fontSize: 10, color: COLORS[i%COLORS.length], borderBottom: '1px solid rgba(255,255,255,0.04)', overflow: 'hidden', whiteSpace: 'nowrap' }}>
● {m.name.substring(0,7)}
</div>
))}
</div>
{/* Scrollable tracks */}
<div style={{ flex: 1, overflow: 'auto hidden' }}>
{/* Ruler */}
<div ref={trackAreaRef} style={{ position: 'relative', height: 18, borderBottom: '1px solid rgba(255,255,255,0.05)', cursor: 'crosshair' }}
onPointerDown={handlePointerDown} onTouchStart={e => handlePointerDown(e)}>
{Array.from({ length: Math.ceil(totalFrames/10) }, (_, i) => {
const f = i*10;
return <div key={f} style={{ position: 'absolute', left: (f/totalFrames)*trackW, top: 0, bottom: 0, borderLeft: '1px solid rgba(255,255,255,0.08)', paddingLeft: 2 }}>
<span style={{ fontSize: 8, color: '#333', lineHeight: '18px' }}>{f}</span>
</div>;
})}
<div style={{ position: 'absolute', left: playheadX, top: 0, bottom: 0, width: 2, background: '#00f5ff', boxShadow: '0 0 6px #00f5ff', pointerEvents: 'none', zIndex: 10 }} />
</div>
{/* Model tracks */}
<div style={{ position: 'relative' }}>
{models.map((m, i) => {
const c = COLORS[i%COLORS.length];
const mKfs = Object.entries(keyframes).filter(([,kf]) => kf[m.id]).map(([f]) => parseInt(f));
return (
<div key={m.id} style={{ position: 'relative', height: 30, background: m.id===selectedId ? `${c}08` : 'transparent', borderBottom: '1px solid rgba(255,255,255,0.04)' }}>
<div style={{ position: 'absolute', top: '50%', left: 0, right: 0, height: 1, background: 'rgba(255,255,255,0.06)' }} />
{mKfs.map(f => {
const x = (f/totalFrames)*trackW;
return (
<div key={f}
title={`Frame ${f} — drag to move, dblclick to delete`}
onDoubleClick={e => { e.stopPropagation(); removeKeyframe(f, m.id); }}
onPointerDown={e => {
e.stopPropagation();
const startX = e.clientX, startF = f;
const move = me => {
const dx = me.clientX - startX;
const df = Math.round((dx/trackW)*totalFrames);
const newF = Math.max(0, Math.min(totalFrames-1, startF+df));
if (newF !== f) moveKeyframe(f, newF, m.id);
};
const up = () => { window.removeEventListener('pointermove', move); window.removeEventListener('pointerup', up); };
window.addEventListener('pointermove', move);
window.addEventListener('pointerup', up);
}}
style={{ position: 'absolute', left: x-5, top: '50%', transform: 'translateY(-50%)', width: 10, height: 10, background: c, border: '1px solid rgba(255,255,255,0.4)', borderRadius: '50%', cursor: 'grab', zIndex: 10, boxShadow: `0 0 6px ${c}` }}
/>
);
})}
</div>
);
})}
{/* Playhead */}
<div style={{ position: 'absolute', left: playheadX, top: 0, bottom: 0, width: 2, background: 'rgba(0,245,255,0.4)', pointerEvents: 'none', zIndex: 5 }} />
</div>
</div>
</div>
</div>
);
}
function Toolbar() {
const transformMode = useStore(s => s.transformMode);
const lightingPreset = useStore(s => s.lightingPreset);
const isPlaying = useStore(s => s.isPlaying);
const selectedId = useStore(s => s.selectedModelId);
const currentFrame = useStore(s => s.currentFrame);
const { setTransformMode, setLightingPreset, setIsPlaying, addKeyframe } = useStore.getState();
return (
<div style={{
position: 'absolute', top: 0, left: 0, right: 0, zIndex: 200,
display: 'flex', alignItems: 'center', gap: 5, padding: '5px 10px',
background: 'rgba(8,8,20,0.95)', backdropFilter: 'blur(12px)',
borderBottom: '1px solid rgba(0,245,255,0.12)', overflowX: 'auto',
}}>
<div style={{ fontFamily: 'Orbitron,monospace', fontSize: 12, fontWeight: 900, color: '#00f5ff', letterSpacing: '0.08em', whiteSpace: 'nowrap', textShadow: '0 0 20px rgba(0,245,255,0.4)', marginRight: 4 }}>
GLB<span style={{ color: '#ff4080' }}>STUDIO</span>
</div>
<div style={{ width: 1, height: 18, background: 'rgba(255,255,255,0.1)' }} />
{[['translate','✛','Move'],['rotate','↻','Rotate'],['scale','⤡','Scale']].map(([id,icon,label]) => (
<button key={id} onClick={() => setTransformMode(id)} title={label} style={{
padding: '5px 9px', borderRadius: 4, cursor: 'pointer', fontSize: 13, fontWeight: 'bold',
background: transformMode===id ? 'rgba(0,245,255,0.15)' : 'rgba(255,255,255,0.04)',
border: `1px solid ${transformMode===id ? '#00f5ff' : 'rgba(255,255,255,0.1)'}`,
color: transformMode===id ? '#00f5ff' : '#666', minWidth: 34, textAlign: 'center',
}}>{icon}</button>
))}
<div style={{ width: 1, height: 18, background: 'rgba(255,255,255,0.1)' }} />
<button onClick={() => setIsPlaying(!isPlaying)} style={{ padding: '5px 10px', borderRadius: 4, cursor: 'pointer', fontSize: 13,
background: isPlaying ? 'rgba(255,64,96,0.15)' : 'rgba(64,255,128,0.12)',
border: `1px solid ${isPlaying ? '#ff4060' : '#40ff80'}`,
color: isPlaying ? '#ff4060' : '#40ff80' }}>{isPlaying ? '⏸' : '▶'}</button>
<div style={{ width: 1, height: 18, background: 'rgba(255,255,255,0.1)' }} />
{[['studio','💡'],['outdoor','☀️'],['dramatic','🎭'],['neon','🌀']].map(([id,icon]) => (
<button key={id} onClick={() => setLightingPreset(id)} title={id} style={{ padding: '4px 7px', borderRadius: 4, cursor: 'pointer', fontSize: 13,
background: lightingPreset===id ? 'rgba(255,170,0,0.12)' : 'rgba(255,255,255,0.03)',
border: `1px solid ${lightingPreset===id ? '#ffaa00' : 'rgba(255,255,255,0.08)'}`,
opacity: lightingPreset===id ? 1 : 0.45 }}>{icon}</button>
))}
<div style={{ flex: 1 }} />
{selectedId && <button onClick={() => addKeyframe(currentFrame, selectedId)} style={{ padding: '5px 10px', borderRadius: 4, cursor: 'pointer', fontSize: 11, fontFamily: 'Space Mono',
background: 'rgba(255,170,0,0.12)', border: '1px solid rgba(255,170,0,0.4)', color: '#ffaa00', whiteSpace: 'nowrap' }}>◆ KEY</button>}
</div>
);
}
const TABS = [
{ id: 'models', label: 'MODELS', icon: '◈' },
{ id: 'properties', label: 'PROPS', icon: '⚙' },
{ id: 'export', label: 'EXPORT', icon: '▶' },
];
function SidePanel() {
const active = useStore(s => s.activePanel);
const { setActivePanel } = useStore.getState();
return (
<div style={{ width: 195, flexShrink: 0, background: 'rgba(8,8,20,0.97)', borderLeft: '1px solid rgba(0,245,255,0.1)', display: 'flex', flexDirection: 'column' }}>
<div style={{ display: 'flex', borderBottom: '1px solid rgba(255,255,255,0.07)' }}>
{TABS.map(t => (
<button key={t.id} onClick={() => setActivePanel(t.id)} style={{
flex: 1, padding: '7px 4px', background: active===t.id ? 'rgba(0,245,255,0.07)' : 'transparent',
border: 'none', borderBottom: `2px solid ${active===t.id ? '#00f5ff' : 'transparent'}`,
color: active===t.id ? '#00f5ff' : '#3a3a5a', cursor: 'pointer', fontSize: 9,
fontFamily: 'Space Mono', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2,
}}>
<span style={{ fontSize: 14 }}>{t.icon}</span><span>{t.label}</span>
</button>
))}
</div>
<div style={{ flex: 1, overflow: 'auto' }}>
{active==='models' && <ModelsPanel />}
{active==='properties' && <PropertiesPanel />}
{active==='export' && <ExportPanel />}
</div>
</div>
);
}
function App() {
const isMobile = window.innerWidth < 600;
const active = useStore(s => s.activePanel);
const { setActivePanel } = useStore.getState();
const showTimeline = useStore(s => s.showTimeline);
const TOOLBAR_H = 40;
const TL_H = showTimeline ? 150 : 30;
const MB_H = isMobile ? 46 : 0;
return (
<div style={{ width: '100vw', height: '100vh', display: 'flex', flexDirection: 'column', background: '#080810', overflow: 'hidden', fontFamily: 'Space Mono, monospace' }}>
<Toolbar />
<div style={{ flex: 1, display: 'flex', position: 'relative', marginTop: TOOLBAR_H, marginBottom: TL_H + MB_H }}>
{/* Canvas */}
<div style={{ flex: 1, position: 'relative' }}>
<SceneView />
{/* Mobile drawer */}
{isMobile && active && (
<div style={{ position: 'absolute', bottom: 0, left: 0, right: 0, height: 260, background: 'rgba(8,8,20,0.98)', borderTop: '1px solid rgba(0,245,255,0.15)', overflow: 'auto', zIndex: 150, animation: 'fadeIn 0.2s ease' }}>
{active==='models' && <ModelsPanel />}
{active==='properties' && <PropertiesPanel />}
{active==='export' && <ExportPanel />}
</div>
)}
</div>
{!isMobile && <SidePanel />}
</div>
{/* Timeline */}
<div style={{ position: 'absolute', bottom: MB_H, left: 0, right: isMobile ? 0 : 195, zIndex: 100 }}>
<Timeline />
</div>
{/* Mobile tab bar */}
{isMobile && (
<div style={{ position: 'absolute', bottom: 0, left: 0, right: 0, display: 'flex', background: 'rgba(8,8,20,0.98)', borderTop: '1px solid rgba(0,245,255,0.1)', zIndex: 200 }}>
{TABS.map(t => (
<button key={t.id} onClick={() => setActivePanel(active===t.id ? null : t.id)} style={{
flex: 1, padding: '7px 4px', background: active===t.id ? 'rgba(0,245,255,0.07)' : 'transparent',
border: 'none', borderTop: `2px solid ${active===t.id ? '#00f5ff' : 'transparent'}`,
color: active===t.id ? '#00f5ff' : '#444', cursor: 'pointer',
fontSize: 9, fontFamily: 'Space Mono', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2,
}}>
<span style={{ fontSize: 18 }}>{t.icon}</span><span>{t.label}</span>
</button>
))}
</div>
)}
</div>
);
}
// ─── Style constants ──────────────────────────────────────────────────────────
const IS = { width: '100%', background: 'rgba(255,255,255,0.04)', border: '1px solid rgba(255,255,255,0.1)', color: '#ddd', padding: '6px 8px', borderRadius: 4, fontSize: 11, fontFamily: 'Space Mono,monospace', display: 'block' };
const PB = { flex: 1, padding: '6px 0', background: 'rgba(0,245,255,0.12)', border: '1px solid rgba(0,245,255,0.3)', color: '#00f5ff', borderRadius: 4, cursor: 'pointer', fontSize: 11, fontFamily: 'Space Mono' };
const SB = { flex: 1, padding: '6px 0', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', color: '#777', borderRadius: 4, cursor: 'pointer', fontSize: 11, fontFamily: 'Space Mono' };
const TB = { padding: '3px 7px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', color: '#888', borderRadius: 4, cursor: 'pointer', fontSize: 12, fontFamily: 'Space Mono' };
// ─── Mount ────────────────────────────────────────────────────────────────────
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(React.createElement(App));
</script>
</body>
</html>