varunm2004 commited on
Commit
a049ed7
Β·
verified Β·
1 Parent(s): e4c940e

deploy: 2-stage Dockerfile, npm build on HF

Browse files
src/App.jsx CHANGED
@@ -10,6 +10,7 @@ import CameraMode from './components/CameraMode'
10
  import SkyboxPanel from './components/SkyboxPanel'
11
  import PhysicsPanel from './components/PhysicsPanel'
12
  import AIController from './components/AIController'
 
13
  import useStore from './store/useStore'
14
 
15
  const TABS = [
@@ -20,6 +21,7 @@ const TABS = [
20
  { id:'skybox', icon:'🌐', label:'Skybox' },
21
  { id:'physics', icon:'⚑', label:'Physics' },
22
  { id:'ai', icon:'✦', label:'AI' },
 
23
  { id:'export', icon:'β–Ά', label:'Export' },
24
  ]
25
 
@@ -36,6 +38,7 @@ function PanelContent({ id, canvasRef }) {
36
  {id==='skybox' && <SkyboxPanel />}
37
  {id==='physics' && <PhysicsPanel />}
38
  {id==='ai' && <AIController />}
 
39
  {id==='export' && <ExportPanel canvasRef={canvasRef} />}
40
  </div>
41
  )
 
10
  import SkyboxPanel from './components/SkyboxPanel'
11
  import PhysicsPanel from './components/PhysicsPanel'
12
  import AIController from './components/AIController'
13
+ import ProjectPanel from './components/ProjectPanel'
14
  import useStore from './store/useStore'
15
 
16
  const TABS = [
 
21
  { id:'skybox', icon:'🌐', label:'Skybox' },
22
  { id:'physics', icon:'⚑', label:'Physics' },
23
  { id:'ai', icon:'✦', label:'AI' },
24
+ { id:'project', icon:'πŸ—‚', label:'Project' },
25
  { id:'export', icon:'β–Ά', label:'Export' },
26
  ]
27
 
 
38
  {id==='skybox' && <SkyboxPanel />}
39
  {id==='physics' && <PhysicsPanel />}
40
  {id==='ai' && <AIController />}
41
+ {id==='project' && <ProjectPanel />}
42
  {id==='export' && <ExportPanel canvasRef={canvasRef} />}
43
  </div>
44
  )
src/components/ProjectPanel.jsx ADDED
@@ -0,0 +1,587 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * ProjectPanel.jsx β€” Complete project share/import/export system
3
+ *
4
+ * Features:
5
+ * - Import preview: shows project contents BEFORE loading
6
+ * - Full bundle export: safely encodes large GLBs (no stack overflow)
7
+ * - Per-model embed toggle: choose which models to embed
8
+ * - Recent projects list from localStorage
9
+ * - Auto-save with configurable interval
10
+ * - Share link with URL param parsing on startup
11
+ * - File size estimation before export
12
+ * - Import validation with error details
13
+ */
14
+ import { useState, useRef, useCallback, useEffect } from 'react'
15
+ import useStore from '../store/useStore'
16
+
17
+ // ── Helpers ───────────────────────────────────────────────────────────────────
18
+ function fmtBytes(b) {
19
+ if (!b || b === 0) return '0 B'
20
+ if (b < 1024) return `${b} B`
21
+ if (b < 1024**2) return `${(b/1024).toFixed(1)} KB`
22
+ return `${(b/1024/1024).toFixed(2)} MB`
23
+ }
24
+
25
+ function fmtDate(iso) {
26
+ if (!iso) return ''
27
+ try {
28
+ return new Date(iso).toLocaleString(undefined, { dateStyle:'short', timeStyle:'short' })
29
+ } catch { return iso }
30
+ }
31
+
32
+ // ── Sub-components ────────────────────────────────────────────────────────────
33
+ function Divider({ label }) {
34
+ return (
35
+ <div style={{ display:'flex', alignItems:'center', gap:10, margin:'4px 0' }}>
36
+ <div style={{ flex:1, height:1, background:'var(--border)' }}/>
37
+ {label && <span style={{ fontSize:9, color:'var(--text3)', fontWeight:700, letterSpacing:'0.1em', textTransform:'uppercase' }}>{label}</span>}
38
+ <div style={{ flex:1, height:1, background:'var(--border)' }}/>
39
+ </div>
40
+ )
41
+ }
42
+
43
+ function Chip({ color='var(--accent)', children }) {
44
+ return (
45
+ <span style={{ fontSize:9, padding:'2px 7px', borderRadius:10, whiteSpace:'nowrap',
46
+ background:`${color}18`, color, border:`1px solid ${color}33`, fontWeight:700 }}>
47
+ {children}
48
+ </span>
49
+ )
50
+ }
51
+
52
+ function StatusBox({ status }) {
53
+ if (!status) return null
54
+ const cfg = {
55
+ ok: { bg:'rgba(6,214,160,0.08)', border:'rgba(6,214,160,0.25)', color:'var(--accent3)', icon:'βœ…' },
56
+ err: { bg:'rgba(239,68,68,0.08)', border:'rgba(239,68,68,0.25)', color:'var(--danger)', icon:'❌' },
57
+ warn: { bg:'rgba(245,158,11,0.08)', border:'rgba(245,158,11,0.25)', color:'var(--warn)', icon:'⚠️' },
58
+ info: { bg:'rgba(79,142,255,0.08)', border:'rgba(79,142,255,0.25)', color:'var(--accent)', icon:'ℹ️' },
59
+ }[status.type] || {}
60
+ return (
61
+ <div style={{ padding:'9px 12px', borderRadius:'var(--radius-sm)', fontSize:11, lineHeight:1.55,
62
+ background:cfg.bg, border:`1px solid ${cfg.border}`, color:cfg.color,
63
+ display:'flex', alignItems:'flex-start', gap:8, animation:'fadeUp 0.15s ease' }}>
64
+ <span style={{flexShrink:0}}>{cfg.icon}</span>
65
+ <span>{status.msg}</span>
66
+ </div>
67
+ )
68
+ }
69
+
70
+ function BigBtn({ icon, label, sub, onClick, color='var(--accent)', disabled, loading, badge }) {
71
+ const [h, setH] = useState(false)
72
+ return (
73
+ <button onClick={onClick} disabled={disabled||loading}
74
+ onMouseEnter={()=>setH(true)} onMouseLeave={()=>setH(false)}
75
+ style={{
76
+ width:'100%', padding:'11px 14px', borderRadius:'var(--radius)',
77
+ background: disabled ? 'var(--bg2)' : h ? `${color}15` : 'var(--bg2)',
78
+ border:`1px solid ${disabled?'var(--border)':h?`${color}55`:`${color}22`}`,
79
+ cursor: disabled||loading ? 'not-allowed' : 'pointer',
80
+ display:'flex', alignItems:'center', gap:12, textAlign:'left',
81
+ opacity: disabled ? 0.45 : 1, transition:'all 0.12s',
82
+ }}>
83
+ <span style={{ fontSize:22, lineHeight:1, flexShrink:0 }}>{loading?'⏳':icon}</span>
84
+ <div style={{ flex:1, minWidth:0 }}>
85
+ <div style={{ fontSize:12, fontWeight:700, color:h&&!disabled?color:'var(--text0)',
86
+ display:'flex', alignItems:'center', gap:6 }}>
87
+ {label}
88
+ {badge && <Chip color={color}>{badge}</Chip>}
89
+ </div>
90
+ {sub && <div style={{ fontSize:10, color:'var(--text3)', marginTop:2, lineHeight:1.4 }}>{sub}</div>}
91
+ </div>
92
+ </button>
93
+ )
94
+ }
95
+
96
+ // ── Import Preview Modal ───────────────────────────────────────────────────────
97
+ function ImportPreview({ preview, onLoad, onCancel }) {
98
+ const dur = preview.totalFrames && preview.fps
99
+ ? `${(preview.totalFrames/preview.fps).toFixed(1)}s`
100
+ : '?'
101
+
102
+ return (
103
+ <div style={{ position:'fixed', inset:0, background:'rgba(0,0,0,0.7)', zIndex:9999,
104
+ display:'flex', alignItems:'center', justifyContent:'center', padding:16,
105
+ animation:'fadeUp 0.15s ease' }}>
106
+ <div style={{ background:'var(--bg1)', border:'1px solid var(--border-hi)',
107
+ borderRadius:'var(--radius-lg)', padding:20, maxWidth:360, width:'100%',
108
+ boxShadow:'var(--shadow-lg)', maxHeight:'80vh', overflow:'auto' }}>
109
+
110
+ {/* Header */}
111
+ <div style={{ display:'flex', alignItems:'center', gap:10, marginBottom:14 }}>
112
+ <span style={{ fontSize:28 }}>πŸ“¦</span>
113
+ <div>
114
+ <div style={{ fontSize:14, fontWeight:700, color:'var(--text0)' }}>
115
+ {preview.projectName}
116
+ </div>
117
+ <div style={{ fontSize:10, color:'var(--text3)', marginTop:2 }}>
118
+ {preview.bundleDate ? fmtDate(preview.bundleDate) : `v${preview.version}`}
119
+ </div>
120
+ </div>
121
+ </div>
122
+
123
+ {/* Stats grid */}
124
+ <div style={{ display:'grid', gridTemplateColumns:'1fr 1fr', gap:6, marginBottom:14 }}>
125
+ {[
126
+ ['πŸ“¦ Models', preview.modelCount],
127
+ ['β—† Keyframes', preview.keyframeCount],
128
+ ['πŸŽ₯ Cameras', preview.cameraCount],
129
+ ['⏱ Duration', dur],
130
+ ['🎬 FPS', preview.fps],
131
+ ['πŸ’‘ Lighting', preview.lightingPreset],
132
+ ].map(([k,v]) => (
133
+ <div key={k} style={{ padding:'7px 10px', background:'var(--bg2)',
134
+ borderRadius:'var(--radius-sm)', border:'1px solid var(--border)' }}>
135
+ <div style={{ fontSize:9, color:'var(--text3)', marginBottom:2 }}>{k}</div>
136
+ <div style={{ fontSize:12, fontWeight:700, color:'var(--text0)' }}>{v}</div>
137
+ </div>
138
+ ))}
139
+ </div>
140
+
141
+ {/* Model list */}
142
+ {preview.models?.length > 0 && (
143
+ <div style={{ marginBottom:14 }}>
144
+ <div style={{ fontSize:10, color:'var(--text2)', fontWeight:600,
145
+ marginBottom:6, letterSpacing:'0.06em', textTransform:'uppercase' }}>Models</div>
146
+ <div style={{ display:'flex', flexDirection:'column', gap:3 }}>
147
+ {preview.models.map(m => (
148
+ <div key={m.id} style={{ display:'flex', alignItems:'center', gap:8,
149
+ padding:'5px 8px', background:'var(--bg2)', borderRadius:'var(--radius-sm)',
150
+ border:'1px solid var(--border)' }}>
151
+ <span style={{ fontSize:12 }}>{m.hasBlob ? 'βœ…' : 'πŸ”—'}</span>
152
+ <span style={{ fontSize:11, flex:1, overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap', color:'var(--text1)' }}>
153
+ {m.name}
154
+ </span>
155
+ <span style={{ fontSize:9, color: m.hasBlob ? 'var(--accent3)' : 'var(--text3)' }}>
156
+ {m.hasBlob ? 'embedded' : 'URL ref'}
157
+ </span>
158
+ </div>
159
+ ))}
160
+ </div>
161
+ </div>
162
+ )}
163
+
164
+ {/* Warning if URL-only */}
165
+ {preview.embeddedModels < preview.modelCount && (
166
+ <div style={{ padding:'8px 10px', borderRadius:'var(--radius-sm)',
167
+ background:'rgba(245,158,11,0.08)', border:'1px solid rgba(245,158,11,0.2)',
168
+ fontSize:10, color:'var(--warn)', marginBottom:12 }}>
169
+ ⚠️ {preview.modelCount - preview.embeddedModels} model{preview.modelCount-preview.embeddedModels>1?'s':''} use URL references β€” internet required to load them.
170
+ </div>
171
+ )}
172
+
173
+ {/* Actions */}
174
+ <div style={{ display:'flex', gap:8 }}>
175
+ <button onClick={onCancel} style={{
176
+ flex:1, padding:'9px 0', borderRadius:'var(--radius-sm)',
177
+ background:'var(--bg3)', border:'1px solid var(--border)',
178
+ color:'var(--text1)', fontSize:12, cursor:'pointer',
179
+ }}>Cancel</button>
180
+ <button onClick={onLoad} style={{
181
+ flex:2, padding:'9px 0', borderRadius:'var(--radius-sm)',
182
+ background:'var(--accent)', border:'none',
183
+ color:'#fff', fontSize:12, fontWeight:700, cursor:'pointer',
184
+ boxShadow:'0 0 14px rgba(79,142,255,0.3)',
185
+ }}>Load Project</button>
186
+ </div>
187
+ </div>
188
+ </div>
189
+ )
190
+ }
191
+
192
+ // ── Main Panel ─────────────────────────────────────────────────────────────────
193
+ export default function ProjectPanel() {
194
+ const {
195
+ projectName, setProjectName,
196
+ models, keyframes, cameras, fps, totalFrames, lightingPreset,
197
+ saveProject, loadProject,
198
+ exportProjectJSON, exportProjectBundle,
199
+ previewBundle, loadBundle,
200
+ getRecentProjects, clearRecentProjects,
201
+ } = useStore()
202
+
203
+ const [status, setStatus] = useState(null)
204
+ const [exporting, setExporting] = useState(false)
205
+ const [progress, setProgress] = useState({ msg:'', pct:0 })
206
+ const [dragging, setDragging] = useState(false)
207
+ const [editName, setEditName] = useState(false)
208
+ const [nameVal, setNameVal] = useState(projectName)
209
+ const [preview, setPreview] = useState(null) // import preview
210
+ const [skipModels, setSkipModels] = useState(new Set()) // models to NOT embed
211
+ const [showSkipUI, setShowSkipUI] = useState(false)
212
+ const [autoSave, setAutoSave] = useState(false)
213
+ const [lastSaved, setLastSaved] = useState(null)
214
+ const [recent, setRecent] = useState([])
215
+ const [showRecent, setShowRecent] = useState(false)
216
+ const fileRef = useRef()
217
+ const autoRef = useRef()
218
+
219
+ const kfCount = Object.keys(keyframes).length
220
+ const hasModels= models.length > 0
221
+ const duration = totalFrames && fps ? `${(totalFrames/fps).toFixed(1)}s` : '0s'
222
+
223
+ // Load recent on mount
224
+ useEffect(() => {
225
+ setRecent(getRecentProjects?.() || [])
226
+ }, [])
227
+
228
+ // Auto-save
229
+ useEffect(() => {
230
+ if (autoSave) {
231
+ autoRef.current = setInterval(() => {
232
+ const ok = saveProject()
233
+ if (ok) setLastSaved(new Date().toLocaleTimeString())
234
+ }, 60_000) // every 60s
235
+ }
236
+ return () => clearInterval(autoRef.current)
237
+ }, [autoSave])
238
+
239
+ const showMsg = (type, msg, ms=5000) => {
240
+ setStatus({ type, msg })
241
+ if (ms > 0) setTimeout(() => setStatus(null), ms)
242
+ }
243
+
244
+ // ── Quick export ───────────────────────────────────────────────────────────
245
+ const handleQuickExport = () => {
246
+ try {
247
+ exportProjectJSON()
248
+ showMsg('ok', 'Exported! Models saved as URLs β€” recipients need internet to reload them.')
249
+ } catch(e) { showMsg('err', `Export failed: ${e.message}`) }
250
+ }
251
+
252
+ // ── Bundle export ──────────────────────────────────────────────────────────
253
+ const handleBundle = async () => {
254
+ if (exporting || !hasModels) return
255
+ setExporting(true)
256
+ setProgress({ msg:'Starting…', pct:0 })
257
+ try {
258
+ const result = await exportProjectBundle(
259
+ (msg, pct) => setProgress({ msg, pct }),
260
+ { skip: [...skipModels] }
261
+ )
262
+ const parts = []
263
+ if (result.embeddedCount > 0) parts.push(`${result.embeddedCount} model${result.embeddedCount>1?'s':''} embedded`)
264
+ if (result.failedCount > 0) parts.push(`${result.failedCount} failed to fetch`)
265
+ const sizeStr = fmtBytes(result.size)
266
+ showMsg('ok', `Bundle saved! ${parts.join(' Β· ')} Β· ${sizeStr}`, 8000)
267
+ setRecent(getRecentProjects?.() || [])
268
+ } catch(e) {
269
+ showMsg('err', `Bundle export failed: ${e.message}`)
270
+ } finally {
271
+ setExporting(false)
272
+ setProgress({ msg:'', pct:0 })
273
+ }
274
+ }
275
+
276
+ // ── Save to browser ────────────────────────────────────────────────────────
277
+ const handleSave = () => {
278
+ const ok = saveProject()
279
+ if (ok) { setLastSaved(new Date().toLocaleTimeString()); showMsg('ok', 'Saved to browser storage βœ“') }
280
+ else showMsg('err', 'Browser storage save failed (storage may be full)')
281
+ }
282
+
283
+ // ── Load from browser ──────────────────────────────────────────────────────
284
+ const handleLoad = () => {
285
+ const ok = loadProject()
286
+ if (ok) { showMsg('ok', 'Project loaded from browser storage'); setRecent(getRecentProjects?.() || []) }
287
+ else showMsg('warn', 'No saved project found in browser storage')
288
+ }
289
+
290
+ // ── Import: preview first ──────────────────────────────────────────────────
291
+ const handleFile = useCallback(async (file) => {
292
+ if (!file) return
293
+ const ext = file.name.split('.').pop().toLowerCase()
294
+ if (!['glbstudio','json'].includes(ext)) { showMsg('err','Only .glbstudio files supported'); return }
295
+ showMsg('info', `Reading "${file.name}"…`, 0)
296
+ const p = await previewBundle(file)
297
+ setStatus(null)
298
+ if (!p.ok) { showMsg('err', `Cannot read file: ${p.error}`); return }
299
+ setPreview(p)
300
+ }, [previewBundle])
301
+
302
+ const handleLoadPreview = () => {
303
+ if (!preview) return
304
+ const result = loadBundle(preview)
305
+ setPreview(null)
306
+ if (result.ok) {
307
+ setRecent(getRecentProjects?.() || [])
308
+ showMsg('ok', `Loaded "${preview.projectName}" β€” ${result.modelCount} model${result.modelCount>1?'s':''}${result.embeddedCount?' (models embedded βœ“)':''}`)
309
+ } else {
310
+ showMsg('err', 'Failed to load project')
311
+ }
312
+ }
313
+
314
+ // ── Share link ────────────────────────────────────────────────────────────
315
+ const handleShare = () => {
316
+ const shareable = models.filter(m => m.url && !m.url.startsWith('blob:') && !m.url.startsWith('data:'))
317
+ if (!shareable.length) { showMsg('warn','No shareable models β€” local uploads cannot be shared via link'); return }
318
+ const payload = {
319
+ n: projectName,
320
+ u: shareable.map(m => m.url),
321
+ m: shareable.map(m => m.name),
322
+ f: fps, t: totalFrames, l: lightingPreset,
323
+ }
324
+ const base = window.location.href.split('?')[0]
325
+ const link = `${base}?project=${encodeURIComponent(JSON.stringify(payload))}`
326
+ navigator.clipboard?.writeText(link)
327
+ .then(() => showMsg('ok', `Share link copied! (${shareable.length} model${shareable.length>1?'s':''} included)`))
328
+ .catch(() => showMsg('info', link, 0))
329
+ }
330
+
331
+ // Estimate bundle size
332
+ const estimatedSize = models.reduce((acc, m) => {
333
+ if (skipModels.has(m.id)) return acc
334
+ // rough: each KB of URL ~= the actual file is fetched; use 500KB as avg GLB estimate
335
+ return acc + 500_000
336
+ }, 0)
337
+
338
+ return (
339
+ <div style={{ padding:14, display:'flex', flexDirection:'column', gap:10, overflowY:'auto', height:'100%' }}>
340
+
341
+ {/* Import preview modal */}
342
+ {preview && (
343
+ <ImportPreview
344
+ preview={preview}
345
+ onLoad={handleLoadPreview}
346
+ onCancel={() => setPreview(null)}
347
+ />
348
+ )}
349
+
350
+ {/* Project header */}
351
+ <div style={{ padding:'12px 14px', borderRadius:'var(--radius)',
352
+ background:'var(--bg2)', border:'1px solid var(--border)' }}>
353
+ <div style={{ fontSize:9, color:'var(--text3)', fontWeight:700,
354
+ letterSpacing:'0.1em', textTransform:'uppercase', marginBottom:6 }}>Project Name</div>
355
+ {editName ? (
356
+ <input value={nameVal}
357
+ onChange={e=>setNameVal(e.target.value)}
358
+ onBlur={()=>{ setProjectName(nameVal); setEditName(false) }}
359
+ onKeyDown={e=>{ if(e.key==='Enter'||e.key==='Escape'){ setProjectName(nameVal); setEditName(false) }}}
360
+ autoFocus style={{ fontSize:15, fontWeight:700, width:'100%' }}/>
361
+ ) : (
362
+ <div style={{ display:'flex', alignItems:'center', justifyContent:'space-between' }}>
363
+ <span style={{ fontSize:15, fontWeight:700, color:'var(--text0)',
364
+ overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>
365
+ {projectName}
366
+ </span>
367
+ <button onClick={()=>{ setNameVal(projectName); setEditName(true) }}
368
+ style={{ background:'none', border:'none', color:'var(--text2)', cursor:'pointer', fontSize:14, padding:'2px 4px' }}>
369
+ ✏️
370
+ </button>
371
+ </div>
372
+ )}
373
+ <div style={{ display:'flex', gap:5, flexWrap:'wrap', marginTop:8 }}>
374
+ <Chip color="var(--accent)">{models.length} model{models.length!==1?'s':''}</Chip>
375
+ <Chip color="var(--warn)">{kfCount} keyframe{kfCount!==1?'s':''}</Chip>
376
+ <Chip color="var(--accent2)">{cameras.length} cam{cameras.length!==1?'s':''}</Chip>
377
+ <Chip color="var(--accent3)">{duration} Β· {fps}fps</Chip>
378
+ </div>
379
+ </div>
380
+
381
+ <StatusBox status={status} />
382
+
383
+ {/* ── Auto-save ── */}
384
+ <div style={{ display:'flex', alignItems:'center', justifyContent:'space-between',
385
+ padding:'9px 12px', borderRadius:'var(--radius-sm)',
386
+ background: autoSave ? 'rgba(6,214,160,0.06)' : 'var(--bg2)',
387
+ border:`1px solid ${autoSave ? 'rgba(6,214,160,0.2)' : 'var(--border)'}`,
388
+ transition:'all 0.2s' }}>
389
+ <div>
390
+ <div style={{ fontSize:11, fontWeight:600, color:'var(--text0)' }}>Auto-save</div>
391
+ <div style={{ fontSize:10, color:'var(--text3)' }}>
392
+ {autoSave ? `Saves every 60s Β· Last: ${lastSaved||'not yet'}` : 'Saves to browser every 60 seconds'}
393
+ </div>
394
+ </div>
395
+ <button onClick={()=>setAutoSave(v=>!v)} style={{
396
+ width:40, height:22, borderRadius:11, border:'none', cursor:'pointer',
397
+ background: autoSave ? 'var(--accent3)' : 'var(--bg4)',
398
+ position:'relative', transition:'background 0.2s', flexShrink:0,
399
+ boxShadow: autoSave ? '0 0 8px rgba(6,214,160,0.4)' : 'none',
400
+ }}>
401
+ <div style={{ position:'absolute', top:3, width:16, height:16, borderRadius:8,
402
+ background:'#fff', transition:'left 0.2s',
403
+ left: autoSave ? 21 : 3, boxShadow:'0 1px 3px rgba(0,0,0,0.4)' }}/>
404
+ </button>
405
+ </div>
406
+
407
+ <Divider label="Save & Export" />
408
+
409
+ {/* Browser save */}
410
+ <BigBtn icon="πŸ’Ύ" color="var(--accent)"
411
+ label="Save to Browser"
412
+ sub={lastSaved ? `Last saved: ${lastSaved}` : 'Instant β€” survives page refresh, same device only'}
413
+ onClick={handleSave} />
414
+
415
+ {/* Quick export */}
416
+ <BigBtn icon="πŸ“„" color="var(--accent)"
417
+ label="Quick Export (.glbstudio)"
418
+ sub="JSON with model URLs + keyframes/cameras/physics. Small file. Needs internet to reload."
419
+ onClick={handleQuickExport} disabled={!hasModels} />
420
+
421
+ {/* Bundle export */}
422
+ <div>
423
+ <BigBtn icon="πŸ“¦" color="var(--accent2)"
424
+ label="Export Full Bundle (.glbstudio)"
425
+ sub={`Embeds GLB data inside file β€” fully self-contained${models.length > 0 ? ` Β· Est. ~${fmtBytes(estimatedSize)}` : ''}`}
426
+ onClick={handleBundle}
427
+ disabled={!hasModels || exporting}
428
+ loading={exporting}
429
+ badge={skipModels.size > 0 ? `${models.length - skipModels.size}/${models.length} models` : undefined}
430
+ />
431
+
432
+ {/* Per-model embed toggle */}
433
+ {hasModels && (
434
+ <button onClick={()=>setShowSkipUI(v=>!v)} style={{
435
+ width:'100%', marginTop:4, padding:'5px 10px',
436
+ background:'transparent', border:'1px solid var(--border)',
437
+ borderRadius:'var(--radius-sm)', color:'var(--text2)',
438
+ fontSize:10, cursor:'pointer', textAlign:'left', transition:'all 0.12s',
439
+ }}>
440
+ {showSkipUI ? 'β–² Hide' : 'β–Ό Configure'} which models to embed
441
+ </button>
442
+ )}
443
+
444
+ {showSkipUI && (
445
+ <div style={{ border:'1px solid var(--border)', borderRadius:'var(--radius-sm)',
446
+ marginTop:4, overflow:'hidden', animation:'fadeUp 0.15s ease' }}>
447
+ {models.map(m => {
448
+ const skip = skipModels.has(m.id)
449
+ const isLocal = m.url?.startsWith('blob:') || m.url?.startsWith('data:')
450
+ return (
451
+ <div key={m.id} style={{ display:'flex', alignItems:'center', gap:8,
452
+ padding:'7px 10px', borderBottom:'1px solid var(--border)',
453
+ background: skip ? 'var(--bg2)' : 'rgba(124,58,237,0.05)' }}>
454
+ <input type="checkbox" checked={!skip}
455
+ onChange={() => setSkipModels(prev => {
456
+ const n = new Set(prev)
457
+ if (n.has(m.id)) n.delete(m.id); else n.add(m.id)
458
+ return n
459
+ })}
460
+ style={{ accentColor:'var(--accent2)', width:14, height:14 }}
461
+ />
462
+ <span style={{ flex:1, fontSize:11, color: skip ? 'var(--text3)' : 'var(--text1)',
463
+ overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>{m.name}</span>
464
+ <span style={{ fontSize:9, color:'var(--text3)', flexShrink:0 }}>
465
+ {isLocal ? 'πŸ“ local' : 'πŸ”— url'}
466
+ </span>
467
+ </div>
468
+ )
469
+ })}
470
+ </div>
471
+ )}
472
+
473
+ {/* Progress bar */}
474
+ {exporting && (
475
+ <div style={{ marginTop:6, padding:'10px 12px', borderRadius:'var(--radius-sm)',
476
+ background:'var(--bg2)', border:'1px solid var(--border)', animation:'fadeUp 0.15s ease' }}>
477
+ <div style={{ display:'flex', justifyContent:'space-between', marginBottom:6, fontSize:11 }}>
478
+ <span style={{ color:'var(--text2)' }}>{progress.msg}</span>
479
+ <span style={{ color:'var(--accent2)', fontFamily:'var(--font-mono)' }}>{progress.pct}%</span>
480
+ </div>
481
+ <div style={{ height:5, background:'var(--bg3)', borderRadius:3 }}>
482
+ <div style={{ height:'100%', borderRadius:3, transition:'width 0.4s',
483
+ width:`${progress.pct}%`,
484
+ background:'linear-gradient(90deg,var(--accent2),var(--accent))' }}/>
485
+ </div>
486
+ </div>
487
+ )}
488
+ </div>
489
+
490
+ {/* Share link */}
491
+ <BigBtn icon="πŸ”—" color="var(--accent3)"
492
+ label="Copy Share Link"
493
+ sub="URL that reopens project with model URLs. Public models only β€” no local uploads."
494
+ onClick={handleShare} disabled={!hasModels} />
495
+
496
+ <Divider label="Import" />
497
+
498
+ {/* Drop zone */}
499
+ <div
500
+ onDrop={e=>{ e.preventDefault(); setDragging(false); handleFile(e.dataTransfer.files[0]) }}
501
+ onDragOver={e=>{ e.preventDefault(); setDragging(true) }}
502
+ onDragLeave={()=>setDragging(false)}
503
+ onClick={()=>fileRef.current?.click()}
504
+ style={{
505
+ border:`2px dashed ${dragging?'var(--accent)':'var(--border-hi)'}`,
506
+ borderRadius:'var(--radius)', padding:'22px 16px',
507
+ textAlign:'center', cursor:'pointer',
508
+ background: dragging ? 'rgba(79,142,255,0.06)' : 'var(--bg2)',
509
+ transition:'all 0.15s',
510
+ }}>
511
+ <div style={{ fontSize:30, marginBottom:7, opacity: dragging?1:0.5 }}>
512
+ {dragging ? 'πŸ“‚' : 'πŸ“₯'}
513
+ </div>
514
+ <div style={{ fontSize:12, fontWeight:600, color:'var(--text1)', marginBottom:3 }}>
515
+ {dragging ? 'Drop to preview & import' : 'Drop .glbstudio here'}
516
+ </div>
517
+ <div style={{ fontSize:10, color:'var(--text3)' }}>
518
+ or click to browse Β· Preview shown before loading
519
+ </div>
520
+ <input ref={fileRef} type="file" accept=".glbstudio,.json"
521
+ style={{display:'none'}} onChange={e=>{ handleFile(e.target.files[0]); e.target.value='' }}/>
522
+ </div>
523
+
524
+ <BigBtn icon="πŸ“‚" color="var(--warn)"
525
+ label="Load from Browser Storage"
526
+ sub="Loads the last project saved with 'Save to Browser'"
527
+ onClick={handleLoad} />
528
+
529
+ {/* Recent projects */}
530
+ {recent.length > 0 && (
531
+ <div>
532
+ <button onClick={()=>setShowRecent(v=>!v)} style={{
533
+ width:'100%', padding:'7px 10px', background:'transparent',
534
+ border:'1px solid var(--border)', borderRadius:'var(--radius-sm)',
535
+ color:'var(--text2)', fontSize:11, cursor:'pointer', textAlign:'left',
536
+ display:'flex', justifyContent:'space-between', alignItems:'center',
537
+ }}>
538
+ <span>πŸ• Recent projects ({recent.length})</span>
539
+ <span style={{ fontSize:10 }}>{showRecent?'β–²':'β–Ό'}</span>
540
+ </button>
541
+ {showRecent && (
542
+ <div style={{ border:'1px solid var(--border)', borderRadius:'var(--radius-sm)',
543
+ marginTop:4, overflow:'hidden', animation:'fadeUp 0.15s ease' }}>
544
+ {recent.map((r,i) => (
545
+ <div key={i} style={{ padding:'7px 10px', borderBottom:'1px solid var(--border)',
546
+ display:'flex', alignItems:'center', gap:8 }}>
547
+ <span style={{ fontSize:14 }}>{r.type==='bundle'?'πŸ“¦':'πŸ“„'}</span>
548
+ <div style={{ flex:1, minWidth:0 }}>
549
+ <div style={{ fontSize:11, fontWeight:600, color:'var(--text1)',
550
+ overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>{r.name}</div>
551
+ <div style={{ fontSize:9, color:'var(--text3)' }}>
552
+ {fmtDate(r.date)} Β· {r.models} model{r.models!==1?'s':''}
553
+ </div>
554
+ </div>
555
+ </div>
556
+ ))}
557
+ <button onClick={()=>{ clearRecentProjects?.(); setRecent([]) }}
558
+ style={{ width:'100%', padding:'6px', background:'transparent',
559
+ border:'none', color:'var(--text3)', cursor:'pointer', fontSize:10 }}>
560
+ Clear history
561
+ </button>
562
+ </div>
563
+ )}
564
+ </div>
565
+ )}
566
+
567
+ {/* Format guide */}
568
+ <div style={{ padding:'12px 14px', borderRadius:'var(--radius)',
569
+ background:'var(--bg2)', border:'1px solid var(--border)',
570
+ fontSize:11, color:'var(--text2)', lineHeight:1.9 }}>
571
+ <div style={{ fontWeight:700, color:'var(--text1)', marginBottom:6 }}>πŸ“‹ Format guide</div>
572
+ {[
573
+ ['πŸ“„ Quick Export', 'URLs only Β· small file Β· needs internet'],
574
+ ['πŸ“¦ Full Bundle', 'Models embedded Β· self-contained Β· shareable offline'],
575
+ ['πŸ”— Share Link', 'URL only Β· no file Β· public models only'],
576
+ ['πŸ’Ύ Browser Save', 'Instant Β· same device Β· clears with browser data'],
577
+ ].map(([k,v])=>(
578
+ <div key={k} style={{ display:'flex', gap:6 }}>
579
+ <span style={{ color:'var(--text0)', fontWeight:600, flexShrink:0 }}>{k}</span>
580
+ <span style={{ color:'var(--text3)' }}>β€” {v}</span>
581
+ </div>
582
+ ))}
583
+ </div>
584
+
585
+ </div>
586
+ )
587
+ }
src/components/Toolbar.jsx CHANGED
@@ -83,8 +83,8 @@ export default function Toolbar() {
83
  const handleImport = (e) => {
84
  const file = e.target.files[0]
85
  if (!file) return
86
- useStore.getState().importProjectJSON(file).then(ok => {
87
- if (!ok) alert('Failed to import project file')
88
  })
89
  e.target.value = ''
90
  }
 
83
  const handleImport = (e) => {
84
  const file = e.target.files[0]
85
  if (!file) return
86
+ useStore.getState().importProjectJSON(file).then(result => {
87
+ if (!result?.ok) console.warn('Import failed:', result?.error)
88
  })
89
  e.target.value = ''
90
  }
src/store/useStore.js CHANGED
@@ -342,38 +342,253 @@ const useStore = create(
342
  } catch(e) { console.error('Load failed', e); return false }
343
  },
344
 
 
345
  exportProjectJSON: () => {
346
- const s = get()
347
- const data = JSON.stringify({ version:2, projectName:s.projectName, models:s.models, keyframes:s.keyframes, cameras:s.cameras, totalFrames:s.totalFrames, fps:s.fps, lightingPreset:s.lightingPreset }, null, 2)
348
- const blob = new Blob([data], { type:'application/json' })
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
349
  const url = URL.createObjectURL(blob)
350
  const a = document.createElement('a')
351
  a.href=url; a.download=`${s.projectName.replace(/\s+/g,'_')}.glbstudio`; a.click()
352
  URL.revokeObjectURL(url)
353
  },
354
 
355
- importProjectJSON: (file) => new Promise((resolve) => {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
356
  const reader = new FileReader()
357
  reader.onload = e => {
358
  try {
359
  const data = JSON.parse(e.target.result)
360
- set(state => {
361
- state.projectName = data.projectName || 'Imported'
362
- state.models = data.models || []
363
- state.keyframes = data.keyframes || {}
364
- state.cameras = data.cameras || []
365
- state.totalFrames = data.totalFrames || 300
366
- state.fps = data.fps || 30
367
- state.lightingPreset= data.lightingPreset||'studio'
368
- state.undoStack = []
369
- state.redoStack = []
 
 
 
 
 
370
  })
371
- resolve(true)
372
- } catch { resolve(false) }
373
  }
374
  reader.readAsText(file)
375
  }),
376
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
377
  // ── Helpers ────────────────────────────────────────────────────────
378
  getSelectedModel: () => {
379
  const { models, selectedModelId } = get()
 
342
  } catch(e) { console.error('Load failed', e); return false }
343
  },
344
 
345
+ // Export project JSON (metadata only, no model blobs)
346
  exportProjectJSON: () => {
347
+ const s = get()
348
+ const data = {
349
+ version: 3,
350
+ projectName: s.projectName,
351
+ models: s.models.map(m => ({
352
+ id:m.id, url:m.url, name:m.name,
353
+ position:m.position, rotation:m.rotation, scale:m.scale,
354
+ visible:m.visible, activeAnimation:m.activeAnimation,
355
+ animationSpeed:m.animationSpeed, animationPlaying:m.animationPlaying,
356
+ materialOverride:m.materialOverride,
357
+ castShadow:m.castShadow, receiveShadow:m.receiveShadow,
358
+ })),
359
+ keyframes: s.keyframes,
360
+ cameras: s.cameras,
361
+ totalFrames: s.totalFrames,
362
+ fps: s.fps,
363
+ loopPlayback: s.loopPlayback,
364
+ lightingPreset: s.lightingPreset,
365
+ skybox: s.skybox,
366
+ physicsEnabled: s.physicsEnabled,
367
+ gravity: s.gravity,
368
+ modelPhysics: s.modelPhysics,
369
+ }
370
+ const blob = new Blob([JSON.stringify(data, null, 2)], { type:'application/json' })
371
  const url = URL.createObjectURL(blob)
372
  const a = document.createElement('a')
373
  a.href=url; a.download=`${s.projectName.replace(/\s+/g,'_')}.glbstudio`; a.click()
374
  URL.revokeObjectURL(url)
375
  },
376
 
377
+ // ── Safe base64 encode β€” handles large buffers without stack overflow ──
378
+ _arrayBufferToBase64: (buffer) => {
379
+ const bytes = new Uint8Array(buffer)
380
+ const CHUNK = 8192
381
+ let result = ''
382
+ for (let i = 0; i < bytes.length; i += CHUNK) {
383
+ result += String.fromCharCode(...bytes.subarray(i, i + CHUNK))
384
+ }
385
+ return btoa(result)
386
+ },
387
+
388
+ // ── Fetch a URL and return { b64, mime, size } or null on failure ────────
389
+ _fetchAsB64: async (url, arrayBufferToBase64) => {
390
+ try {
391
+ const res = await fetch(url, { mode: 'cors' })
392
+ if (!res.ok) throw new Error(`HTTP ${res.status}`)
393
+ const buf = await res.arrayBuffer()
394
+ const ext = url.split('?')[0].split('.').pop().toLowerCase()
395
+ const mime = ext === 'glb' ? 'model/gltf-binary' :
396
+ ext === 'gltf' ? 'model/gltf+json' :
397
+ 'application/octet-stream'
398
+ return { b64: arrayBufferToBase64(buf), mime, size: buf.byteLength }
399
+ } catch(e) {
400
+ console.warn('[Bundle] fetch failed for', url, e.message)
401
+ return null
402
+ }
403
+ },
404
+
405
+ // ── Export full bundle β€” safely embeds model GLBs as base64 ─────────────
406
+ exportProjectBundle: async (onProgress, embedOptions = {}) => {
407
+ const s = get()
408
+ const a2b = get()._arrayBufferToBase64
409
+ const fb = get()._fetchAsB64
410
+ onProgress?.('Preparing…', 2)
411
+
412
+ const modelEntries = []
413
+ let totalBytes = 0
414
+
415
+ for (let i = 0; i < s.models.length; i++) {
416
+ const m = s.models[i]
417
+ const pct = 5 + Math.round((i / s.models.length) * 70)
418
+ const skip = embedOptions.skip?.includes(m.id)
419
+ onProgress?.(`${skip?'Skipping':'Packing'} model ${i+1}/${s.models.length}: ${m.name}`, pct)
420
+
421
+ const entry = {
422
+ id:m.id, url:m.url, name:m.name,
423
+ position:m.position, rotation:m.rotation, scale:m.scale,
424
+ visible:m.visible, activeAnimation:m.activeAnimation,
425
+ animationSpeed:m.animationSpeed, animationPlaying:m.animationPlaying,
426
+ materialOverride:m.materialOverride,
427
+ castShadow:m.castShadow, receiveShadow:m.receiveShadow,
428
+ }
429
+
430
+ if (!skip && m.url && !m.url.startsWith('data:')) {
431
+ const fetched = await fb(m.url, a2b)
432
+ if (fetched) {
433
+ entry.embeddedBlob = `data:${fetched.mime};base64,${fetched.b64}`
434
+ entry.embeddedSize = fetched.size
435
+ totalBytes += fetched.size
436
+ } else {
437
+ entry.embedError = 'fetch_failed'
438
+ }
439
+ } else if (m.url?.startsWith('data:')) {
440
+ // Already a data URL (local file upload) β€” keep as-is
441
+ entry.embeddedBlob = m.url
442
+ }
443
+ modelEntries.push(entry)
444
+ }
445
+
446
+ onProgress?.('Building bundle JSON…', 80)
447
+ const bundle = {
448
+ version: 4,
449
+ appVersion: 'GLB Studio 2.0',
450
+ bundleDate: new Date().toISOString(),
451
+ projectName: s.projectName,
452
+ models: modelEntries,
453
+ keyframes: s.keyframes,
454
+ cameras: s.cameras,
455
+ totalFrames: s.totalFrames,
456
+ fps: s.fps,
457
+ loopPlayback: s.loopPlayback,
458
+ lightingPreset: s.lightingPreset,
459
+ skybox: s.skybox,
460
+ physicsEnabled: s.physicsEnabled,
461
+ gravity: s.gravity,
462
+ modelPhysics: s.modelPhysics,
463
+ stats: {
464
+ modelCount: modelEntries.length,
465
+ keyframeCount: Object.keys(s.keyframes).length,
466
+ cameraCount: s.cameras.length,
467
+ embeddedModels:modelEntries.filter(m=>m.embeddedBlob).length,
468
+ embeddedBytes: totalBytes,
469
+ }
470
+ }
471
+
472
+ onProgress?.('Saving file…', 92)
473
+ const json = JSON.stringify(bundle)
474
+ const fileBlob = new Blob([json], { type:'application/json' })
475
+ const fileUrl = URL.createObjectURL(fileBlob)
476
+ const a = document.createElement('a')
477
+ const safeName = s.projectName.replace(/[^a-z0-9_\-]/gi,'_') || 'project'
478
+ a.href = fileUrl
479
+ a.download = `${safeName}_bundle.glbstudio`
480
+ document.body.appendChild(a); a.click(); document.body.removeChild(a)
481
+ setTimeout(() => URL.revokeObjectURL(fileUrl), 5000)
482
+ onProgress?.('Done!', 100)
483
+
484
+ // Save to recent projects in localStorage
485
+ try {
486
+ const recent = JSON.parse(localStorage.getItem('glb_recent') || '[]')
487
+ const entry = { name:s.projectName, date:new Date().toISOString(), type:'bundle', models:modelEntries.length }
488
+ localStorage.setItem('glb_recent', JSON.stringify([entry, ...recent.slice(0,9)]))
489
+ } catch {}
490
+
491
+ return {
492
+ modelCount: modelEntries.length,
493
+ embeddedCount: modelEntries.filter(m=>m.embeddedBlob).length,
494
+ failedCount: modelEntries.filter(m=>m.embedError).length,
495
+ size: fileBlob.size,
496
+ }
497
+ },
498
+
499
+ // ── Parse a .glbstudio file β€” returns preview WITHOUT loading ────────────
500
+ previewBundle: (file) => new Promise((resolve) => {
501
  const reader = new FileReader()
502
  reader.onload = e => {
503
  try {
504
  const data = JSON.parse(e.target.result)
505
+ resolve({
506
+ ok: true,
507
+ projectName: data.projectName || 'Unknown',
508
+ version: data.version || 1,
509
+ bundleDate: data.bundleDate || null,
510
+ modelCount: (data.models || []).length,
511
+ keyframeCount: Object.keys(data.keyframes || {}).length,
512
+ cameraCount: (data.cameras || []).length,
513
+ totalFrames: data.totalFrames || 0,
514
+ fps: data.fps || 30,
515
+ lightingPreset: data.lightingPreset|| 'studio',
516
+ embeddedModels: (data.models || []).filter(m=>m.embeddedBlob).length,
517
+ models: (data.models || []).map(m=>({ id:m.id, name:m.name, hasBlob:!!m.embeddedBlob })),
518
+ stats: data.stats || null,
519
+ _raw: data, // keep for actual load
520
  })
521
+ } catch(err) { resolve({ ok:false, error: err.message }) }
 
522
  }
523
  reader.readAsText(file)
524
  }),
525
 
526
+ // ── Load a bundle that was already parsed by previewBundle ───────────────
527
+ loadBundle: (parsedData) => {
528
+ const data = parsedData._raw || parsedData
529
+ const a2blob = (dataUrl) => {
530
+ if (!dataUrl) return null
531
+ try {
532
+ const [header, b64] = dataUrl.split(',')
533
+ const mime = header.match(/data:([^;]+)/)?.[1] || 'model/gltf-binary'
534
+ const binary = atob(b64)
535
+ const bytes = new Uint8Array(binary.length)
536
+ for (let i=0; i<binary.length; i++) bytes[i] = binary.charCodeAt(i)
537
+ return URL.createObjectURL(new Blob([bytes], { type: mime }))
538
+ } catch { return null }
539
+ }
540
+
541
+ const models = (data.models || []).map(m => {
542
+ const blobUrl = m.embeddedBlob ? a2blob(m.embeddedBlob) : null
543
+ return { ...m, url: blobUrl || m.url, embeddedBlob: undefined, embeddedSize: undefined, embedError: undefined }
544
+ })
545
+
546
+ set(state => {
547
+ state.projectName = data.projectName || 'Imported'
548
+ state.models = models
549
+ state.keyframes = data.keyframes || {}
550
+ state.cameras = data.cameras?.length ? data.cameras : [{ id:'cam_1',name:'Camera 1',position:[5,3,5],target:[0,0,0],fov:50,near:0.01,far:1000 }]
551
+ state.totalFrames = data.totalFrames || 300
552
+ state.fps = data.fps || 30
553
+ state.loopPlayback = data.loopPlayback || false
554
+ state.lightingPreset = data.lightingPreset || 'studio'
555
+ state.skybox = data.skybox || { type:'preset', value:null, bgColor:'#080810', showBg:false }
556
+ state.physicsEnabled = data.physicsEnabled || false
557
+ state.gravity = data.gravity ?? -9.82
558
+ state.modelPhysics = data.modelPhysics || {}
559
+ state.selectedModelId = null
560
+ state.undoStack = []
561
+ state.redoStack = []
562
+ })
563
+
564
+ // Track recent
565
+ try {
566
+ const recent = JSON.parse(localStorage.getItem('glb_recent') || '[]')
567
+ const entry = { name:data.projectName, date:new Date().toISOString(), type: models.some(m=>m.url?.startsWith('blob:'))?'bundle':'url', models:models.length }
568
+ localStorage.setItem('glb_recent', JSON.stringify([entry, ...recent.slice(0,9)]))
569
+ } catch {}
570
+
571
+ return { ok:true, modelCount:models.length, embeddedCount:models.filter(m=>m.url?.startsWith('blob:')).length }
572
+ },
573
+
574
+ // ── importProjectJSON: convenience wrapper (preview + load) ─────────────
575
+ importProjectJSON: (file) => new Promise(async (resolve) => {
576
+ const store = get()
577
+ const preview = await store.previewBundle(file)
578
+ if (!preview.ok) { resolve({ ok:false, error: preview.error }); return }
579
+ const result = store.loadBundle(preview)
580
+ resolve(result)
581
+ }),
582
+
583
+ // ── Recent projects (from localStorage) ─────────────────────────────────
584
+ getRecentProjects: () => {
585
+ try { return JSON.parse(localStorage.getItem('glb_recent') || '[]') }
586
+ catch { return [] }
587
+ },
588
+ clearRecentProjects: () => {
589
+ try { localStorage.removeItem('glb_recent') } catch {}
590
+ },
591
+
592
  // ── Helpers ────────────────────────────────────────────────────────
593
  getSelectedModel: () => {
594
  const { models, selectedModelId } = get()