Splat Walrus
Gaussian Splat Renderer for LED Wall Virtual Production
v3.7.0
Drop a .ply or .splat file here
or click to browse
or
v3.7.0
`); ledWindow.document.close(); // Move the canvas to the LED window and set up a mirrored render const renderLoop = () => { if (!ledWindow || ledWindow.closed) { return; } if (state.renderer && state.camera && state.threeScene) { // Draw to a second canvas in the LED window if (!ledWindow._canvas) { ledWindow._canvas = ledWindow.document.createElement('canvas'); ledWindow.document.body.appendChild(ledWindow._canvas); ledWindow._ctx = ledWindow._canvas.getContext('2d'); } const srcCanvas = state.renderer.domElement; ledWindow._canvas.width = ledWindow.innerWidth; ledWindow._canvas.height = ledWindow.innerHeight; ledWindow._ctx.drawImage(srcCanvas, 0, 0, ledWindow._canvas.width, ledWindow._canvas.height); } requestAnimationFrame(renderLoop); }; renderLoop(); // Auto-fullscreen the LED window ledWindow.addEventListener('click', () => { ledWindow.document.documentElement.requestFullscreen?.(); }); toast('LED Wall window opened — drag it to your LED wall display, then click to go fullscreen', 'success', 6000); }); // ── Panel Toggle ── $('btn-panel-toggle').addEventListener('click', () => { sidePanel.classList.toggle('hidden'); setTimeout(updateFrameOverlay, 350); // after transition }); // ── Panel Resize ── const resizeHandle = $('panel-resize-handle'); let isResizing = false; function positionResizeHandle() { if (sidePanel.classList.contains('hidden')) { resizeHandle.style.display = 'none'; } else { resizeHandle.style.display = 'block'; resizeHandle.style.left = (window.innerWidth - sidePanel.offsetWidth - 6) + 'px'; } } // Keep handle positioned new MutationObserver(positionResizeHandle).observe(sidePanel, { attributes: true, attributeFilter: ['class', 'style'] }); window.addEventListener('resize', positionResizeHandle); resizeHandle.addEventListener('mousedown', e => { e.preventDefault(); isResizing = true; sidePanel.classList.add('resizing'); document.body.style.cursor = 'col-resize'; document.body.style.userSelect = 'none'; }); window.addEventListener('mousemove', e => { if (!isResizing) return; const newWidth = window.innerWidth - e.clientX; const clamped = Math.max(260, Math.min(newWidth, window.innerWidth * 0.7)); sidePanel.style.width = clamped + 'px'; positionResizeHandle(); updateFrameOverlay(); }); window.addEventListener('mouseup', () => { if (isResizing) { isResizing = false; sidePanel.classList.remove('resizing'); document.body.style.cursor = ''; document.body.style.userSelect = ''; } }); // ── Video Model Config ── const videoModelConfig = { 'kling-v3-std': { endpoint: 'fal-ai/kling-video/v3/standard/image-to-video', imageParam: 'start_image_url', durations: ['3','5','8','10','15'], durationFmt: s => s, aspect: false, resolution: false, negPrompt: true, audioParam: 'generate_audio', audioDefault: false, pricePerSec: 0.084, pricePerSecAudio: 0.126 }, 'kling-v3-pro': { endpoint: 'fal-ai/kling-video/v3/pro/image-to-video', imageParam: 'start_image_url', durations: ['3','5','8','10','15'], durationFmt: s => s, aspect: false, resolution: false, negPrompt: true, audioParam: 'generate_audio', audioDefault: false, pricePerSec: 0.112, pricePerSecAudio: 0.168 }, 'seedance': { endpoint: 'bytedance/seedance-2.0/image-to-video', imageParam: 'image_url', durations: ['4','5','8','10','15'], durationFmt: s => s, aspect: ['auto','16:9','9:16','1:1','21:9','4:3','3:4'], resolution: ['720p','1080p'], negPrompt: false, audioParam: 'generate_audio', audioDefault: false, pricePerSec: 0.242, pricePerSecAudio: 0.242 }, 'veo31-fast': { endpoint: 'fal-ai/veo3.1/image-to-video', imageParam: 'image_url', durations: ['4','6','8'], durationFmt: s => s + 's', aspect: ['auto','16:9','9:16'], resolution: ['720p','1080p','4k'], negPrompt: true, audioParam: 'generate_audio', audioDefault: false, pricePerSec: 0.20, pricePerSecAudio: 0.40 }, 'hailuo': { endpoint: 'fal-ai/minimax/hailuo-02/pro/image-to-video', imageParam: 'image_url', durations: ['5','10','15'], durationFmt: s => s, aspect: ['16:9','9:16','1:1'], resolution: false, negPrompt: false, audioParam: 'audio', audioDefault: false, pricePerSec: 0.08, pricePerSecAudio: 0.08 }, 'wan-pro': { endpoint: 'fal-ai/wan-pro/image-to-video', imageParam: 'image_url', durations: ['3','5','7','9'], durationFmt: s => s, aspect: ['auto','16:9','9:16','1:1','4:3'], resolution: ['480p','720p'], negPrompt: true, audioParam: 'generate_audio', audioDefault: false, pricePerSec: 0.14, pricePerSecAudio: 0.14 }, 'luma-ray2': { endpoint: 'fal-ai/luma-dream-machine/ray-2/image-to-video', imageParam: 'image_url', durations: ['5','9'], durationFmt: s => s, aspect: ['16:9','9:16','1:1','4:3','3:4','21:9'], resolution: ['720p','1080p'], negPrompt: false, audioParam: 'generate_audio', audioDefault: false, pricePerSec: 0.16, pricePerSecAudio: 0.16 }, 'ltx-2': { endpoint: 'fal-ai/ltx-2.3/image-to-video', imageParam: 'image_url', durations: ['6','8','10'], durationFmt: s => s, aspect: ['16:9','9:16','1:1'], resolution: ['1080p','1440p','2160p'], negPrompt: true, audioParam: 'generate_audio', audioDefault: false, pricePerSec: 0.06, pricePerSecAudio: 0.06 }, 'sana-video': { endpoint: 'fal-ai/sana-video/image-to-video', imageParam: 'image_url', durations: ['3','5','8','12'], durationFmt: s => s, aspect: ['16:9','9:16','1:1','4:3','3:4'], resolution: ['480p','720p'], negPrompt: true, audioParam: 'generate_audio', audioDefault: false, pricePerSec: 0.15, pricePerSecAudio: 0.15 } }; function updateVideoSettings() { const modelKey = $('gen-model').value; const cfg = videoModelConfig[modelKey]; if (!cfg) return; // Duration options const durSelect = $('vid-duration'); durSelect.innerHTML = ''; cfg.durations.forEach(d => { const opt = document.createElement('option'); opt.value = d; opt.textContent = d + ' seconds'; durSelect.appendChild(opt); }); durSelect.value = cfg.durations.includes('5') ? '5' : cfg.durations[0]; // Aspect ratio if (cfg.aspect) { $('vid-opt-aspect').style.display = 'flex'; const aspSelect = $('vid-aspect'); aspSelect.innerHTML = ''; cfg.aspect.forEach(a => { const opt = document.createElement('option'); opt.value = a; opt.textContent = a === 'auto' ? 'Auto' : a; aspSelect.appendChild(opt); }); aspSelect.value = cfg.aspect.includes('16:9') ? '16:9' : cfg.aspect[0]; } else { $('vid-opt-aspect').style.display = 'none'; } // Resolution if (cfg.resolution) { $('vid-opt-resolution').style.display = 'flex'; const resSelect = $('vid-resolution'); resSelect.innerHTML = ''; cfg.resolution.forEach(r => { const opt = document.createElement('option'); opt.value = r; opt.textContent = r; resSelect.appendChild(opt); }); resSelect.value = '720p'; } else { $('vid-opt-resolution').style.display = 'none'; } // Audio $('vid-audio').checked = cfg.audioDefault; // Negative prompt $('vid-opt-negprompt').style.display = cfg.negPrompt ? 'flex' : 'none'; updateCostEstimate(); } function updateCostEstimate() { const modelKey = $('gen-model').value; const cfg = videoModelConfig[modelKey]; if (!cfg) return; const dur = parseInt($('vid-duration').value) || 5; const audio = $('vid-audio').checked; const rate = audio ? cfg.pricePerSecAudio : cfg.pricePerSec; const est = (dur * rate).toFixed(2); $('cost-estimate').textContent = `Est. cost: $${est} (${dur}s × $${rate.toFixed(3)}/s)`; } $('gen-model').addEventListener('change', updateVideoSettings); $('vid-duration').addEventListener('change', updateCostEstimate); $('vid-audio').addEventListener('change', updateCostEstimate); updateVideoSettings(); // ── Image Model Settings ── function updateImageSettings() { const modelKey = $('img-model').value; const provider = $('img-model').selectedOptions[0]?.dataset?.provider; // OpenAI: quality, background, n — no style, no seed $('img-opt-quality').style.display = provider === 'openai' ? 'flex' : 'none'; $('img-opt-background').style.display = provider === 'openai' ? 'flex' : 'none'; // Recraft: style — no quality, no background $('img-opt-style').style.display = modelKey === 'recraft-v3' ? 'flex' : 'none'; // fal.ai models: seed, n $('img-opt-seed').style.display = provider === 'fal' ? 'flex' : 'none'; $('img-opt-n').style.display = (provider === 'fal' || provider === 'openai') ? 'flex' : 'none'; // Gemini: no extra settings (just aspect/prompt) $('img-settings').style.display = provider === 'google' ? 'none' : 'flex'; } $('img-model').addEventListener('change', updateImageSettings); updateImageSettings(); // ── 3D Model Settings ── function updateSplatSettings() { const model = $('splat-gen-model').value; // TripoSR: format, removebg, resolution — no quality, material, textured, seed $('splat-opt-removebg').style.display = model === 'triposr' ? 'flex' : 'none'; $('splat-opt-resolution').style.display = model !== 'rodin' ? 'flex' : 'none'; // Rodin: format (many), quality, material, seed — no removebg, no resolution slider $('splat-opt-quality').style.display = model === 'rodin' ? 'flex' : 'none'; $('splat-opt-material').style.display = model === 'rodin' ? 'flex' : 'none'; // Hunyuan3D: textured, seed, resolution $('splat-opt-textured').style.display = model === 'hunyuan3d' ? 'flex' : 'none'; // Seed: hunyuan3d and rodin $('splat-opt-seed').style.display = (model === 'hunyuan3d' || model === 'rodin') ? 'flex' : 'none'; // Format options differ per model const fmtSelect = $('splat-format'); fmtSelect.innerHTML = ''; if (model === 'rodin') { ['glb','usdz','fbx','obj','stl'].forEach(f => { const o = document.createElement('option'); o.value = f; o.textContent = f.toUpperCase(); fmtSelect.appendChild(o); }); } else { ['glb','obj'].forEach(f => { const o = document.createElement('option'); o.value = f; o.textContent = f.toUpperCase(); fmtSelect.appendChild(o); }); } } $('splat-gen-model').addEventListener('change', updateSplatSettings); updateSplatSettings(); // Show preview of selected video source // gen-source visual picker handles selection via updateGenSourceDropdown() // ── Crop Image to Aspect Ratio ── function cropImageToAspect(dataUrl, targetRatio) { return new Promise((resolve) => { if (!targetRatio || targetRatio === 'none') { resolve(dataUrl); return; } const [tw, th] = targetRatio.split(':').map(Number); const target = tw / th; const img = new Image(); img.onload = () => { const srcRatio = img.width / img.height; let sx = 0, sy = 0, sw = img.width, sh = img.height; if (srcRatio > target) { // Too wide — crop sides sw = Math.round(img.height * target); sx = Math.round((img.width - sw) / 2); } else if (srcRatio < target) { // Too tall — crop top/bottom sh = Math.round(img.width / target); sy = Math.round((img.height - sh) / 2); } const canvas = document.createElement('canvas'); canvas.width = sw; canvas.height = sh; const ctx = canvas.getContext('2d'); ctx.drawImage(img, sx, sy, sw, sh, 0, 0, sw, sh); resolve(canvas.toDataURL('image/png')); }; img.onerror = () => resolve(dataUrl); img.src = dataUrl; }); } // ── API Key Modal ── let activeKeyType = 'fal'; function openKeyModal(type, title, placeholder) { activeKeyType = type; $('api-modal').querySelector('h3').textContent = title; const keys = { fal: state.apiKey, openai: state.openaiKey, google: state.googleKey }; $('api-key-input').value = keys[type] || ''; $('api-key-input').placeholder = placeholder; $('api-modal').classList.remove('hidden'); } $('btn-api-key').addEventListener('click', () => openKeyModal('fal', 'fal.ai API Key', 'fal-xxxxxxxx')); $('btn-openai-key').addEventListener('click', () => openKeyModal('openai', 'OpenAI API Key', 'sk-xxxxxxxx')); $('btn-google-key').addEventListener('click', () => openKeyModal('google', 'Google AI API Key', 'AIzaxxxxxxxx')); $('btn-modal-cancel').addEventListener('click', () => $('api-modal').classList.add('hidden')); $('btn-modal-save').addEventListener('click', () => { const val = $('api-key-input').value.trim(); if (activeKeyType === 'fal') { state.apiKey = val; localStorage.setItem('splatwalrus-api-key', val); } else if (activeKeyType === 'openai') { state.openaiKey = val; localStorage.setItem('splatwalrus-openai-key', val); } else if (activeKeyType === 'google') { state.googleKey = val; localStorage.setItem('splatwalrus-google-key', val); } $('api-modal').classList.add('hidden'); if (state.screenshots.length > 0) $('btn-generate').disabled = !state.apiKey; setStatus(val ? `${activeKeyType} API key saved` : `${activeKeyType} API key cleared`); }); $('btn-modal-clear').addEventListener('click', () => { $('api-key-input').value = ''; if (activeKeyType === 'fal') { state.apiKey = ''; localStorage.removeItem('splatwalrus-api-key'); } else if (activeKeyType === 'openai') { state.openaiKey = ''; localStorage.removeItem('splatwalrus-openai-key'); } else if (activeKeyType === 'google') { state.googleKey = ''; localStorage.removeItem('splatwalrus-google-key'); } $('api-modal').classList.add('hidden'); setStatus(`${activeKeyType} API key cleared`); }); $('btn-clear-all-keys').addEventListener('click', () => { if (!confirm('Clear all API keys from this browser?')) return; state.apiKey = ''; state.openaiKey = ''; state.googleKey = ''; localStorage.removeItem('splatwalrus-api-key'); localStorage.removeItem('splatwalrus-openai-key'); localStorage.removeItem('splatwalrus-google-key'); setStatus('All API keys cleared'); }); // ── Video Generation ── $('btn-generate').addEventListener('click', async () => { if (!state.apiKey) { toast('fal.ai API key required for video generation. Add it in Settings.', 'warning'); openKeyModal('fal', 'fal.ai API Key', 'fal-xxxxxxxx'); return; } const srcIdx = parseInt($('gen-source').value); const ss = videoSources[srcIdx]; if (!ss) { toast('Select a source image first', 'warning'); return; } const modelKey = $('gen-model').value; const prompt = $('gen-prompt').value.trim() || 'smooth cinematic camera movement'; // Cost calculated dynamically from model config + settings const cfg = videoModelConfig[modelKey]; if (!cfg) { toast('Unknown model', 'error'); return; } const endpoint = cfg.endpoint; $('btn-generate').disabled = true; $('btn-generate').textContent = 'Uploading...'; showProgress('vid', 5, 'Uploading image...'); setStatus('Uploading image to fal.ai CDN...'); try { // Step 1: Crop source image if needed, then upload to fal.ai CDN const cropRatio = $('gen-crop').value; let sourceData = ss.dataUrl; if (cropRatio && cropRatio !== 'none') { toast('Cropping image...', 'info'); sourceData = await cropImageToAspect(sourceData, cropRatio); } toast('Uploading image to fal.ai...', 'info'); const imageUrl = await uploadToFalCDN(sourceData); console.log('Uploaded image URL:', imageUrl); if (!imageUrl || imageUrl.startsWith('data:')) { throw new Error('Image upload to fal.ai CDN failed. Check your API key and try again.'); } // Step 2: Build model-specific request body const dur = $('vid-duration').value; const requestBody = { [cfg.imageParam]: imageUrl, prompt, duration: cfg.durationFmt(dur) }; // Audio if ($('vid-audio').checked) { requestBody[cfg.audioParam] = true; } // Aspect ratio if (cfg.aspect && $('vid-aspect').value) { requestBody.aspect_ratio = $('vid-aspect').value; } // Resolution if (cfg.resolution && $('vid-resolution').value) { requestBody.resolution = $('vid-resolution').value; } // Negative prompt if (cfg.negPrompt) { const neg = $('vid-negprompt').value.trim(); if (neg) requestBody.negative_prompt = neg; } // Hailuo-specific if (modelKey === 'hailuo') { requestBody.prompt_optimizer = true; } // Update cost based on actual settings const audio = $('vid-audio').checked; const rate = audio ? cfg.pricePerSecAudio : cfg.pricePerSec; const estCost = parseInt(dur) * rate; console.log('Video gen request:', endpoint, JSON.stringify(requestBody).slice(0, 300)); $('btn-generate').textContent = 'Submitting...'; toast('Submitting video generation...', 'info'); setStatus('Submitting to fal.ai...'); // Step 3: Submit to queue const submitResp = await fetch(`https://queue.fal.run/${endpoint}`, { method: 'POST', headers: { 'Authorization': `Key ${state.apiKey}`, 'Content-Type': 'application/json' }, body: JSON.stringify(requestBody) }); const submitText = await submitResp.text(); console.log('Submit response:', submitResp.status, submitText.slice(0, 500)); if (!submitResp.ok) { throw new Error(`fal.ai error ${submitResp.status}: ${submitText.slice(0, 300)}`); } let submitData; try { submitData = JSON.parse(submitText); } catch(e) { throw new Error(`fal.ai returned invalid JSON: "${submitText.slice(0, 200)}"`); } // Check if it completed synchronously (some fast models do this) const requestId = submitData.request_id; if (!requestId) { const videoUrl = submitData.video?.url || submitData.output?.url || submitData.data?.video_url; if (videoUrl) { await handleVideoResult(videoUrl, estCost); return; } console.error('Full submit response:', submitData); throw new Error('No request_id returned. Response: ' + submitText.slice(0, 200)); } // Use URLs from fal.ai response (don't construct manually — nested endpoints break) const statusUrl = submitData.status_url || `https://queue.fal.run/${endpoint}/requests/${requestId}/status`; const responseUrl = submitData.response_url || `https://queue.fal.run/${endpoint}/requests/${requestId}`; console.log('Poll URLs:', { statusUrl, responseUrl, requestId }); toast('Video generating... this can take 1-5 minutes', 'info', 10000); setStatus(`Generating video...`); $('btn-generate').textContent = 'Generating...'; // Step 4: Poll for result (up to 10 minutes, adaptive interval) let result = null; const startTime = Date.now(); const maxWait = 10 * 60 * 1000; // 10 minutes let pollCount = 0; let failedPolls = 0; while (Date.now() - startTime < maxWait) { const elapsed = Date.now() - startTime; const interval = elapsed < 60000 ? 2000 : elapsed < 300000 ? 4000 : 6000; await new Promise(r => setTimeout(r, interval)); pollCount++; let statusData; try { const statusResp = await fetch(statusUrl, { headers: { 'Authorization': `Key ${state.apiKey}` } }); const statusText = await statusResp.text(); if (!statusResp.ok) { failedPolls++; console.warn(`Poll ${pollCount} error (${failedPolls} failed):`, statusResp.status, statusText.slice(0, 200)); // If ALL polls fail, something is fundamentally wrong — bail early if (failedPolls >= 10 && failedPolls === pollCount) { throw new Error(`Status polling failing consistently (${statusResp.status}). URL: ${statusUrl.slice(0, 100)}. Response: ${statusText.slice(0, 200)}`); } continue; } statusData = JSON.parse(statusText); } catch(e) { if (e.message.includes('Status polling failing')) throw e; failedPolls++; console.warn(`Poll ${pollCount} parse error (${failedPolls} failed):`, e.message); if (failedPolls >= 10 && failedPolls === pollCount) { throw new Error(`Status polling failing consistently. ${e.message}`); } continue; } if (statusData.status === 'COMPLETED') { const resultResp = await fetch(responseUrl, { headers: { 'Authorization': `Key ${state.apiKey}` } }); const resultText = await resultResp.text(); try { result = JSON.parse(resultText); } catch(e) { throw new Error('Invalid result JSON: ' + resultText.slice(0, 200)); } break; } else if (statusData.status === 'FAILED') { const errMsg = statusData.error || statusData.detail || JSON.stringify(statusData); throw new Error('Video generation failed: ' + errMsg); } const elapsedSec = Math.round(elapsed / 1000); const statusMsg = statusData.status === 'IN_QUEUE' ? 'Queued' : 'Generating'; const queuePos = statusData.queue_position ? ` (#${statusData.queue_position} in queue)` : ''; const pct = Math.min(90, Math.round(elapsed / (5 * 60 * 10))); // estimate based on ~5min max showProgress('vid', pct, `${statusMsg}... ${elapsedSec}s elapsed${queuePos}`); setStatus(`${statusMsg}... ${elapsedSec}s elapsed${queuePos}`); $('btn-generate').textContent = `${statusMsg} ${elapsedSec}s`; } if (!result) throw new Error(`Generation timed out after 10 minutes (${pollCount} polls, ${failedPolls} failed). The video may still complete on fal.ai — check your dashboard.`); // Step 5: Extract video URL (fal.ai response shapes vary by model) const videoUrl = result.video?.url || result.output?.url || result.data?.video_url || result.video?.[0]?.url || result.videos?.[0]?.url; if (!videoUrl) { console.error('Full fal.ai result:', JSON.stringify(result)); throw new Error('No video URL in response — check console for details'); } await handleVideoResult(videoUrl, estCost); } catch (err) { console.error('Video generation error:', err); setStatus(`Error: ${err.message}`); showError('Video Generation Failed', err.message); } finally { $('btn-generate').disabled = false; $('btn-generate').textContent = 'Generate Video'; hideProgress('vid'); } }); // ── Handle Video Result (shared helper) ── async function handleVideoResult(videoUrl, cost) { toast('Video ready! Loading...', 'success'); try { const videoResp = await fetch(videoUrl); const videoBlob = await videoResp.blob(); state.lastVideoBlobUrl = URL.createObjectURL(videoBlob); $('result-video').src = state.lastVideoBlobUrl; } catch(e) { // CORS blocked — try direct URL state.lastVideoBlobUrl = null; $('result-video').src = videoUrl; } state.lastVideoUrl = videoUrl; $('video-result').classList.remove('hidden'); state.videoCount++; state.sessionCost += cost; $('stat-cost').textContent = `$${state.sessionCost.toFixed(2)}`; addToGallery({ type: 'video', dataUrl: videoUrl, blobUrl: state.lastVideoBlobUrl, name: `Video ${state.videoCount}`, cost }); toast(`Video generated! Cost: $${cost.toFixed(2)}`, 'success'); setStatus(`Video generated! Cost: $${cost.toFixed(2)}`); } // ── Video Result Actions ── function closeVideoResult() { $('video-result').classList.add('hidden'); $('result-video').pause(); } $('btn-close-video').addEventListener('click', closeVideoResult); $('video-result').addEventListener('click', e => { if (e.target === $('video-result')) closeVideoResult(); }); $('btn-download-video').addEventListener('click', () => { const url = state.lastVideoUrl || state.lastVideoBlobUrl || $('result-video').src; if (url) { const a = document.createElement('a'); a.href = url; a.download = `splatwalrus-video-${Date.now()}.mp4`; a.target = '_blank'; a.click(); toast('Downloading video...', 'info', 2000); } }); $('btn-fullscreen-video').addEventListener('click', () => { const video = $('result-video'); if (video.requestFullscreen) video.requestFullscreen(); }); // ── fal.ai CDN Upload Helper ── async function uploadToFalCDN(dataUrl) { // Convert dataUrl to blob const resp = await fetch(dataUrl); const blob = await resp.blob(); const contentType = blob.type || 'image/png'; // Step 1: Initiate upload const initResp = await fetch('https://rest.fal.ai/storage/upload/initiate?storage_type=fal-cdn-v3', { method: 'POST', headers: { 'Authorization': `Key ${state.apiKey}`, 'Content-Type': 'application/json', 'Accept': 'application/json' }, body: JSON.stringify({ file_name: 'splatwalrus-input.png', content_type: contentType }) }); if (!initResp.ok) { const errText = await initResp.text(); throw new Error(`CDN upload init failed (${initResp.status}): ${errText.slice(0, 200)}`); } const initData = await initResp.json(); const { file_url, upload_url } = initData; if (!upload_url || !file_url) { throw new Error('CDN upload returned no URLs: ' + JSON.stringify(initData).slice(0, 200)); } // Step 2: PUT file to presigned URL const putResp = await fetch(upload_url, { method: 'PUT', headers: { 'Content-Type': contentType }, body: blob }); if (!putResp.ok) { console.warn('CDN PUT status:', putResp.status); // Some presigned URLs return 200, others 204 — both are OK } console.log('Uploaded to fal CDN:', file_url); return file_url; } // ── Session Gallery ── const sessionOutputs = []; // { type: 'image'|'video'|'screenshot', dataUrl, blobUrl, name, cost } function addToGallery(item) { sessionOutputs.push(item); renderGallery(); } function renderGallery() { const gallery = $('session-gallery'); gallery.innerHTML = ''; $('stat-total').textContent = sessionOutputs.length; $('gallery-badge').textContent = sessionOutputs.length; $('btn-download-all').disabled = sessionOutputs.length === 0; // Horizontal filmstrip sessionOutputs.forEach((item, i) => { const div = document.createElement('div'); div.className = 'filmstrip-item'; const typeClass = item.type === 'video' ? 'vid' : item.type === 'image' ? 'img' : 'ss'; const typeLabel = item.type === 'video' ? 'VID' : item.type === 'image' ? 'IMG' : 'SS'; const src = item.blobUrl || item.dataUrl; if (item.type === 'video') { div.innerHTML = `${typeLabel}`; } else { div.innerHTML = `${typeLabel}`; } div.addEventListener('click', () => { if (item.type === 'video') { $('result-video').src = src; $('video-result').classList.remove('hidden'); } else { showLightbox(src); } }); gallery.appendChild(div); }); // Auto-scroll to latest if (gallery.lastChild) gallery.lastChild.scrollIntoView({ behavior: 'smooth', inline: 'end' }); } $('btn-clear-gallery').addEventListener('click', () => { if (!confirm('Clear all session outputs?')) return; sessionOutputs.length = 0; renderGallery(); state.sessionCost = 0; $('stat-cost').textContent = '$0.00'; }); async function downloadAllOutputs() { if (sessionOutputs.length === 0) return; toast('Preparing downloads...', 'info'); for (let i = 0; i < sessionOutputs.length; i++) { const item = sessionOutputs[i]; const url = item.blobUrl || item.dataUrl; if (!url) continue; const ext = item.type === 'video' ? 'mp4' : 'png'; const a = document.createElement('a'); a.href = url; a.download = `splatwalrus-${item.type}-${i+1}.${ext}`; a.click(); await new Promise(r => setTimeout(r, 300)); } toast(`Downloaded ${sessionOutputs.length} files`, 'success'); } $('btn-download-all').addEventListener('click', downloadAllOutputs); // ── Full Gallery Page ── function openGalleryPage() { const grid = $('gallery-grid'); grid.innerHTML = ''; $('gp-count').textContent = sessionOutputs.length; $('gp-cost').textContent = `$${state.sessionCost.toFixed(2)}`; sessionOutputs.forEach((item, i) => { const div = document.createElement('div'); div.className = 'gallery-grid-item'; const src = item.blobUrl || item.dataUrl; const typeLabel = item.type === 'video' ? 'VIDEO' : item.type === 'image' ? 'IMAGE' : 'SCREENSHOT'; if (item.type === 'video') { div.innerHTML = `
${typeLabel}${item.name}${item.cost ? '$'+item.cost.toFixed(2) : ''}
`; // Play on hover const vid = div.querySelector('video'); div.addEventListener('mouseenter', () => vid.play()); div.addEventListener('mouseleave', () => { vid.pause(); vid.currentTime = 0; }); } else { div.innerHTML = `
${typeLabel}${item.name}${item.cost ? '$'+item.cost.toFixed(2) : ''}
`; } div.addEventListener('click', () => { if (item.type === 'video') { $('result-video').src = src; $('video-result').classList.remove('hidden'); } else { showLightbox(src); } }); grid.appendChild(div); }); $('gallery-page').classList.remove('hidden'); } $('btn-view-all-gallery').addEventListener('click', openGalleryPage); $('btn-gallery-topbar').addEventListener('click', openGalleryPage); $('btn-gp-close').addEventListener('click', () => $('gallery-page').classList.add('hidden')); $('btn-gp-download-all').addEventListener('click', downloadAllOutputs); // ── Lightbox ── let lightboxCurrentUrl = null; function showLightbox(url) { lightboxCurrentUrl = url; $('lightbox-img').src = url; $('lightbox').classList.remove('hidden'); } $('lightbox').addEventListener('click', e => { if (e.target === $('lightbox')) $('lightbox').classList.add('hidden'); }); $('lightbox-close').addEventListener('click', () => $('lightbox').classList.add('hidden')); $('lightbox-download').addEventListener('click', () => { if (!lightboxCurrentUrl) return; const a = document.createElement('a'); a.href = lightboxCurrentUrl; a.download = `splatwalrus-${Date.now()}.png`; a.click(); }); $('lightbox-video').addEventListener('click', () => { if (!lightboxCurrentUrl) return; const name = `Image ${videoSources.length + 1}`; if (lightboxCurrentUrl.startsWith('blob:')) { fetch(lightboxCurrentUrl).then(r => r.blob()).then(b => { const reader = new FileReader(); reader.onload = () => { addVideoSource('generated', reader.result, name); toast('Added as video source', 'success', 2000); }; reader.readAsDataURL(b); }); } else { addVideoSource('generated', lightboxCurrentUrl, name); toast('Added as video source', 'success', 2000); } $('lightbox').classList.add('hidden'); }); // Clickable previews for lightbox $('gen-img-preview').addEventListener('click', () => { if ($('gen-img-preview').src) showLightbox($('gen-img-preview').src); }); $('img-to-splat-preview').addEventListener('click', () => { if ($('img-to-splat-preview').src) showLightbox($('img-to-splat-preview').src); }); $('img-ref-preview').addEventListener('click', () => { if ($('img-ref-preview').src) showLightbox($('img-ref-preview').src); }); // ── Video Source Management ── // Track all available video sources (screenshots + generated images) const videoSources = []; // { type, dataUrl, name } function addVideoSource(type, dataUrl, name) { videoSources.push({ type, dataUrl, name }); updateGenSourceDropdown(); } let selectedVideoSourceIdx = -1; function updateGenSourceDropdown() { const select = $('gen-source'); const grid = $('gen-source-grid'); select.innerHTML = ''; grid.innerHTML = ''; if (videoSources.length === 0) { select.innerHTML = ''; $('btn-generate').disabled = true; $('gen-source-hint').textContent = '— take a screenshot or generate an image first'; $('gen-source-info').style.display = 'none'; return; } $('gen-source-hint').textContent = `— ${videoSources.length} available (click to select)`; videoSources.forEach((src, i) => { // Hidden select for compatibility const opt = document.createElement('option'); opt.value = i.toString(); select.appendChild(opt); // Visual thumbnail const thumb = document.createElement('div'); thumb.style.cssText = `width:72px; height:48px; border-radius:4px; overflow:hidden; cursor:pointer; border:2px solid ${i === videoSources.length - 1 ? 'var(--accent)' : 'var(--panel-border)'}; position:relative; flex-shrink:0; transition:border-color 0.15s;`; thumb.innerHTML = ``; thumb.title = `${src.type === 'screenshot' ? 'Screenshot' : 'Generated'}: ${src.name}`; thumb.addEventListener('click', () => { selectedVideoSourceIdx = i; select.value = i.toString(); // Update selection border grid.querySelectorAll('div').forEach((t, ti) => { t.style.borderColor = ti === i ? 'var(--accent)' : 'var(--panel-border)'; }); // Show info const img = new Image(); img.onload = () => { const ar = (img.width / img.height).toFixed(2); const ok = ar >= 0.4 && ar <= 2.5; $('gen-source-info').style.display = 'block'; $('gen-source-info').innerHTML = `${img.width}×${img.height} (${ar}) ${ok ? '✓ Kling OK' : '✗ use Crop'}`; }; img.src = src.dataUrl; $('btn-generate').disabled = !state.apiKey; }); grid.appendChild(thumb); }); // Auto-select latest selectedVideoSourceIdx = videoSources.length - 1; select.value = selectedVideoSourceIdx.toString(); $('btn-generate').disabled = !state.apiKey; // Show info for latest const latest = videoSources[videoSources.length - 1]; const img = new Image(); img.onload = () => { const ar = (img.width / img.height).toFixed(2); const ok = ar >= 0.4 && ar <= 2.5; $('gen-source-info').style.display = 'block'; $('gen-source-info').innerHTML = `${img.width}×${img.height} (${ar}) ${ok ? '✓ Kling OK' : '✗ use Crop'}`; }; img.src = latest.dataUrl; // Also update all source grids if (typeof updateDepthSourceGrid === 'function') updateDepthSourceGrid(); if (typeof updateSplatSourceGrid === 'function') updateSplatSourceGrid(); if (typeof updateUpscaleSourceGrid === 'function') updateUpscaleSourceGrid(); if (typeof updateRmbgSourceGrid === 'function') updateRmbgSourceGrid(); } // ── Image Reference ── let imgRefDataUrl = null; const imgRefDrop = $('img-ref-drop'); const imgRefInput = $('img-ref-input'); imgRefDrop.addEventListener('click', () => imgRefInput.click()); imgRefInput.addEventListener('change', e => { if (e.target.files[0]) loadImgRef(e.target.files[0]); }); imgRefDrop.addEventListener('dragover', e => { e.preventDefault(); imgRefDrop.style.borderColor = 'var(--accent)'; }); imgRefDrop.addEventListener('dragleave', () => { imgRefDrop.style.borderColor = 'var(--panel-border)'; }); imgRefDrop.addEventListener('drop', e => { e.preventDefault(); e.stopPropagation(); imgRefDrop.style.borderColor = 'var(--panel-border)'; if (e.dataTransfer?.files?.[0]?.type?.startsWith('image/')) loadImgRef(e.dataTransfer.files[0]); }); function loadImgRef(file) { const reader = new FileReader(); reader.onload = () => { imgRefDataUrl = reader.result; $('img-ref-preview').src = imgRefDataUrl; $('img-ref-preview').style.display = 'block'; imgRefDrop.textContent = file.name || 'Reference loaded'; toast('Reference image loaded', 'success', 2000); }; reader.readAsDataURL(file); } $('btn-ref-from-screenshot').addEventListener('click', () => { if (state.screenshots.length === 0) { toast('Take a screenshot first (Shift+S)', 'warning'); return; } const latest = state.screenshots[state.screenshots.length - 1]; imgRefDataUrl = latest.dataURL; $('img-ref-preview').src = imgRefDataUrl; $('img-ref-preview').style.display = 'block'; imgRefDrop.textContent = 'Using latest screenshot'; toast('Screenshot loaded as reference', 'success', 2000); }); $('btn-ref-clear').addEventListener('click', () => { imgRefDataUrl = null; $('img-ref-preview').style.display = 'none'; imgRefDrop.textContent = 'Drop reference image, click to browse, or use a screenshot'; }); // ── Image Generation ── let lastGenImageUrl = null; function getImageSize(aspect) { const sizes = { '16:9': { width: 1344, height: 768 }, '21:9': { width: 1536, height: 640 }, '1:1': { width: 1024, height: 1024 }, '9:16': { width: 768, height: 1344 }, '4:3': { width: 1152, height: 896 } }; return sizes[aspect] || sizes['16:9']; } async function genImageOpenAI(prompt, aspect) { if (!state.openaiKey) { toast('OpenAI API key required. Add it in Settings below.', 'warning'); openKeyModal('openai', 'OpenAI API Key', 'sk-xxxxxxxx'); return null; } const size = aspect === '16:9' ? '1536x1024' : aspect === '1:1' ? '1024x1024' : aspect === '9:16' ? '1024x1536' : aspect === '21:9' ? '1536x1024' : '1536x1024'; const quality = $('img-quality').value || 'medium'; const background = $('img-background').value || 'auto'; const n = parseInt($('img-n').value) || 1; const body = { model: 'gpt-image-1', prompt, size, n, quality, background }; if (imgRefDataUrl) body.prompt = `Based on this reference image: ${prompt}`; const resp = await fetch('https://api.openai.com/v1/images/generations', { method: 'POST', headers: { 'Authorization': `Bearer ${state.openaiKey}`, 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); if (!resp.ok) throw new Error(`OpenAI error ${resp.status}: ${await resp.text()}`); const result = await resp.json(); // Return first image (handle multiple later if n > 1) const b64 = result.data?.[0]?.b64_json; if (b64) return `data:image/png;base64,${b64}`; return result.data?.[0]?.url; } async function genImageGemini(prompt, aspect) { if (!state.googleKey) { toast('Google AI API key required. Add it in Settings below.', 'warning'); openKeyModal('google', 'Google AI API Key', 'AIzaxxxxxxxx'); return null; } const genConfig = { responseModalities: ['TEXT', 'IMAGE'] }; // Gemini supports aspect ratio const geminiAspects = { '16:9': '16:9', '9:16': '9:16', '1:1': '1:1', '4:3': '4:3', '21:9': '21:9' }; if (geminiAspects[aspect]) { genConfig.responseFormat = { image: { aspectRatio: geminiAspects[aspect] } }; } const contents = [{ parts: [{ text: prompt }] }]; const resp = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash-exp:generateContent?key=${state.googleKey}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ contents, generationConfig: genConfig }) }); if (!resp.ok) throw new Error(`Gemini error ${resp.status}: ${await resp.text()}`); const result = await resp.json(); const parts = result.candidates?.[0]?.content?.parts || []; const imgPart = parts.find(p => p.inlineData); if (imgPart) return `data:${imgPart.inlineData.mimeType};base64,${imgPart.inlineData.data}`; throw new Error('No image in Gemini response'); } async function genImageFal(prompt, aspect, modelKey) { if (!state.apiKey) { toast('fal.ai API key required. Add it in Settings below.', 'warning'); openKeyModal('fal', 'fal.ai API Key', 'fal-xxxxxxxx'); return null; } const seed = $('img-seed').value ? parseInt($('img-seed').value) : undefined; const numImages = parseInt($('img-n').value) || 1; // If reference image provided, use img2img endpoint if (imgRefDataUrl && modelKey.startsWith('flux')) { toast('Uploading reference image...', 'info'); const refUrl = await uploadToFalCDN(imgRefDataUrl); const body = { prompt, image_url: refUrl, image_size: getImageSize(aspect), num_images: numImages, num_inference_steps: 28, guidance_scale: 3.5 }; if (seed !== undefined) body.seed = seed; const resp = await fetch('https://fal.run/fal-ai/flux-pro/v1.1/redux', { method: 'POST', headers: { 'Authorization': `Key ${state.apiKey}`, 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); if (!resp.ok) { throw new Error(`Flux Redux error ${resp.status}: ${(await resp.text()).slice(0, 200)}`); } const result = await resp.json(); return result.images?.[0]?.url || result.image?.url; } // Recraft with reference image → use image-to-image if (imgRefDataUrl && modelKey === 'recraft-v3') { toast('Uploading reference image...', 'info'); const refUrl = await uploadToFalCDN(imgRefDataUrl); const body = { prompt, image_url: refUrl, image_size: getImageSize(aspect), style: $('img-style').value || 'realistic_image', strength: 0.5 }; const resp = await fetch('https://fal.run/fal-ai/recraft/v3/image-to-image', { method: 'POST', headers: { 'Authorization': `Key ${state.apiKey}`, 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); if (!resp.ok) { throw new Error(`Recraft img2img error ${resp.status}: ${(await resp.text()).slice(0, 200)}`); } const result = await resp.json(); return result.images?.[0]?.url || result.image?.url; } // Standard text-to-image const endpoints = { 'flux-pro': 'fal-ai/flux-pro/v1.1', 'flux-schnell': 'fal-ai/flux/schnell', 'recraft-v3': 'fal-ai/recraft/v3/text-to-image', 'sana-sprint': 'fal-ai/sana/sprint', 'sana-4.8b': 'fal-ai/sana/v1.5/4.8b', 'ideogram-v3': 'fal-ai/ideogram/v3', 'hidream': 'fal-ai/hidream-i1-full' }; const body = { prompt, image_size: getImageSize(aspect), num_images: numImages }; // Model-specific params if (modelKey === 'recraft-v3') { body.style = $('img-style').value || 'realistic_image'; delete body.num_images; } if (modelKey === 'ideogram-v3') { // Ideogram uses aspect_ratio string, not image_size object body.aspect_ratio = aspect; delete body.image_size; delete body.num_images; } if (modelKey === 'hidream') { delete body.num_images; // HiDream doesn't support num_images } if (modelKey.startsWith('sana')) { delete body.num_images; // SANA doesn't support num_images } if (seed !== undefined) body.seed = seed; const resp = await fetch(`https://fal.run/${endpoints[modelKey]}`, { method: 'POST', headers: { 'Authorization': `Key ${state.apiKey}`, 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); if (!resp.ok) { throw new Error(`fal.ai error ${resp.status}: ${(await resp.text()).slice(0, 200)}`); } const result = await resp.json(); return result.images?.[0]?.url || result.image?.url; } $('btn-gen-img').addEventListener('click', async () => { const prompt = $('img-prompt').value.trim(); if (!prompt) { setStatus('Enter a prompt first'); return; } const modelKey = $('img-model').value; const provider = $('img-model').selectedOptions[0]?.dataset?.provider; const aspect = $('img-aspect').value; $('btn-gen-img').disabled = true; $('btn-gen-img').textContent = 'Generating...'; showProgress('img', -1, 'Generating image...'); setStatus('Generating image...'); try { let imgUrl; if (provider === 'openai') imgUrl = await genImageOpenAI(prompt, aspect); else if (provider === 'google') imgUrl = await genImageGemini(prompt, aspect); else imgUrl = await genImageFal(prompt, aspect, modelKey); if (!imgUrl) return; // Key modal was shown lastGenImageUrl = imgUrl; // If it's a remote URL, fetch as blob to display (avoids CORS broken image) if (imgUrl.startsWith('http')) { try { const imgResp = await fetch(imgUrl); const imgBlob = await imgResp.blob(); const blobUrl = URL.createObjectURL(imgBlob); $('gen-img-preview').src = blobUrl; state.lastGenImageBlobUrl = blobUrl; } catch(e) { // CORS blocked — try with img tag directly (may show broken) $('gen-img-preview').src = imgUrl; } } else { // base64 data URL — display directly $('gen-img-preview').src = imgUrl; } $('gen-img-result').style.display = 'block'; const imgName = `Image ${sessionOutputs.filter(o=>o.type==='image').length + 1}`; addToGallery({ type: 'image', dataUrl: imgUrl, blobUrl: state.lastGenImageBlobUrl, name: imgName }); // Auto-add to video/depth source pool (use data URL or blob for reliability) const sourceDataUrl = state.lastGenImageBlobUrl || imgUrl; if (sourceDataUrl.startsWith('blob:')) { // Convert blob URL to data URL for persistence fetch(sourceDataUrl).then(r => r.blob()).then(b => { const reader = new FileReader(); reader.onload = () => { addVideoSource('generated', reader.result, imgName); }; reader.readAsDataURL(b); }); } else { addVideoSource('generated', sourceDataUrl, imgName); } toast('Image generated!', 'success'); setStatus('Image generated!'); } catch (err) { console.error('Image gen error:', err); setStatus(`Error: ${err.message}`); showError('Image Generation Failed', err.message); } finally { $('btn-gen-img').disabled = false; $('btn-gen-img').textContent = 'Generate Image'; hideProgress('img'); } }); $('btn-img-download').addEventListener('click', () => { const url = state.lastGenImageBlobUrl || lastGenImageUrl; if (!url) return; const a = document.createElement('a'); a.href = url; a.download = `splatwalrus-render-${Date.now()}.png`; a.click(); toast('Downloading image...', 'info', 2000); }); $('btn-img-to-video').addEventListener('click', () => { const url = state.lastGenImageBlobUrl || lastGenImageUrl; if (!url) return; // Convert to data URL if it's a blob URL if (url.startsWith('blob:')) { fetch(url).then(r => r.blob()).then(b => { const reader = new FileReader(); reader.onload = () => { const imgName = `Generated Image ${videoSources.filter(s=>s.type==='generated').length + 1}`; addVideoSource('generated', reader.result, imgName); toast('Image added as video source — scroll to Generate Video', 'success'); }; reader.readAsDataURL(b); }); } else { const imgName = `Generated Image ${videoSources.filter(s=>s.type==='generated').length + 1}`; addVideoSource('generated', url, imgName); toast('Image added as video source — scroll to Generate Video', 'success'); } }); $('btn-img-to-depth').addEventListener('click', () => { // Scroll to depth plate section and auto-select latest source const depthSection = document.querySelector('.sec-depth'); if (depthSection) depthSection.scrollIntoView({ behavior: 'smooth', block: 'center' }); // The image was auto-added to videoSources, so depth grid should have it toast('Image ready for depth estimation — scroll down to Depth Plate and hit Generate', 'success'); }); $('btn-img-to-3d').addEventListener('click', async () => { const url = state.lastGenImageBlobUrl || lastGenImageUrl; if (!url) return; $('img-to-splat-preview').src = url; $('img-to-splat-preview').style.display = 'block'; try { const resp = await fetch(url); pendingImageBlob = await resp.blob(); $('btn-gen-splat').disabled = !state.apiKey; toast('Image ready for 3D generation — scroll down and click Generate 3D Splat', 'success'); } catch(e) { toast('Could not load image for 3D conversion', 'error'); } }); // ── Depth Plate ── let selectedDepthIdx = -1; function updateDepthSourceGrid() { const grid = $('depth-source-grid'); grid.innerHTML = ''; if (videoSources.length === 0) { $('btn-gen-depth').disabled = true; return; } videoSources.forEach((src, i) => { const thumb = document.createElement('div'); thumb.style.cssText = `width:72px; height:48px; border-radius:4px; overflow:hidden; cursor:pointer; border:2px solid ${i === videoSources.length - 1 ? 'var(--accent)' : 'var(--panel-border)'}; flex-shrink:0; transition:border-color 0.15s;`; thumb.innerHTML = ``; thumb.addEventListener('click', () => { selectedDepthIdx = i; grid.querySelectorAll('div').forEach((t, ti) => { t.style.borderColor = ti === i ? 'var(--accent)' : 'var(--panel-border)'; }); $('btn-gen-depth').disabled = !state.apiKey; }); grid.appendChild(thumb); }); selectedDepthIdx = videoSources.length - 1; $('btn-gen-depth').disabled = !state.apiKey; } // Show/hide Marigold options $('depth-model').addEventListener('change', () => { $('depth-marigold-opts').style.display = $('depth-model').value === 'marigold' ? 'block' : 'none'; }); $('depth-steps')?.addEventListener('input', () => { $('depth-steps-val').textContent = $('depth-steps').value; }); $('depth-ensemble')?.addEventListener('input', () => { $('depth-ensemble-val').textContent = $('depth-ensemble').value; }); // Lightbox for depth results $('depth-original')?.addEventListener('click', () => { if ($('depth-original').src) showLightbox($('depth-original').src); }); $('depth-map-preview')?.addEventListener('click', () => { if ($('depth-map-preview').src) showLightbox($('depth-map-preview').src); }); $('btn-gen-depth').addEventListener('click', async () => { if (!state.apiKey) { toast('fal.ai API key required', 'warning'); openKeyModal('fal', 'fal.ai API Key', 'fal-xxxxxxxx'); return; } const src = videoSources[selectedDepthIdx]; if (!src) { toast('Select a source image first', 'warning'); return; } const model = $('depth-model').value; $('btn-gen-depth').disabled = true; $('btn-gen-depth').textContent = 'Generating...'; showProgress('depth', -1, 'Estimating depth...'); try { toast('Uploading image...', 'info'); const imageUrl = await uploadToFalCDN(src.dataUrl); // Build endpoint + body per model let endpoint, requestBody; if (model === 'depth-anything') { endpoint = 'fal-ai/image-preprocessors/depth-anything/v2'; requestBody = { image_url: imageUrl }; } else if (model === 'marigold') { endpoint = 'fal-ai/imageutils/marigold-depth'; requestBody = { image_url: imageUrl, num_inference_steps: parseInt($('depth-steps').value) || 10, ensemble_size: parseInt($('depth-ensemble').value) || 10 }; } else { endpoint = 'fal-ai/imageutils/depth'; requestBody = { image_url: imageUrl }; } toast('Estimating depth...', 'info'); const resp = await fetch(`https://fal.run/${endpoint}`, { method: 'POST', headers: { 'Authorization': `Key ${state.apiKey}`, 'Content-Type': 'application/json' }, body: JSON.stringify(requestBody) }); if (!resp.ok) { const errText = await resp.text(); throw new Error(`Depth error ${resp.status}: ${errText.slice(0, 200)}`); } const result = await resp.json(); const depthUrl = result.image?.url; if (!depthUrl) throw new Error('No depth map in response'); // Show result $('depth-original').src = src.dataUrl; try { const dResp = await fetch(depthUrl); const dBlob = await dResp.blob(); state.lastDepthBlobUrl = URL.createObjectURL(dBlob); $('depth-map-preview').src = state.lastDepthBlobUrl; } catch(e) { $('depth-map-preview').src = depthUrl; state.lastDepthBlobUrl = depthUrl; } state.lastDepthUrl = depthUrl; state.lastDepthOriginalDataUrl = src.dataUrl; // save for viewer loading $('depth-result').style.display = 'block'; addToGallery({ type: 'image', dataUrl: depthUrl, blobUrl: state.lastDepthBlobUrl, name: `Depth Map (${model})` }); toast('Depth map generated! Click "Load as 3D Parallax Plate" to view with depth.', 'success', 5000); } catch(err) { console.error('Depth error:', err); showError('Depth Estimation Failed', err.message); } finally { $('btn-gen-depth').disabled = false; $('btn-gen-depth').textContent = 'Generate Depth Map'; hideProgress('depth'); } }); $('btn-depth-download')?.addEventListener('click', () => { const url = state.lastDepthBlobUrl || state.lastDepthUrl; if (!url) return; const a = document.createElement('a'); a.href = url; a.download = `splatwalrus-depth-${Date.now()}.png`; a.click(); }); $('btn-depth-load-viewer')?.addEventListener('click', async () => { if (!state.lastDepthBlobUrl) { toast('Generate a depth map first', 'warning'); return; } const originalSrc = state.lastDepthOriginalDataUrl || (videoSources[selectedDepthIdx] && videoSources[selectedDepthIdx].dataUrl); if (!originalSrc) { toast('Original image not found — regenerate the depth map', 'warning'); return; } showLoading('Loading depth plate into viewer...'); try { // Init scene if not already if (!state.renderer) initThreeScene(); // Remove existing splat viewer if (state.viewer) { state.threeScene.remove(state.viewer); state.viewer = null; } // Remove previous depth plate if (state.depthPlate) { state.threeScene.remove(state.depthPlate); state.depthPlate = null; } // Helper to load image as texture (handles data URLs, blob URLs, and remote URLs) async function loadTex(src, label) { console.log(`Loading ${label} from:`, src.slice(0, 80)); // If remote URL, fetch as blob first to avoid CORS texture issues let imgSrc = src; if (src.startsWith('http')) { try { const resp = await fetch(src); const blob = await resp.blob(); imgSrc = URL.createObjectURL(blob); } catch(e) { console.warn(`${label} fetch failed, using direct URL`); } } return new Promise((resolve, reject) => { const img = new Image(); img.onload = () => { console.log(`${label} loaded:`, img.width, 'x', img.height); const tex = new THREE.Texture(img); tex.needsUpdate = true; tex.colorSpace = THREE.SRGBColorSpace; resolve(tex); }; img.onerror = () => reject(new Error(`Failed to load ${label}`)); img.src = imgSrc; }); } const colorTex = await loadTex(originalSrc, 'color texture'); const depthTex = await loadTex(state.lastDepthBlobUrl, 'depth texture'); // Create displaced plane const aspect = colorTex.image.width / colorTex.image.height; const planeW = 10; const planeH = planeW / aspect; const segments = 256; const geo = new THREE.PlaneGeometry(planeW, planeH, segments, segments); const mat = new THREE.MeshStandardMaterial({ map: colorTex, displacementMap: depthTex, displacementScale: 1.5, side: THREE.DoubleSide }); const mesh = new THREE.Mesh(geo, mat); state.depthPlate = mesh; // Add lighting if (!state.depthLight) { state.depthLight = new THREE.DirectionalLight(0xffffff, 1.5); state.depthLight.position.set(0, 5, 5); state.threeScene.add(state.depthLight); state.threeScene.add(new THREE.AmbientLight(0xffffff, 0.5)); } state.threeScene.add(mesh); // Position camera state.camera.position.set(0, 0, 8); pitch.value = 0; yaw.value = 0; state.camera.rotation.set(0, 0, 0); state.sceneLoaded = true; state.sceneName = 'Depth Plate'; // Show UI dropZone.classList.add('hidden'); topBar.classList.remove('hidden'); sidePanel.classList.remove('hidden'); statusBar.classList.remove('hidden'); $('scene-name').textContent = 'Depth Plate'; updateFrameOverlay(); hideLoading(); toast('Depth plate loaded! Orbit around to see the parallax effect.', 'success', 5000); setStatus('Depth plate loaded — fly around to see parallax'); } catch(err) { console.error('Depth plate load error:', err); hideLoading(); showError('Failed to Load Depth Plate', err.message); } }); // ── Upscale Tool ── let selectedUpscaleIdx = -1; function updateUpscaleSourceGrid() { const grid = $('upscale-source-grid'); grid.innerHTML = ''; if (videoSources.length === 0) { $('btn-upscale').disabled = true; return; } videoSources.forEach((src, i) => { const thumb = document.createElement('div'); thumb.style.cssText = `width:72px; height:48px; border-radius:4px; overflow:hidden; cursor:pointer; border:2px solid ${i === videoSources.length - 1 ? 'var(--accent)' : 'var(--panel-border)'}; flex-shrink:0;`; thumb.innerHTML = ``; thumb.addEventListener('click', () => { selectedUpscaleIdx = i; grid.querySelectorAll('div').forEach((t, ti) => { t.style.borderColor = ti === i ? 'var(--accent)' : 'var(--panel-border)'; }); $('btn-upscale').disabled = !state.apiKey; }); grid.appendChild(thumb); }); selectedUpscaleIdx = videoSources.length - 1; $('btn-upscale').disabled = !state.apiKey; } $('btn-upscale').addEventListener('click', async () => { if (!state.apiKey) { openKeyModal('fal', 'fal.ai API Key', 'fal-xxxxxxxx'); return; } const src = videoSources[selectedUpscaleIdx]; if (!src) { toast('Select a source image', 'warning'); return; } $('btn-upscale').disabled = true; $('btn-upscale').textContent = 'Upscaling...'; showProgress('upscale', -1, 'Upscaling image...'); try { const imageUrl = await uploadToFalCDN(src.dataUrl); const model = $('upscale-model').value; const endpoint = model === 'creative-upscaler' ? 'fal-ai/creative-upscaler' : 'fal-ai/aura-sr'; const body = { image_url: imageUrl }; if (model === 'creative-upscaler') { body.scale = parseInt($('upscale-scale').value); } const resp = await fetch(`https://fal.run/${endpoint}`, { method: 'POST', headers: { 'Authorization': `Key ${state.apiKey}`, 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); if (!resp.ok) throw new Error(`Upscale error ${resp.status}: ${(await resp.text()).slice(0, 200)}`); const result = await resp.json(); const outUrl = result.image?.url; if (!outUrl) throw new Error('No image in response'); try { const imgResp = await fetch(outUrl); const blob = await imgResp.blob(); state.lastUpscaleBlobUrl = URL.createObjectURL(blob); $('upscale-preview').src = state.lastUpscaleBlobUrl; } catch(e) { $('upscale-preview').src = outUrl; state.lastUpscaleBlobUrl = outUrl; } state.lastUpscaleUrl = outUrl; $('upscale-result').style.display = 'block'; addToGallery({ type: 'image', dataUrl: outUrl, blobUrl: state.lastUpscaleBlobUrl, name: `Upscaled (${model})` }); toast('Image upscaled!', 'success'); } catch(err) { showError('Upscale Failed', err.message); } finally { $('btn-upscale').disabled = false; $('btn-upscale').textContent = 'Upscale'; hideProgress('upscale'); } }); $('btn-upscale-download')?.addEventListener('click', () => { const url = state.lastUpscaleBlobUrl || state.lastUpscaleUrl; if (url) { const a = document.createElement('a'); a.href = url; a.download = `splatwalrus-upscaled-${Date.now()}.png`; a.click(); } }); $('btn-upscale-to-video')?.addEventListener('click', () => { const url = state.lastUpscaleBlobUrl || state.lastUpscaleUrl; if (!url) return; if (url.startsWith('blob:')) { fetch(url).then(r => r.blob()).then(b => { const reader = new FileReader(); reader.onload = () => { addVideoSource('generated', reader.result, 'Upscaled'); toast('Added as video source', 'success', 2000); }; reader.readAsDataURL(b); }); } else { addVideoSource('generated', url, 'Upscaled'); toast('Added as video source', 'success', 2000); } }); $('upscale-preview')?.addEventListener('click', () => { if ($('upscale-preview').src) showLightbox($('upscale-preview').src); }); // ── Background Removal Tool ── let selectedRmbgIdx = -1; function updateRmbgSourceGrid() { const grid = $('rmbg-source-grid'); grid.innerHTML = ''; if (videoSources.length === 0) { $('btn-rmbg').disabled = true; return; } videoSources.forEach((src, i) => { const thumb = document.createElement('div'); thumb.style.cssText = `width:72px; height:48px; border-radius:4px; overflow:hidden; cursor:pointer; border:2px solid ${i === videoSources.length - 1 ? 'var(--accent)' : 'var(--panel-border)'}; flex-shrink:0;`; thumb.innerHTML = ``; thumb.addEventListener('click', () => { selectedRmbgIdx = i; grid.querySelectorAll('div').forEach((t, ti) => { t.style.borderColor = ti === i ? 'var(--accent)' : 'var(--panel-border)'; }); $('btn-rmbg').disabled = !state.apiKey; }); grid.appendChild(thumb); }); selectedRmbgIdx = videoSources.length - 1; $('btn-rmbg').disabled = !state.apiKey; } $('btn-rmbg').addEventListener('click', async () => { if (!state.apiKey) { openKeyModal('fal', 'fal.ai API Key', 'fal-xxxxxxxx'); return; } const src = videoSources[selectedRmbgIdx]; if (!src) { toast('Select a source image', 'warning'); return; } $('btn-rmbg').disabled = true; $('btn-rmbg').textContent = 'Removing...'; showProgress('rmbg', -1, 'Removing background...'); try { const imageUrl = await uploadToFalCDN(src.dataUrl); const resp = await fetch('https://fal.run/fal-ai/birefnet/v2', { method: 'POST', headers: { 'Authorization': `Key ${state.apiKey}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ image_url: imageUrl }) }); if (!resp.ok) throw new Error(`RMBG error ${resp.status}: ${(await resp.text()).slice(0, 200)}`); const result = await resp.json(); const outUrl = result.image?.url; if (!outUrl) throw new Error('No image in response'); try { const imgResp = await fetch(outUrl); const blob = await imgResp.blob(); state.lastRmbgBlobUrl = URL.createObjectURL(blob); $('rmbg-preview').src = state.lastRmbgBlobUrl; } catch(e) { $('rmbg-preview').src = outUrl; state.lastRmbgBlobUrl = outUrl; } state.lastRmbgUrl = outUrl; $('rmbg-result').style.display = 'block'; addToGallery({ type: 'image', dataUrl: outUrl, blobUrl: state.lastRmbgBlobUrl, name: 'BG Removed' }); toast('Background removed!', 'success'); } catch(err) { showError('Background Removal Failed', err.message); } finally { $('btn-rmbg').disabled = false; $('btn-rmbg').textContent = 'Remove Background'; hideProgress('rmbg'); } }); $('btn-rmbg-download')?.addEventListener('click', () => { const url = state.lastRmbgBlobUrl || state.lastRmbgUrl; if (url) { const a = document.createElement('a'); a.href = url; a.download = `splatwalrus-nobg-${Date.now()}.png`; a.click(); } }); $('rmbg-preview')?.addEventListener('click', () => { if ($('rmbg-preview').src) showLightbox($('rmbg-preview').src); }); // ── Keyboard Shortcuts ── document.addEventListener('keydown', e => { if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.tagName === 'SELECT') return; if (e.key === 'S' && e.shiftKey) { e.preventDefault(); takeScreenshot(); } // Shift+S for screenshot (S alone is WASD) if (e.key === 'f' || e.key === 'F') { e.preventDefault(); if (document.fullscreenElement) document.exitFullscreen(); else document.documentElement.requestFullscreen(); } if (e.key === 'Escape') { $('video-result').classList.add('hidden'); $('api-modal').classList.add('hidden'); $('error-overlay').classList.add('hidden'); $('gallery-page').classList.add('hidden'); $('lightbox').classList.add('hidden'); $('controls-help').classList.add('hidden'); } if (e.key === 'Tab') { e.preventDefault(); sidePanel.classList.toggle('hidden'); } }); // ── Image to Splat ── const imgToSplatDrop = $('img-to-splat-drop'); const imgToSplatInput = $('img-to-splat-input'); const imgToSplatPreview = $('img-to-splat-preview'); let pendingImageBlob = null; function setupImgToSplat(triggerEl) { if (triggerEl) triggerEl.addEventListener('click', () => { if (!state.apiKey) { $('api-modal').classList.remove('hidden'); return; } imgToSplatInput.click(); }); } setupImgToSplat(imgToSplatDrop); setupImgToSplat($('btn-img-to-splat-landing')); imgToSplatInput.addEventListener('change', e => { const file = e.target.files[0]; if (!file) return; pendingImageBlob = file; const url = URL.createObjectURL(file); imgToSplatPreview.src = url; imgToSplatPreview.style.display = 'block'; $('btn-gen-splat').disabled = !state.apiKey; }); imgToSplatDrop.addEventListener('dragover', e => { e.preventDefault(); imgToSplatDrop.style.borderColor = 'var(--accent)'; }); imgToSplatDrop.addEventListener('dragleave', () => { imgToSplatDrop.style.borderColor = 'var(--panel-border)'; }); imgToSplatDrop.addEventListener('drop', e => { e.preventDefault(); e.stopPropagation(); imgToSplatDrop.style.borderColor = 'var(--panel-border)'; const file = e.dataTransfer?.files?.[0]; if (file && file.type.startsWith('image/')) { pendingImageBlob = file; imgToSplatPreview.src = URL.createObjectURL(file); imgToSplatPreview.style.display = 'block'; $('btn-gen-splat').disabled = !state.apiKey; } }); // ── Splat Source Grid ── function updateSplatSourceGrid() { const grid = $('splat-source-grid'); grid.innerHTML = ''; if (videoSources.length === 0) return; videoSources.forEach((src, i) => { const thumb = document.createElement('div'); thumb.style.cssText = `width:72px; height:48px; border-radius:4px; overflow:hidden; cursor:pointer; border:2px solid var(--panel-border); flex-shrink:0; transition:border-color 0.15s;`; thumb.innerHTML = ``; thumb.addEventListener('click', async () => { // Load this image as the pending splat source grid.querySelectorAll('div').forEach((t, ti) => { t.style.borderColor = ti === i ? 'var(--accent)' : 'var(--panel-border)'; }); try { const resp = await fetch(src.dataUrl); pendingImageBlob = await resp.blob(); $('img-to-splat-preview').src = src.dataUrl; $('img-to-splat-preview').style.display = 'block'; $('btn-gen-splat').disabled = !state.apiKey; toast('Image selected for 3D generation', 'success', 2000); } catch(e) { toast('Could not load image', 'error'); } }); grid.appendChild(thumb); }); } function download3DResult(url, format) { state.last3DModelUrl = url; state.last3DFormat = format || 'glb'; setStatus('3D model generated!'); showProgress('splat', 100, '3D model ready!'); toast('3D model generated! Preview below.', 'success', 5000); // Show 3D preview if (format === 'glb' || !format) { show3DPreview(url); } $('splat-3d-result').style.display = 'block'; } // ── Inline 3D Model Preview ── let previewRenderer = null, previewScene = null, previewCamera = null, previewControls = null, previewAnimId = null; async function show3DPreview(url) { const container = $('splat-preview-container'); // Clean up previous if (previewAnimId) cancelAnimationFrame(previewAnimId); if (previewRenderer) previewRenderer.dispose(); if (previewControls) previewControls.dispose(); // Remove old canvas const oldCanvas = container.querySelector('canvas'); if (oldCanvas) oldCanvas.remove(); const w = container.clientWidth || 280; const h = Math.round(w * 0.75); previewRenderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }); previewRenderer.setSize(w, h); previewRenderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); previewRenderer.toneMapping = THREE.ACESFilmicToneMapping; container.insertBefore(previewRenderer.domElement, container.firstChild); previewScene = new THREE.Scene(); previewScene.background = new THREE.Color(0x1a1a1a); previewCamera = new THREE.PerspectiveCamera(45, w / h, 0.01, 100); previewCamera.position.set(0, 1, 3); // Lighting previewScene.add(new THREE.AmbientLight(0xffffff, 0.6)); const dirLight = new THREE.DirectionalLight(0xffffff, 1.2); dirLight.position.set(2, 4, 3); previewScene.add(dirLight); // Orbit controls for preview previewControls = new OrbitControls(previewCamera, previewRenderer.domElement); previewControls.enableDamping = true; previewControls.dampingFactor = 0.1; previewControls.autoRotate = true; previewControls.autoRotateSpeed = 2; // Load GLB try { const resp = await fetch(url); const blob = await resp.blob(); const blobUrl = URL.createObjectURL(blob); const loader = new GLTFLoader(); loader.load(blobUrl, (gltf) => { const model = gltf.scene; // Auto-fit to view const box = new THREE.Box3().setFromObject(model); const center = box.getCenter(new THREE.Vector3()); const size = box.getSize(new THREE.Vector3()).length(); model.position.sub(center); previewCamera.position.set(0, size * 0.5, size * 1.5); previewControls.target.set(0, 0, 0); previewControls.update(); previewScene.add(model); }); } catch(e) { console.warn('3D preview load error:', e); } // Animate function animatePreview() { previewAnimId = requestAnimationFrame(animatePreview); previewControls.update(); previewRenderer.render(previewScene, previewCamera); } animatePreview(); } // Download/load buttons for 3D result $('btn-splat-download-result')?.addEventListener('click', () => { if (!state.last3DModelUrl) return; const a = document.createElement('a'); a.href = state.last3DModelUrl; a.download = `splatwalrus-3d-model.${state.last3DFormat || 'glb'}`; a.click(); toast('Downloading 3D model...', 'info', 2000); }); $('btn-splat-load-scene')?.addEventListener('click', () => { toast('Load into main viewport coming soon — for now, download and reload as .ply', 'info'); }); // Expand preview to lightbox $('btn-splat-preview-expand')?.addEventListener('click', () => { // Open a full-screen 3D preview overlay if (!state.last3DModelUrl) return; const overlay = document.createElement('div'); overlay.style.cssText = 'position:fixed;inset:0;z-index:300;background:rgba(0,0,0,0.9);display:flex;align-items:center;justify-content:center;flex-direction:column;cursor:pointer;'; const bigContainer = document.createElement('div'); bigContainer.style.cssText = 'width:80vw;height:70vh;border-radius:12px;overflow:hidden;'; const closeBtn = document.createElement('button'); closeBtn.className = 'btn'; closeBtn.textContent = 'Close (Esc)'; closeBtn.style.marginTop = '16px'; overlay.appendChild(bigContainer); overlay.appendChild(closeBtn); document.body.appendChild(overlay); // Set up big renderer const bw = bigContainer.clientWidth, bh = bigContainer.clientHeight; const bigRenderer = new THREE.WebGLRenderer({ antialias: true }); bigRenderer.setSize(bw, bh); bigRenderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); bigRenderer.toneMapping = THREE.ACESFilmicToneMapping; bigContainer.appendChild(bigRenderer.domElement); const bigScene = new THREE.Scene(); bigScene.background = new THREE.Color(0x1a1a1a); const bigCam = new THREE.PerspectiveCamera(45, bw / bh, 0.01, 100); bigCam.position.set(0, 1, 3); bigScene.add(new THREE.AmbientLight(0xffffff, 0.6)); const dl = new THREE.DirectionalLight(0xffffff, 1.2); dl.position.set(2, 4, 3); bigScene.add(dl); const bigControls = new OrbitControls(bigCam, bigRenderer.domElement); bigControls.enableDamping = true; bigControls.autoRotate = true; // Load model fetch(state.last3DModelUrl).then(r => r.blob()).then(b => { const bUrl = URL.createObjectURL(b); new GLTFLoader().load(bUrl, (gltf) => { const m = gltf.scene; const box = new THREE.Box3().setFromObject(m); const center = box.getCenter(new THREE.Vector3()); const size = box.getSize(new THREE.Vector3()).length(); m.position.sub(center); bigCam.position.set(0, size * 0.5, size * 1.5); bigControls.update(); bigScene.add(m); }); }); let bigAnimId; function animBig() { bigAnimId = requestAnimationFrame(animBig); bigControls.update(); bigRenderer.render(bigScene, bigCam); } animBig(); function closeBig() { cancelAnimationFrame(bigAnimId); bigRenderer.dispose(); bigControls.dispose(); overlay.remove(); } closeBtn.addEventListener('click', closeBig); overlay.addEventListener('click', e => { if (e.target === overlay) closeBig(); }); document.addEventListener('keydown', function escHandler(e) { if (e.key === 'Escape') { closeBig(); document.removeEventListener('keydown', escHandler); } }); }); $('btn-gen-splat').addEventListener('click', async () => { if (!state.apiKey || !pendingImageBlob) return; const splatModel = $('splat-gen-model').value; $('btn-gen-splat').disabled = true; $('btn-gen-splat').textContent = 'Generating 3D...'; showProgress('splat', 5, 'Uploading image...'); // Non-blocking — no showLoading() so viewport stays usable try { // Upload image to fal CDN toast('Uploading image...', 'info'); const dataUrl = await new Promise(resolve => { const reader = new FileReader(); reader.onload = () => resolve(reader.result); reader.readAsDataURL(pendingImageBlob); }); const imageUrl = await uploadToFalCDN(dataUrl); // Build model-specific request let endpoint, requestBody; const outputFormat = $('splat-format').value || 'glb'; const seed = $('splat-seed').value ? parseInt($('splat-seed').value) : undefined; if (splatModel === 'triposr') { endpoint = 'fal-ai/triposr'; requestBody = { image_url: imageUrl, output_format: outputFormat, do_remove_background: $('splat-removebg').checked, foreground_ratio: 0.9, mc_resolution: parseInt($('splat-resolution').value) || 256 }; } else if (splatModel === 'hunyuan3d') { endpoint = 'fal-ai/hunyuan3d/v2'; requestBody = { input_image_url: imageUrl, octree_resolution: parseInt($('splat-resolution').value) || 256, textured_mesh: $('splat-textured').checked, num_inference_steps: 50 }; if (seed !== undefined) requestBody.seed = seed; } else if (splatModel === 'rodin') { endpoint = 'fal-ai/hyper3d/rodin'; requestBody = { input_image_urls: [imageUrl], geometry_file_format: outputFormat, material: $('splat-material').value || 'PBR', quality: $('splat-quality').value || 'medium', condition_mode: 'concat' }; if (seed !== undefined) requestBody.seed = seed; } console.log('3D gen request:', endpoint, JSON.stringify(requestBody).slice(0, 300)); // Submit to queue const submitResp = await fetch(`https://queue.fal.run/${endpoint}`, { method: 'POST', headers: { 'Authorization': `Key ${state.apiKey}`, 'Content-Type': 'application/json' }, body: JSON.stringify(requestBody) }); const submitText = await submitResp.text(); if (!submitResp.ok) throw new Error(`API error ${submitResp.status}: ${submitText.slice(0, 300)}`); let submitData; try { submitData = JSON.parse(submitText); } catch(e) { throw new Error('Invalid response: ' + submitText.slice(0, 200)); } const request_id = submitData.request_id; if (!request_id) { // Maybe sync result const modelUrl = submitData.mesh?.url || submitData.model_mesh?.url || submitData.output?.url; if (modelUrl) { download3DResult(modelUrl, outputFormat); return; } throw new Error('No request_id: ' + submitText.slice(0, 200)); } setStatus('Generating 3D model...'); // Poll let result = null; for (let i = 0; i < 90; i++) { await new Promise(r => setTimeout(r, 2000)); try { const statusResp = await fetch(`https://queue.fal.run/${endpoint}/requests/${request_id}/status`, { headers: { 'Authorization': `Key ${state.apiKey}` } }); const statusText = await statusResp.text(); if (!statusResp.ok) { console.warn('3D status poll error:', statusResp.status); continue; } const statusData = JSON.parse(statusText); if (statusData.status === 'COMPLETED') { const resultResp = await fetch(`https://queue.fal.run/${endpoint}/requests/${request_id}`, { headers: { 'Authorization': `Key ${state.apiKey}` } }); result = JSON.parse(await resultResp.text()); break; } else if (statusData.status === 'FAILED') { throw new Error('3D generation failed: ' + (statusData.error || JSON.stringify(statusData))); } } catch(e) { if (e.message.includes('3D generation failed')) throw e; console.warn('3D poll error:', e.message); continue; } const pct = Math.min(95, Math.round(i * 3)); showProgress('splat', pct, `Generating 3D model... ${pct}%`); } if (!result) throw new Error('3D generation timed out'); const modelUrl = result.mesh?.url || result.model_mesh?.url || result.output?.url || result.model?.url || result.glb?.url; if (modelUrl) { download3DResult(modelUrl, outputFormat); } else { console.error('Full 3D result:', JSON.stringify(result)); throw new Error('No model URL in response — check console'); } } catch (err) { console.error('3D gen error:', err); setStatus(`Error: ${err.message}`); showError('3D Generation Failed', err.message); } finally { $('btn-gen-splat').disabled = false; $('btn-gen-splat').textContent = 'Generate 3D Splat'; hideProgress('splat'); } }); // ── Init ── renderPresets(); if (state.apiKey) $('btn-generate').disabled = false;