  import { createApiClient } from '/public/js/api-client.js';
  import {
    clamp,
    editorState,
    formatPaddedTime as fmtTime,
    formatTime,
    lerp,
  } from '/public/js/editor/main.js';
  import {
    filterAssetCatalog,
  } from '/public/js/editor/asset-browser-model.js';
  import {
    assetBrowserGrid,
    createAssetBrowserShell,
    loadAssetCatalog,
  } from '/public/js/editor/asset-browser-controller.js';
  import {
    formatDuration,
    scaleTimelineSegments,
  } from '/public/js/editor/audio-model.js';
  import {
    buildWaveformData,
    detailedWaveformResponseMeetsRequest,
    detailedWaveformSampleCount,
    detailedWaveformSources,
    normalizeWaveformResponse,
  } from '/public/js/editor/audio-waveform.js';
  import { createAudioPlaybackController } from '/public/js/editor/audio-playback.js';
  import {
    applyAudioClipParameter,
    audioClipWaveformMaximum,
    audioClipRecords,
    audioWaveformViewportWindow,
    createAudioClipPlayback,
    createSeekableAudioSourceCache,
    drawAudioClipWaveform,
    trimAudioClipStart,
  } from '/public/js/editor/audio-clips.js?v=3';
  import {
    loadProjectAudio,
    readAudioDuration,
    waitForAudioReady,
  } from '/public/js/editor/audio-loader.js';
  import {
    preloadTimelineMedia,
    selectPlayableVideoBuffer,
  } from '/public/js/editor/media-preload.js?v=1';
  import {
    generateWithClaude,
    generateWithGemini,
  } from '/public/js/editor/ai/providers.js';
  import {
    createAiPreferences,
    createAiStatusPresenter,
  } from '/public/js/editor/ai/controller.js';
  import {
    activeTrackSegments,
  } from '/public/js/editor/rendering/layers.js';
  import {
    createRenderTargetPool,
    ensureRenderTarget,
    ensureRenderTargetPair,
  } from '/public/js/editor/rendering/targets.js';
  import {
    blendModeIndex,
    createSceneCompositor,
  } from '/public/js/editor/rendering/compositor.js';
  import {
    configureLutMaterial,
    configurePostFxMaterial,
    discoverEffects,
    runEffectChain,
  } from '/public/js/editor/rendering/effects.js';
  import {
    filterOverlays,
    overlayPassHasContent,
    overlaysNeedBase,
    renderOverlayPass,
  } from '/public/js/editor/rendering/overlays.js';
  import {
    renderWebGlLayerPlan,
    sceneBackgroundIsTransparent,
    shaderSupportsPostFxBypass,
    splitPostFxShaderLayers,
  } from '/public/js/editor/rendering/renderer.js';
  import {
    captureCompositionDataUrl,
    drawCompositionFrame,
    presentWebGlBehindOverlay,
  } from '/public/js/editor/rendering/capture.js?v=2';
  import {
    exportBitrate,
    exportChunkFrameCount,
    exportErrorMessage,
    exportFrameCount,
    exportTimeRange,
  } from '/public/js/editor/export/model.js';
  import { createFfmpegAdapter } from '/public/js/editor/export/ffmpeg-adapter.js';
  import { createExportController } from '/public/js/editor/export/controller.js';
  import {
    buildAudioMixPlan,
    renderAudioMixToWav,
  } from '/public/js/editor/export/audio-mix.js';
  import {
    analyzeAudioMixForExport,
    audioReactiveFrameAt,
  } from '/public/js/editor/export/audio-analysis.js?v=1';
  import {
    encodeVideoInChunks,
    ExportCancelledError,
  } from '/public/js/editor/export/video-encoder.js';
  import {
    prepareVideoFrameForExport,
  } from '/public/js/editor/export/video-frame.js?v=2';
  import { createSceneSystemRegistry } from '/public/js/editor/scenes/registry.js';
  import { createSceneFoundation } from '/public/js/editor/scenes/foundation.js';
  import { createSceneObjects } from '/public/js/editor/scenes/objects.js';
  import { createSceneGroups } from '/public/js/editor/scenes/groups.js';
  import { disposeObjectTree } from '/public/js/editor/scenes/disposal.js';
  import { createAnimationMixerStore } from '/public/js/editor/scenes/animation-mixers.js';
  import {
    createSegmentSceneInstanceStore,
    ensureSceneSegmentOverrideOwnership,
    segmentSceneKey,
  } from '/public/js/editor/scenes/instances.js';
  import {
    collectSceneOverrides as collectStoredSceneOverrides,
    createSceneOverrideEnvelope,
    parseSceneOverride,
    sceneStorageKey,
  } from '/public/js/editor/scenes/serialization.js';
  import { serializeRuntimeSceneState } from '/public/js/editor/scenes/runtime-serialization.js?v=2';
  import { createSierpinskiPyramid as createSierpinskiPyramidSystem } from '/public/js/editor/scenes/sierpinski.js';
  import { createMengerSponge as createMengerSpongeSystem } from '/public/js/editor/scenes/menger-sponge.js';
  import { createCoralGrowth as createCoralGrowthSystem } from '/public/js/editor/scenes/coral-growth.js';
  import { createCrystalLattice as createCrystalLatticeSystem } from '/public/js/editor/scenes/crystal-lattice.js';
  import { createFractalTree as createFractalTreeSystem } from '/public/js/editor/scenes/fractal-tree.js';
  import { createDisplacedSphere as createDisplacedSphereSystem } from '/public/js/editor/scenes/displaced-sphere.js';
  import {
    applyParticleKeyframeValue,
    createParticleSystem as createParticleSystemModule,
    syncParticleConfigFromRuntime,
  } from '/public/js/editor/scenes/particles.js';
  import {
    applyProceduralSceneKeyframeValue,
  } from '/public/js/editor/scenes/procedural-keyframes.js?v=4';
  import { createGrassField as createGrassFieldSystem } from '/public/js/editor/scenes/grass.js';
  import { createVoxelTunnel as createVoxelTunnelSystem } from '/public/js/editor/scenes/voxel-tunnel.js';
  import {
    applySphereClonerPlacement,
    createSphereCloner as createSphereClonerSystem,
  } from '/public/js/editor/scenes/sphere-cloner.js?v=3';
  import {
    applyHeartFieldMaterial,
    createHeartField as createHeartFieldSystem,
    updateHeartField,
  } from '/public/js/editor/scenes/heart-field.js?v=3';
  import {
    applyLandscapeFieldConfig,
    createLandscapeField as createLandscapeFieldSystem,
    updateLandscapeField,
  } from '/public/js/editor/scenes/landscape-field.js?v=3';
  import {
    applyClothSimulationConfig,
    createClothSimulation as createClothSimulationSystem,
    resetClothSimulation,
    setClothSimulationTexture,
    updateClothSimulation,
  } from '/public/js/editor/scenes/cloth-simulation.js';
  import {
    applyStarCrawlConfig,
    createStarCrawl as createStarCrawlSystem,
    ensureStarCrawlFont,
    loadStarCrawlBakedImage,
    resolveStarCrawlTime,
    updateStarCrawl,
  } from '/public/js/editor/scenes/star-crawl.js?v=8';
  import {
    bakeStarCrawlTexturesForExport,
  } from '/public/js/editor/scenes/star-crawl-export.js?v=2';
  import {
    applyFireworksConfig,
    createFireworksDisplay as createFireworksDisplaySystem,
    resolveFireworksTime,
    updateFireworksDisplay,
  } from '/public/js/editor/scenes/fireworks-display.js?v=1';
  import {
    exportResolution,
    exportResolutionLabels,
    normalizeViewportOrientation,
    viewportAspectRatio,
  } from '/public/js/editor/settings/orientation.js';
  import { createSettingsController } from '/public/js/editor/settings/controller.js';
  import { createFullscreenController } from '/public/js/editor/settings/fullscreen.js';
  import {
    EDITOR_EVENTS,
    PLAYER_ACTIONS,
    isPlayerControlMessage,
  } from '/public/js/player-protocol.js';
  import {
    detectPlayerPlatform,
    requiresMobileAudioGesture,
  } from '/public/js/player-mobile.js?v=3';
  import {
    KEYFRAME_TRACK_NAMES,
    interpolateKeyframes as interpolateKeyframeValues,
    upsertKeyframe,
  } from '/public/js/editor/keyframes.js';
  import {
    calculateLfoValue as calculatePureLfoValue,
    combineAnimatedValue,
  } from '/public/js/editor/lfo.js';
  import { parseShaderParams as parseShaderDirectives } from '/public/js/editor/shader-parser.js';
  import {
    applyLutClipParameter,
    lutToRgba8,
    parseCubeLUT as parseCubeLUTData,
  } from '/public/js/editor/lut.js';
  import {
    buildProjectData as buildSerializedProject,
    deserializeExposedParams,
    projectTracksForLoad,
  } from '/public/js/editor/project-format.js?v=2';
  import {
    appendTimelineTrack,
    createDefaultTimelineTracks,
    findAvailableSegmentStart,
    findNearestAvailableSegmentRange,
    findTrack,
    moveSegmentStart,
    nextTrackIdentity,
    removeTimelineTrack,
    replaceLibrarySegmentPreset,
    replaceMediaSegmentSource,
    renameTimelineTrack,
    reorderTimelineTracks,
    resizeSegmentEnd,
    resizeSegmentStart,
    setTimelineTrackVisibility,
    snapTimeToGrid,
    sortedTracks,
    trackIsVisible,
    timelineContentEnd,
    tracksOfType,
    normalizeProjectDuration,
  } from '/public/js/editor/timeline-model.js?v=7';
  import {
    clearSegmentState,
    legacyTrackName,
    migrateIndexedSegmentState,
    moveSegmentState,
    removeIndexedSegmentState,
    removeTrackSegmentState,
    stableSegmentStateKey,
  } from '/public/js/editor/segment-state.js?v=3';
  import {
    applySegmentClipboardState,
    createSegmentClipboardPayload,
    handleSegmentClipboardShortcut,
  } from '/public/js/editor/clipboard.js?v=2';
  import { createTimelinePointerController } from '/public/js/editor/timeline-pointer.js';
  import {
    createTimelineCanvasRenderer,
    timelineLoopRangeStyle,
  } from '/public/js/editor/timeline-canvas.js?v=2';
  import {
    createTimelineZoomRenderer,
  } from '/public/js/editor/timeline-zoom.js';
  import {
    applyTimelineClipShortcut,
    normalizeTimelineFrameRate,
    timelineClipShortcut,
    timelinePlayheadDelta,
  } from '/public/js/editor/timeline-shortcuts.js?v=3';
  import {
    createKeyframeEditorController,
    formatKeyframeValue,
    initialKeyframeAtPlayhead,
    keyframeDragActivated,
    parseKeyframeValue,
  } from '/public/js/editor/keyframe-controller.js?v=4';
  import {
    isKeyframeLaneSelectionKey,
    keyframeLaneHeightFromWheel,
    normalizeKeyframeLaneHeight,
    reorderKeyframeParams,
  } from '/public/js/editor/keyframe-lanes.js?v=2';
  import {
    drawKeyframeRulerCanvas,
    drawKeyframeWaveformCanvas,
  } from '/public/js/editor/keyframe-canvas.js';
  import { createParameterBuilders } from '/public/js/editor/parameters/builders.js';
  import { createParameterEditSession } from '/public/js/editor/parameters/edit-session.js';
  import {
    createParameterSliderValueController,
  } from '/public/js/editor/parameters/manual-value.js?v=1';
  import {
    createParameterDomainController,
    parameterPanelItemSelector,
  } from '/public/js/editor/parameters/domain-controller.js';
  import {
    createConfirmDialogController,
    createTextInputDialogController,
  } from '/public/js/editor/dialog-controller.js?v=2';
  import {
    adjustFontSizeScaleOnSelection,
    adjustRangesForEdit,
    applyFormatToSelection,
    findWordBoundary,
    getCharFormat,
    getSelectionFormat,
    textFormatsEqual as formatEquals,
    toggleBoldOnSelection,
    toggleItalicOnSelection,
    wordRangeAt,
  } from '/public/js/editor/text/rich-text.js';
  import {
    cursorIndexFromPoint,
    deleteTextSelection,
    extendTextSelection,
    insertText,
    verticalCursorIndex,
  } from '/public/js/editor/text/editor-model.js';

  // =========================
  // PLAY MODE DETECTION
  // =========================
  const urlParams = new URLSearchParams(window.location.search);
  const isStandaloneMode = urlParams.get('mode') === 'standalone';
  const isPlayMode = urlParams.get('mode') === 'play' || isStandaloneMode;
  const debugMobile = urlParams.get('debugMobile') === '1';
  
  // iOS/Mobile detection - used for audio handling
  const playerPlatform = detectPlayerPlatform(navigator);
  const isIOS = playerPlatform.isIOS || debugMobile;
  const isMobile = playerPlatform.isMobile || debugMobile;
  const isEmbeddedInIframe = window.self !== window.top;
  const playerMessageOrigin = window.location.origin;
  const api = createApiClient();
  
  // Global error handler for debugging iOS issues
  window.onerror = function(message, source, lineno, colno, error) {
    console.error('[GLOBAL ERROR]', message, 'at', source, 'line', lineno);
    // Also notify parent if in iframe
    if (window.self !== window.top) {
      try {
        window.parent.postMessage({
          type: EDITOR_EVENTS.LOADING_ERROR,
          message: `Script error: ${message} at line ${lineno}`
        }, playerMessageOrigin);
      } catch (e) {}
    }
    return false;
  };
  
  window.onunhandledrejection = function(event) {
    console.error('[UNHANDLED PROMISE]', event.reason);
    if (window.self !== window.top) {
      try {
        window.parent.postMessage({
          type: EDITOR_EVENTS.LOADING_ERROR,
          message: `Promise rejected: ${event.reason}`
        }, playerMessageOrigin);
      } catch (e) {}
    }
  };
  
  // Apply play mode styling immediately (CSS is pre-defined in <style> block)
  if (isPlayMode) {
    document.body.classList.add('play-mode');
    if (isIOS) document.body.classList.add('ios-single-surface');
    if (isIOS || isMobile) {
      console.log('[Editor] Mobile device detected in play mode');
    }
  }
  
  // =========================
  // AUTH CHECK
  // =========================
  let isAdmin = false;
  let isLoggedIn = false;
  
  // Track the user's last demo for "Continue" button
  let lastDemoId = null;
  let lastDemoName = null;
  
  async function checkAuth() {
    if (isStandaloneMode) {
      document.getElementById('authLoading').classList.add('hidden');
      return true;
    }
    try {
      const response = await api.auth.me();
      if (!response.ok) {
        // In play mode, allow viewing without auth
        if (isPlayMode) {
          document.getElementById('authLoading').classList.add('hidden');
          return true;
        }
        // Not logged in - show login form in start overlay
        isLoggedIn = false;
        document.getElementById('authLoading').classList.add('hidden');
        document.getElementById('startLoginForm').style.display = 'flex';
        document.getElementById('startWelcome').style.display = 'none';
        document.getElementById('startUsername').focus();
        return false;
      }
      const data = await response.json();
      editorState.currentUser = data.username;
      isAdmin = data.is_admin || false;
      isLoggedIn = true;
      
      if (!isPlayMode) {
        document.getElementById('usernameDisplay').textContent = editorState.currentUser;
        // Show welcome message in start overlay
        document.getElementById('startLoginForm').style.display = 'none';
        document.getElementById('startWelcome').style.display = 'block';
        document.getElementById('startWelcomeUser').textContent = editorState.currentUser;
        
        // Check for existing demos to update Start button
        await updateStartButtonForUser();
        
        // Auto-start if there's a project ID in the URL (e.g., after creating a new project)
        const urlParams = new URLSearchParams(window.location.search);
        if (urlParams.get('project')) {
          // Small delay to ensure everything is initialized, then auto-start
          setTimeout(() => {
            document.getElementById('btnStart').click();
          }, 100);
        }
      }
      document.getElementById('authLoading').classList.add('hidden');
      return true;
    } catch (e) {
      console.error('Auth check failed:', e);
      if (isPlayMode) {
        document.getElementById('authLoading').classList.add('hidden');
        return true;
      }
      // Error checking auth - show login form
      isLoggedIn = false;
      document.getElementById('authLoading').classList.add('hidden');
      document.getElementById('startLoginForm').style.display = 'flex';
      document.getElementById('startWelcome').style.display = 'none';
      document.getElementById('startUsername').focus();
      return false;
    }
  }
  
  // Update the Start button based on whether user has existing demos
  async function updateStartButtonForUser() {
    try {
      const response = await api.demos.mine();
      const data = await response.json();
      
      const btnStart = document.getElementById('btnStart');
      
      if (data.demos && data.demos.length > 0) {
        // User has projects - show "Continue My Project" with their most recent
        lastDemoId = data.demos[0].id;
        lastDemoName = data.demos[0].name;
        btnStart.textContent = 'Continue My Project';
        btnStart.title = `Continue working on "${lastDemoName}"`;
      } else {
        // New user - show "Start"
        lastDemoId = null;
        lastDemoName = null;
        btnStart.textContent = 'Start';
        btnStart.title = 'Create your first project';
      }
    } catch (e) {
      console.error('Failed to check user projects:', e);
      // On error, default to "Start"
      lastDemoId = null;
      lastDemoName = null;
    }
  }
  
  // Login from start overlay
  function showStartMessage(text, type) {
    const msgEl = document.getElementById('startMessage');
    msgEl.textContent = text;
    msgEl.className = 'start-message ' + type;
  }
  
  async function handleStartLogin() {
    const username = document.getElementById('startUsername').value.trim();
    const password = document.getElementById('startPassword').value;
    const loginBtn = document.getElementById('btnStartLogin');

    if (!username || !password) {
      showStartMessage('Please enter username and password', 'error');
      return;
    }
    
    loginBtn.disabled = true;
    loginBtn.textContent = 'Logging in...';
    
    try {
      const response = await api.auth.login(username, password);
      
      const data = await response.json();
      
      if (response.ok) {
        editorState.currentUser = username;
        isLoggedIn = true;
        isAdmin = data.is_admin || false;
        
        // Show success message briefly
        if (data.is_new_account) {
          showStartMessage('Account created! Welcome to Lazy Sunday Designer.', 'success');
          loginBtn.textContent = 'Account created!';
        } else {
          loginBtn.textContent = 'Success!';
        }
        
        // Update username display
        document.getElementById('usernameDisplay').textContent = editorState.currentUser;
        
        // Short delay to show success, then show loading and start the editor
        setTimeout(async () => {
          // Check if we're opening a shared project
          const urlParams = new URLSearchParams(window.location.search);
          const projectId = urlParams.get('project');
          
          // Update button to show we're loading
          loginBtn.textContent = projectId ? 'Opening project...' : 'Loading...';
          loginBtn.style.opacity = '0.7';
          
          // Update the Start button based on user's demos (for the data, not to show it)
          await updateStartButtonForUser();
          // Auto-start the editor - skip the welcome screen
          document.getElementById('btnStart').click();
        }, data.is_new_account ? 500 : 200);
      } else {
        showStartMessage(data.error || 'Login failed', 'error');
        loginBtn.disabled = false;
        loginBtn.textContent = 'Login & Start';
      }
    } catch (err) {
      showStartMessage('Connection error. Is the server running?', 'error');
      loginBtn.disabled = false;
      loginBtn.textContent = 'Login & Start';
    }
  }
  
  // Setup login button handler
  document.getElementById('btnStartLogin').addEventListener('click', handleStartLogin);
  
  // Allow Enter key to submit login
  document.getElementById('startPassword').addEventListener('keydown', (e) => {
    if (e.key === 'Enter') handleStartLogin();
  });
  document.getElementById('startUsername').addEventListener('keydown', (e) => {
    if (e.key === 'Enter') document.getElementById('startPassword').focus();
  });
  
  // Start screen: Start a new project
  document.getElementById('startNewDemo').addEventListener('click', async (e) => {
    e.preventDefault();
    try {
      const response = await api.demos.create();
      if (!response.ok) {
        throw new Error(`Server returned ${response.status}`);
      }
      const data = await response.json();
      if (data.demo_id) {
        window.location.href = `/editor?project=${data.demo_id}`;
      } else {
        throw new Error('No project ID returned');
      }
    } catch (err) {
      console.error('Failed to create project:', err);
      alert('Failed to create project. Please make sure you are logged in.');
    }
  });
  
  // Check if we're opening a shared project link
  async function checkSharedProjectLink() {
    const urlParams = new URLSearchParams(window.location.search);
    const projectId = urlParams.get('project');
    
    if (projectId) {
      try {
        // Fetch project info to show in login form
        const response = await api.demos.get(projectId);
        if (response.ok) {
          const data = await response.json();
          const projectName = data.demo?.name || 'Untitled Project';
          
          // Show the shared project info
          const infoEl = document.getElementById('sharedProjectInfo');
          const nameEl = document.getElementById('sharedProjectName');
          if (infoEl && nameEl) {
            nameEl.textContent = projectName;
            infoEl.style.display = 'block';
          }
          
          // Update login button text
          const loginBtn = document.getElementById('btnStartLogin');
          if (loginBtn) {
            loginBtn.textContent = 'Login & Open Project';
          }
        }
      } catch (e) {
        console.log('Could not fetch shared project info:', e);
      }
    }
  }
  
  // Check for shared project link (don't await - let it run in background)
  checkSharedProjectLink();
  
  // Check auth (but don't redirect if not logged in)
  await checkAuth();
  
  // Setup user menu
  const userMenu = document.getElementById('userMenu');
  const userMenuBtn = document.getElementById('userMenuBtn');
  const shareMenu = document.getElementById('shareMenu');
  const shareMenuBtn = document.getElementById('btnShare');
  userMenuBtn.addEventListener('click', () => {
    userMenu.classList.toggle('open');
  });
  shareMenuBtn.addEventListener('click', event => {
    event.stopPropagation();
    const isOpening = !shareMenu.classList.contains('open');
    userMenu.classList.remove('open');
    shareMenu.classList.toggle('open', isOpening);
    shareMenuBtn.setAttribute('aria-expanded', String(isOpening));
  });
  shareMenu.querySelectorAll('.share-menu-item').forEach(item => {
    item.addEventListener('click', () => {
      shareMenu.classList.remove('open');
      shareMenuBtn.setAttribute('aria-expanded', 'false');
    });
  });
  document.addEventListener('click', (e) => {
    if (!userMenu.contains(e.target)) {
      userMenu.classList.remove('open');
    }
    if (!shareMenu.contains(e.target)) {
      shareMenu.classList.remove('open');
      shareMenuBtn.setAttribute('aria-expanded', 'false');
    }
  });
  document.addEventListener('keydown', event => {
    if (event.key === 'Escape' && shareMenu.classList.contains('open')) {
      shareMenu.classList.remove('open');
      shareMenuBtn.setAttribute('aria-expanded', 'false');
      shareMenuBtn.focus();
    }
  });
  
  // Logout handler
  document.getElementById('btnLogout').addEventListener('click', async () => {
    await api.auth.logout();
    window.location.reload();  // Reload to show login form in start overlay
  });
  
  // Track which asset type we're quick-adding
  let quickAddAssetType = null;
  
  // Handle asset menu item clicks (now in user menu)
  document.querySelectorAll('.assets-menu-item').forEach(item => {
    item.addEventListener('click', () => {
      const assetType = item.dataset.assetType;
      userMenu.classList.remove('open');
      openAssetBrowserForQuickAdd(assetType);
    });
  });
  
  // =========================
  // DEMO MANAGEMENT
  // =========================
  const demoNameInput = document.getElementById('demoNameInput');
  const saveStatusEl = document.getElementById('saveStatus');
  const btnPublish = document.getElementById('btnPublish');
  const btnMyDemos = document.getElementById('btnMyDemos');
  
  let currentDemoData = null;
  let saveTimeout = null;
  let isSaving = false;
  let demoNameSaveTimeout = null;
  let isDemoNameSaving = false;
  let demoNameSaveQueued = false;
  let lastPersistedDemoName = '';
  
  // Pending project data from server (loaded before timeline vars exist)
  let pendingProjectData = null;
  let pendingSceneOverrides = null;
  
  // Get demo ID from URL or create new demo
  async function initializeDemo() {
    const urlParams = new URLSearchParams(window.location.search);
    const demoId = urlParams.get('project');

    if (isStandaloneMode) {
      const response = await fetch('/standalone/demo.json', { cache: 'no-store' });
      if (!response.ok) {
        throw new Error(`Standalone project data returned ${response.status}`);
      }
      const data = await response.json();
      if (data.format !== 'lazy-sunday-standalone' || !data.project) {
        throw new Error('Standalone project data is invalid');
      }
      editorState.currentDemoId = data.demo?.id || 'standalone';
      currentDemoData = data.demo || { name: 'Lazy Sunday Demo' };
      demoNameInput.value = currentDemoData.name || 'Lazy Sunday Demo';
      lastPersistedDemoName = demoNameInput.value;
      pendingProjectData = data.project;
      pendingSceneOverrides = data.scene_overrides || {};
      console.log('[INIT] Loaded standalone project:', demoNameInput.value);
      return;
    }
    
    if (demoId) {
      // Load existing demo
      try {
        const response = await api.demos.get(demoId);
        if (response.ok) {
          const data = await response.json();
          editorState.currentDemoId = demoId;
          currentDemoData = data.demo;
          demoNameInput.value = data.demo.name;
          lastPersistedDemoName = data.demo.name;
          
          // Store for later loading (variables not defined yet)
          if (data.project) {
            pendingProjectData = data.project;
            pendingSceneOverrides = data.scene_overrides;
            console.log('[INIT] Project data received:', {
              version: data.project._version,
              audioFile: data.project.audioFile,
              scenes: data.project.sceneSegments?.length || 0,
              images: data.project.imageSegments?.length || 0,
              postfx: data.project.postfxSegments?.length || 0
            });
          } else {
            console.warn('[INIT] No project data in server response');
          }
          console.log('Loaded project:', data.demo.name);
          return;
        }
      } catch (e) {
        console.error('Failed to load project:', e);
      }
    }
    
    // Create new demo
    try {
      const response = await api.demos.create();
      const data = await response.json();
      editorState.currentDemoId = data.demo_id;
      currentDemoData = data.demo;
      demoNameInput.value = data.demo.name;
      lastPersistedDemoName = data.demo.name;
      
      // Update URL without reload
      window.history.replaceState({}, '', `/editor?project=${editorState.currentDemoId}`);
      console.log('Created new project:', data.demo.name);
    } catch (e) {
      console.error('Failed to create project:', e);
      saveStatusEl.textContent = '⚠';
      saveStatusEl.className = 'error';
      saveStatusEl.title = 'Error saving';
    }
  }
  
  // Show save status
  function showSaveStatus(status) {
    if (status === 'saving') {
      saveStatusEl.textContent = '↻';
      saveStatusEl.className = 'saving';
      saveStatusEl.title = 'Saving...';
    } else if (status === 'saved') {
      saveStatusEl.textContent = '✓';
      saveStatusEl.className = 'saved';
      saveStatusEl.title = 'Saved';
    } else if (status === 'error') {
      saveStatusEl.textContent = '⚠';
      saveStatusEl.className = 'error';
      saveStatusEl.title = 'Error saving';
    }
  }
  
  // Save demo to server (debounced)
  function scheduleDemoSave() {
    if (!editorState.currentDemoId) return;
    
    showSaveStatus('saving');
    
    if (saveTimeout) {
      clearTimeout(saveTimeout);
    }
    
    saveTimeout = setTimeout(async () => {
      await saveDemoToServer();
    }, 2000); // 2 second debounce
  }

  function projectSaveNameUpdate() {
    const editingAnotherUsersDemo = (
      currentDemoData?.owner
      && currentDemoData.owner !== editorState.currentUser
      && !isAdmin
    );
    return editingAnotherUsersDemo
      ? { name: demoNameInput.value.trim() || currentDemoData.name }
      : {};
  }

  function canRenameCurrentDemoDirectly() {
    return (
      !currentDemoData?.owner
      || currentDemoData.owner === editorState.currentUser
      || isAdmin
    );
  }

  function scheduleDemoNameSave() {
    if (!editorState.currentDemoId) return;
    showSaveStatus('saving');
    if (demoNameSaveTimeout) clearTimeout(demoNameSaveTimeout);
    demoNameSaveTimeout = setTimeout(() => {
      demoNameSaveTimeout = null;
      void saveDemoNameToServer();
    }, 400);
  }

  async function saveDemoNameToServer({ keepalive = false } = {}) {
    if (!editorState.currentDemoId) return false;
    const requestedName = demoNameInput.value.trim();
    if (isDemoNameSaving && !keepalive) {
      demoNameSaveQueued = true;
      return false;
    }
    if (!requestedName || requestedName === lastPersistedDemoName) {
      if (!isSaving && !isDemoNameSaving) showSaveStatus('saved');
      return true;
    }

    isDemoNameSaving = true;
    try {
      const response = await api.demos.rename(
        editorState.currentDemoId,
        requestedName,
        keepalive ? { keepalive: true } : {},
      );
      if (!response.ok) {
        showSaveStatus('error');
        return false;
      }
      const result = await response.json();
      lastPersistedDemoName = result.name || requestedName;
      if (currentDemoData && demoNameInput.value.trim() === requestedName) {
        currentDemoData.name = lastPersistedDemoName;
      }
      if (!isSaving) showSaveStatus('saved');
      return true;
    } catch (error) {
      console.error('Failed to save project name:', error);
      showSaveStatus('error');
      return false;
    } finally {
      isDemoNameSaving = false;
      if (demoNameSaveQueued && !keepalive) {
        demoNameSaveQueued = false;
        void saveDemoNameToServer();
      }
    }
  }

  function flushDemoNameSave(options = {}) {
    if (demoNameSaveTimeout) {
      clearTimeout(demoNameSaveTimeout);
      demoNameSaveTimeout = null;
    }
    return saveDemoNameToServer(options);
  }
  
  // Actually save to server (uses getProjectData which is defined later)
  async function saveDemoToServer() {
    if (!editorState.currentDemoId || isSaving) return;
    if (typeof getProjectData !== 'function') return; // Not ready yet
    
    isSaving = true;
    showSaveStatus('saving');
    
    try {
      const projectData = getProjectData();
      console.log('[SAVE] Saving project with audioFile:', projectData.audioFile);
      
      // Collect ALL scene overrides from localStorage (including custom scene names)
      const sceneOverrides = collectAllSceneOverrides();
      
      const response = await api.demos.save(
        editorState.currentDemoId,
        projectData,
        sceneOverrides,
        projectSaveNameUpdate(),
      );
      
      if (response.ok) {
        const result = await response.json();
        
        // Handle remix - project was forked because user doesn't own it
        if (result.remixed && result.new_demo_id) {
          console.log('[SAVE] Project remixed! New ID:', result.new_demo_id);
          editorState.currentDemoId = result.new_demo_id;
          currentDemoData = result.new_demo || currentDemoData;
          if (result.new_demo?.name) lastPersistedDemoName = result.new_demo.name;
          
          // Update URL to reflect new project
          window.history.replaceState({}, '', `/editor?project=${editorState.currentDemoId}`);
          
          // Update project name display if available
          if (result.new_demo?.name) {
            const nameEl = document.getElementById('projectName');
            if (nameEl) nameEl.textContent = result.new_demo.name;
          }
          
          // Show notification that remix was created
          console.log('[SAVE] Remix saved successfully - this is now your own copy');
        }
        
        if (!isDemoNameSaving && !demoNameSaveTimeout) showSaveStatus('saved');
      } else {
        showSaveStatus('error');
      }
    } catch (e) {
      console.error('Failed to save project:', e);
      showSaveStatus('error');
    } finally {
      isSaving = false;
    }
  }
  
  // Immediate save - bypasses debounce, waits for completion
  async function saveDemoToServerImmediate() {
    if (!editorState.currentDemoId) return false;
    if (typeof getProjectData !== 'function') return false;
    
    // Cancel any pending debounced save
    if (saveTimeout) {
      clearTimeout(saveTimeout);
      saveTimeout = null;
    }
    
    // Wait if another save is in progress
    while (isSaving) {
      await new Promise(r => setTimeout(r, 100));
    }
    
    isSaving = true;
    
    try {
      const projectData = getProjectData();
      console.log('[SAVE IMMEDIATE] Saving project with audioFile:', projectData.audioFile);
      
      // Collect ALL scene overrides from localStorage (including custom scene names)
      const sceneOverrides = collectAllSceneOverrides();
      
      const response = await api.demos.save(
        editorState.currentDemoId,
        projectData,
        sceneOverrides,
        projectSaveNameUpdate(),
      );
      
      if (response.ok) {
        const result = await response.json();
        
        // Handle remix - project was forked because user doesn't own it
        if (result.remixed && result.new_demo_id) {
          console.log('[SAVE IMMEDIATE] Project remixed! New ID:', result.new_demo_id);
          editorState.currentDemoId = result.new_demo_id;
          currentDemoData = result.new_demo || currentDemoData;
          if (result.new_demo?.name) lastPersistedDemoName = result.new_demo.name;
          
          // Update URL to reflect new project
          window.history.replaceState({}, '', `/editor?project=${editorState.currentDemoId}`);
          
          // Update project name display if available
          if (result.new_demo?.name) {
            const nameEl = document.getElementById('projectName');
            if (nameEl) nameEl.textContent = result.new_demo.name;
          }
          
          console.log('[SAVE IMMEDIATE] Remix saved - this is now your own copy');
        }
        
        if (!isDemoNameSaving && !demoNameSaveTimeout) showSaveStatus('saved');
        return true;
      } else {
        showSaveStatus('error');
        return false;
      }
    } catch (e) {
      console.error('[SAVE IMMEDIATE] Failed:', e);
      showSaveStatus('error');
      return false;
    } finally {
      isSaving = false;
    }
  }
  
  // Project name input handler
  demoNameInput.addEventListener('input', () => {
    if (currentDemoData) {
      currentDemoData.name = demoNameInput.value;
    }
    if (canRenameCurrentDemoDirectly()) scheduleDemoNameSave();
    else scheduleDemoSave();
  });
  demoNameInput.addEventListener('change', () => {
    if (canRenameCurrentDemoDirectly()) void flushDemoNameSave();
  });
  demoNameInput.addEventListener('keydown', event => {
    if (event.key === 'Enter') {
      event.preventDefault();
      demoNameInput.blur();
    }
  });
  window.addEventListener('pagehide', () => {
    if (
      canRenameCurrentDemoDirectly()
      && demoNameInput.value.trim() !== lastPersistedDemoName
    ) {
      void flushDemoNameSave({ keepalive: true });
    }
  });
  
  // =========================
  // SCREENSHOT CAPTURE
  // =========================
  
  /**
   * Capture a screenshot for the demo thumbnail
   * Seeks to a specific time, renders a frame, and captures it
   * @param {number} targetTime - Time in seconds to capture (default 2.5s)
   * @returns {Promise<string|null>} - Base64 encoded JPEG or null on failure
   */
  async function captureScreenshot(targetTime = 2.5) {
    return new Promise(async (resolve) => {
      try {
        // Get the canvas element
        const canvas = document.getElementById('threeCanvas');
        if (!canvas) {
          console.error('[SCREENSHOT] Canvas not found');
          resolve(null);
          return;
        }
        
        // Store current time to restore later
        const originalTime = typeof getCurrentTime === 'function' ? getCurrentTime() : 0;
        const wasPlaying = silentPlaying;
        
        // Pause if playing
        if (wasPlaying) {
          silentPlaying = false;
          audioClipPlayback?.pauseAll();
        }
        
        // Seek to the target time (clamp to valid range)
        const duration = durationGuess || 60;
        const seekTime = Math.min(Math.max(targetTime, 0.5), duration - 0.5);
        
        // Update time position
        silentTime = seekTime;
        audioClipPlayback?.sync(timelineTracks, seekTime, false);
        
        // Wait a moment for render to update
        await new Promise(r => setTimeout(r, 150));
        
        // Force a render frame by calling the scene rendering
        if (typeof renderFrame === 'function') {
          renderFrame(seekTime);
        }
        
        // Wait for render to complete
        await new Promise(r => setTimeout(r, 100));
        
        // Try multiple captures to ensure we get a non-black frame
        let bestCapture = null;
        let bestBrightness = 0;
        
        for (let attempt = 0; attempt < 3; attempt++) {
          // Try different times if the frame is too dark
          const tryTime = seekTime + attempt * 0.5;
          silentTime = tryTime;
          audioClipPlayback?.sync(timelineTracks, tryTime, false);
          
          await new Promise(r => setTimeout(r, 100));
          if (typeof renderFrame === 'function') {
            renderFrame(tryTime);
          }
          await new Promise(r => setTimeout(r, 50));
          
          // Capture the complete composition. Media/text that intentionally
          // bypass post-FX live on the overlay canvas, not the WebGL canvas.
          const dataUrl = captureCompositionDataUrl(
            document,
            canvas,
            imageOverlayCanvas,
            'image/jpeg',
            0.85,
          );
          
          // Check brightness by sampling the image
          const brightness = await checkImageBrightness(dataUrl);
          console.log(`[SCREENSHOT] Attempt ${attempt + 1} at ${tryTime.toFixed(1)}s, brightness: ${brightness.toFixed(2)}`);
          
          if (brightness > bestBrightness) {
            bestBrightness = brightness;
            bestCapture = dataUrl;
          }
          
          // If brightness is acceptable, stop trying
          if (brightness > 0.05) {
            break;
          }
        }
        
        // Restore original time
        silentTime = originalTime;
        audioClipPlayback?.sync(timelineTracks, originalTime, false);
        
        // Resume if was playing
        if (wasPlaying) {
          silentPlaying = true;
          audioClipPlayback?.sync(timelineTracks, originalTime, true);
        }
        
        if (bestCapture && bestBrightness > 0.01) {
          console.log(`[SCREENSHOT] Captured with brightness ${bestBrightness.toFixed(2)}`);
          resolve(bestCapture);
        } else {
          console.warn('[SCREENSHOT] All captures were too dark');
          resolve(bestCapture); // Return best attempt anyway
        }
        
      } catch (err) {
        console.error('[SCREENSHOT] Error:', err);
        resolve(null);
      }
    });
  }
  
  /**
   * Check the average brightness of an image
   * @param {string} dataUrl - Base64 encoded image
   * @returns {Promise<number>} - Brightness value 0-1
   */
  function checkImageBrightness(dataUrl) {
    return new Promise((resolve) => {
      const img = new Image();
      img.onload = () => {
        const canvas = document.createElement('canvas');
        const size = 32; // Sample at low resolution for speed
        canvas.width = size;
        canvas.height = size;
        const ctx = canvas.getContext('2d');
        ctx.drawImage(img, 0, 0, size, size);
        
        const imageData = ctx.getImageData(0, 0, size, size);
        const data = imageData.data;
        let totalBrightness = 0;
        
        for (let i = 0; i < data.length; i += 4) {
          // Calculate perceived brightness
          const r = data[i];
          const g = data[i + 1];
          const b = data[i + 2];
          const brightness = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
          totalBrightness += brightness;
        }
        
        const avgBrightness = totalBrightness / (size * size);
        resolve(avgBrightness);
      };
      img.onerror = () => resolve(0);
      img.src = dataUrl;
    });
  }
  
  /**
   * Save screenshot to server
   * @param {string} demoId - Demo ID
   * @param {string} dataUrl - Base64 encoded image
   * @returns {Promise<boolean>} - Success
   */
  async function saveScreenshot(demoId, dataUrl) {
    try {
      const response = await api.demos.screenshot(demoId, dataUrl);
      return response.ok;
    } catch (err) {
      console.error('[SCREENSHOT] Failed to save:', err);
      return false;
    }
  }
  
  // =========================
  // PUBLISH MODAL
  // =========================
  const publishModal = document.getElementById('publishModal');
  const publishSuccessModal = document.getElementById('publishSuccessModal');
  const publishDemoName = document.getElementById('publishDemoName');
  const publishHint = document.getElementById('publishHint');
  const publishButtonsNew = document.getElementById('publishButtonsNew');
  const publishButtonsUpdate = document.getElementById('publishButtonsUpdate');
  
  function openPublishModal() {
    publishDemoName.value = demoNameInput.value || currentDemoData?.name || '';
    
    // Show different buttons based on whether already published
    const isAlreadyPublic = currentDemoData?.is_public === true;
    
    if (isAlreadyPublic) {
      publishButtonsNew.style.display = 'none';
      publishButtonsUpdate.style.display = 'flex';
      publishHint.textContent = 'This project is already published. You can update it or publish a copy.';
    } else {
      publishButtonsNew.style.display = 'flex';
      publishButtonsUpdate.style.display = 'none';
      publishHint.textContent = 'Your project will be published to the gallery and can be viewed by anyone.';
    }
    
    publishModal.classList.add('show');
  }
  
  function closePublishModal() {
    publishModal.classList.remove('show');
  }
  
  function openPublishSuccessModal(projectName) {
    document.getElementById('publishSuccessName').textContent = `"${projectName}"`;
    publishSuccessModal.classList.add('show');
  }
  
  function closePublishSuccessModal() {
    publishSuccessModal.classList.remove('show');
  }
  
  // Get unique name for copy (adds number suffix if name exists)
  async function getUniquePublicName(baseName) {
    try {
      const response = await api.demos.public();
      const data = await response.json();
      const existingNames = (data.demos || []).map(d => d.name.toLowerCase());
      
      if (!existingNames.includes(baseName.toLowerCase())) {
        return baseName;
      }
      
      let counter = 2;
      while (existingNames.includes(`${baseName} ${counter}`.toLowerCase())) {
        counter++;
      }
      return `${baseName} ${counter}`;
    } catch (e) {
      console.error('Failed to check existing names:', e);
      return `${baseName} ${Date.now()}`;
    }
  }
  
  // Core publish function
  async function doPublish(btn, projectName, isUpdate = false, isCopy = false) {
    const originalText = btn.textContent;
    
    try {
      // Save current state first
      await saveDemoToServer();
      
      // Get the target demo ID
      let targetDemoId = editorState.currentDemoId;
      let finalName = projectName;
      
      // If publishing a copy, create a new demo first
      if (isCopy) {
        btn.textContent = 'Creating copy...';
        btn.disabled = true;
        
        // Get unique name
        finalName = await getUniquePublicName(projectName);
        
        // Fork from current demo
        const forkResponse = await api.demos.fork(editorState.currentDemoId);
        
        if (!forkResponse.ok) {
          throw new Error('Failed to create copy');
        }
        
        const forkData = await forkResponse.json();
        targetDemoId = forkData.demo_id;
      }
      
      // Capture screenshot for thumbnail
      btn.textContent = 'Capturing...';
      btn.disabled = true;
      
      const screenshot = await captureScreenshot(2.5);
      if (screenshot) {
        btn.textContent = 'Saving thumbnail...';
        await saveScreenshot(targetDemoId, screenshot);
      }
      
      // Publish
      btn.textContent = 'Publishing...';
      const response = await api.demos.publish(targetDemoId, {
        name: finalName,
        is_public: true,
      });
      
      if (!response.ok) {
        const errorData = await response.json();
        throw new Error(errorData.error || 'Failed to publish');
      }
      
      const publishResult = await response.json();
      
      // Handle remix - project was forked because user doesn't own it
      if (publishResult.remixed && publishResult.new_demo_id) {
        console.log('[PUBLISH] Project remixed during publish! New ID:', publishResult.new_demo_id);
        targetDemoId = publishResult.new_demo_id;
        editorState.currentDemoId = publishResult.new_demo_id;
        
        // Update URL to reflect new project
        window.history.replaceState({}, '', `/editor?project=${editorState.currentDemoId}`);
        
        // Update project name display if available
        if (publishResult.new_demo?.name) {
          const nameEl = document.getElementById('projectName');
          if (nameEl) nameEl.textContent = publishResult.new_demo.name;
          finalName = publishResult.new_demo.name;
        }
        
        // Re-save screenshot to the new remixed demo
        if (screenshot) {
          console.log('[PUBLISH] Re-saving screenshot to remixed project');
          await saveScreenshot(targetDemoId, screenshot);
        }
      }
      
      // Update local state (only for current project, not copy)
      if (!isCopy) {
        demoNameInput.value = finalName;
        currentDemoData.name = finalName;
        currentDemoData.is_public = true;
      }
      
        closePublishModal();
        showSaveStatus('saved');
      
      // Show success modal
      openPublishSuccessModal(finalName);
      
      console.log(`[PUBLISH] Successfully published: ${finalName} (demo_id: ${targetDemoId})`);
      
    } catch (e) {
      console.error('Failed to publish:', e);
      alert('Failed to publish: ' + e.message);
    } finally {
      btn.textContent = originalText;
      btn.disabled = false;
    }
  }
  
  btnPublish.addEventListener('click', openPublishModal);
  document.getElementById('btnExportHtml').addEventListener('click', async event => {
    if (!editorState.currentDemoId) return;
    const button = event.currentTarget;
    const title = button.querySelector('.share-menu-label');
    const originalTitle = title.textContent;
    button.disabled = true;
    title.textContent = 'Building standalone ZIP…';
    showToast('Saving and packaging standalone demo…', 4000);
    try {
      let nameSaved = await flushDemoNameSave();
      if (!nameSaved && isDemoNameSaving) {
        while (isDemoNameSaving) {
          await new Promise(resolve => setTimeout(resolve, 50));
        }
        nameSaved = await flushDemoNameSave();
      }
      const projectSaved = await saveDemoToServerImmediate();
      if (!nameSaved || !projectSaved) {
        throw new Error('The latest project changes could not be saved');
      }
      const bakedCrawls = await bakeStarCrawlTexturesForExport({
        tracks: timelineTracks,
        keyframeData,
        lfoData,
        documentTarget: document,
      });
      if (bakedCrawls.textures.length) {
        showToast(
          `Baked ${bakedCrawls.textures.length} crawl texture`
          + `${bakedCrawls.textures.length === 1 ? '' : 's'} for identical playback`,
          4000,
        );
      }
      if (bakedCrawls.skipped.length) {
        console.warn(
          '[HTML EXPORT] Kept animated crawl typography live:',
          bakedCrawls.skipped,
        );
      }
      const response = await api.demos.exportHtml(
        editorState.currentDemoId,
        bakedCrawls.textures,
      );
      if (!response.ok) {
        let message = `Export failed (${response.status})`;
        try {
          const error = await response.json();
          if (error.error) message = error.error;
        } catch (_error) {}
        throw new Error(message);
      }
      const archive = await response.blob();
      const disposition = response.headers.get('Content-Disposition') || '';
      const filenameMatch = disposition.match(/filename="([^"]+)"/i);
      const filename = filenameMatch?.[1]
        || `${demoNameInput.value.trim() || 'lazy-sunday-demo'}-standalone.zip`;
      const downloadUrl = URL.createObjectURL(archive);
      const link = document.createElement('a');
      link.href = downloadUrl;
      link.download = filename;
      document.body.appendChild(link);
      link.click();
      link.remove();
      setTimeout(() => URL.revokeObjectURL(downloadUrl), 1000);
      showToast('Standalone website downloaded');
    } catch (error) {
      console.error('[HTML EXPORT] Failed:', error);
      alert(`Failed to export HTML: ${error.message}`);
    } finally {
      button.disabled = false;
      title.textContent = originalTitle;
    }
  });
  document.getElementById('publishModalClose').addEventListener('click', closePublishModal);
  document.getElementById('publishCancel').addEventListener('click', closePublishModal);
  document.getElementById('publishCancelUpdate').addEventListener('click', closePublishModal);
  
  // New project publish
  document.getElementById('publishConfirm').addEventListener('click', async () => {
    if (!editorState.currentDemoId) return;
    const btn = document.getElementById('publishConfirm');
    await doPublish(btn, publishDemoName.value, false, false);
  });
  
  // Update existing published project
  document.getElementById('publishUpdate').addEventListener('click', async () => {
    if (!editorState.currentDemoId) return;
    const btn = document.getElementById('publishUpdate');
    await doPublish(btn, publishDemoName.value, true, false);
  });
  
  // Publish a copy
  document.getElementById('publishCopy').addEventListener('click', async () => {
    if (!editorState.currentDemoId) return;
    const btn = document.getElementById('publishCopy');
    await doPublish(btn, publishDemoName.value, false, true);
  });
  
  publishModal.addEventListener('click', (e) => {
    if (e.target === publishModal) closePublishModal();
  });
  
  // Success modal handlers
  document.getElementById('publishSuccessClose').addEventListener('click', closePublishSuccessModal);
  document.getElementById('publishSuccessCloseBtn').addEventListener('click', closePublishSuccessModal);
  document.getElementById('publishSuccessGallery').addEventListener('click', () => {
    window.open('/gallery', '_blank');
    closePublishSuccessModal();
  });
  publishSuccessModal.addEventListener('click', (e) => {
    if (e.target === publishSuccessModal) closePublishSuccessModal();
  });
  
  // =========================
  // MY DEMOS MODAL
  // =========================
  const myDemosModal = document.getElementById('myDemosModal');
  const demosList = document.getElementById('demosList');
  
  async function openMyDemosModal() {
    myDemosModal.classList.add('show');
    demosList.innerHTML = '<div class="empty-state">Loading...</div>';
    
    try {
      const response = await api.demos.mine();
      const data = await response.json();
      
      if (!data.demos || data.demos.length === 0) {
        demosList.innerHTML = '<div class="empty-state">No projects yet. Create your first one!</div>';
        return;
      }
      
      demosList.innerHTML = data.demos.map(demo => `
        <div class="demo-item" data-id="${demo.id}">
          <div class="demo-item-info">
            <div class="demo-item-name">${escapeHtml(demo.name)}</div>
            <div class="demo-item-meta">
              Updated ${formatDate(demo.updated_at)}
              <span class="visibility-badge ${demo.is_public ? 'public' : ''}">${demo.is_public ? 'Public' : 'Private'}</span>
            </div>
          </div>
          <div class="demo-item-actions">
            <button class="nav-btn open-demo" data-id="${demo.id}">Open</button>
            ${demo.is_public 
              ? `<button class="nav-btn unpublish" data-id="${demo.id}" data-name="${escapeHtml(demo.name)}">Unpublish</button>`
              : `<button class="nav-btn delete" data-id="${demo.id}" data-name="${escapeHtml(demo.name)}">Delete</button>`
            }
          </div>
        </div>
      `).join('');
      
      // Add event listeners
      demosList.querySelectorAll('.open-demo').forEach(btn => {
        btn.addEventListener('click', () => {
          window.location.href = `/editor?project=${btn.dataset.id}`;
        });
      });
      
      demosList.querySelectorAll('.delete').forEach(btn => {
        btn.addEventListener('click', async () => {
          if (confirm(`Delete "${btn.dataset.name}"? This cannot be undone.`)) {
            try {
              await api.demos.delete(btn.dataset.id);
              openMyDemosModal(); // Refresh list
            } catch (e) {
              console.error('Failed to delete project:', e);
            }
          }
        });
      });
      
      demosList.querySelectorAll('.unpublish').forEach(btn => {
        btn.addEventListener('click', async () => {
          if (confirm(`Unpublish "${btn.dataset.name}"? It will be removed from the gallery but you can publish it again later.`)) {
            try {
              await api.demos.publish(btn.dataset.id, { is_public: false });
              openMyDemosModal(); // Refresh list
            } catch (e) {
              console.error('Failed to unpublish project:', e);
            }
          }
        });
      });
    } catch (e) {
      console.error('Failed to load projects:', e);
      demosList.innerHTML = '<div class="empty-state">Failed to load projects</div>';
    }
  }
  
  function closeMyDemosModal() {
    myDemosModal.classList.remove('show');
  }
  
  function escapeHtml(text) {
    const div = document.createElement('div');
    div.textContent = text || '';
    return div.innerHTML;
  }
  
  // Show a toast notification
  function showToast(message, duration = 2000) {
    const container = document.getElementById('toastContainer');
    const toast = document.createElement('div');
    toast.className = 'toast';
    toast.textContent = message;
    container.appendChild(toast);
    
    // Remove toast after animation completes
    setTimeout(() => {
      if (toast.parentNode) {
        toast.parentNode.removeChild(toast);
      }
    }, duration);
  }
  
  function formatDate(dateStr) {
    if (!dateStr) return '';
    const date = new Date(dateStr);
    const now = new Date();
    const diff = now - date;
    
    if (diff < 60000) return 'just now';
    if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`;
    if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`;
    if (diff < 604800000) return `${Math.floor(diff / 86400000)}d ago`;
    return date.toLocaleDateString();
  }
  
  btnMyDemos.addEventListener('click', openMyDemosModal);
  document.getElementById('btnNewProject').addEventListener('click', async () => {
    // Close the user menu
    document.getElementById('userMenu').classList.remove('open');
    // Create new project
    try {
      const response = await api.demos.create();
      const data = await response.json();
      window.location.href = `/editor?project=${data.demo_id}`;
    } catch (e) {
      console.error('Failed to create project:', e);
    }
  });
  document.getElementById('myDemosModalClose').addEventListener('click', closeMyDemosModal);
  document.getElementById('btnNewDemo').addEventListener('click', async () => {
    try {
      const response = await api.demos.create();
      const data = await response.json();
      window.location.href = `/editor?project=${data.demo_id}`;
    } catch (e) {
      console.error('Failed to create project:', e);
    }
  });
  
  myDemosModal.addEventListener('click', (e) => {
    if (e.target === myDemosModal) closeMyDemosModal();
  });
  
  // Close modals on Escape - only close one modal per ESC press (topmost first)
  document.addEventListener('keydown', (e) => {
    if (e.code === 'Escape') {
      // Priority order: AI generator (topmost) -> new scene -> publish -> my demos -> asset browser
      if (aiGeneratorModal.classList.contains('show')) {
        closeAiGenerator();
        return; // Only close one modal per ESC
      }
      const newSceneModalEl = document.getElementById('newSceneModal');
      if (newSceneModalEl?.classList.contains('show')) {
        newSceneModalEl.classList.remove('show');
        return; // Only close one modal per ESC
      }
      if (publishModal.classList.contains('show')) {
        closePublishModal();
        return; // Only close one modal per ESC
      }
      if (myDemosModal.classList.contains('show')) {
        closeMyDemosModal();
        return; // Only close one modal per ESC
      }
      if (assetBrowserModal.classList.contains('show')) {
        closeAssetBrowser();
        return; // Only close one modal per ESC
      }
    }
  });
  
  // =========================
  // ASSET BROWSER
  // =========================
  const assetBrowserModal = document.getElementById('assetBrowserModal');
  const assetBrowserTitle = document.getElementById('assetBrowserTitle');
  const assetGrid = document.getElementById('assetGrid');
  const assetFilterTabs = document.getElementById('assetFilterTabs');
  const assetBrowserSelect = document.getElementById('assetBrowserSelect');
  
  let assetBrowserMode = 'media'; // 'media', 'postfx', 'shader', 'audio', or 'scenes'
  let assetBrowserCallback = null;
  let selectedAsset = null;
  let loadedAssets = [];
  let currentAssetFilter = 'all';
  
  // Pagination for media assets (4 rows of ~4 items = ~16 items per page)
  let currentMediaPage = 0;
  const MEDIA_ITEMS_PER_PAGE = 16;
  
  const assetGenerateBtn = document.getElementById('assetGenerateBtn');
  const assetNewSceneBtn = document.getElementById('assetNewSceneBtn');
  const assetRefineBtn = document.getElementById('assetRefineBtn');
  
  // Post FX Preview state
  let postfxPreviewAnimationId = null;
  let postfxPreviewShader = null;
  let postfxPreviewCanvas = null;
  let postfxPreviewCtx = null;
  let postfxKeyboardNavIndex = -1;
  
  // Shader Preview state
  let shaderPreviewAnimationId = null;
  let shaderPreviewShader = null;
  let shaderPreviewCanvas = null;
  let shaderPreviewCtx = null;
  let shaderKeyboardNavIndex = -1;
  
  // Scene Preview state
  let scenePreviewAnimationId = null;
  let scenePreviewSceneObj = null; // Full scene object from createThreeScene
  let scenePreviewCanvas = null;
  let scenePreviewCtx = null;
  let scenePreviewStartTime = 0;
  let sceneKeyboardNavIndex = -1;
  
  // Audio Preview state
  let audioPreviewAnimationId = null;
  let audioPreviewCanvas = null;
  let audioPreviewCtx = null;
  let audioPreviewAudio = null;
  let audioPreviewAudioCtx = null;
  let audioPreviewAnalyser = null;
  let audioPreviewDataArray = null;
  let audioPreviewSource = null;
  let audioKeyboardNavIndex = -1;
  let timelineWasMuted = false;
  let timelineWasPlaying = false;

  // LUT Preview state
  let lutPreviewCanvas = null;
  let lutPreviewCtx = null;
  let lutKeyboardNavIndex = -1;
  let lutPreviewCurrentLUT = null;

  const assetBrowserShell = createAssetBrowserShell({
    documentTarget: document,
    modal: assetBrowserModal,
    title: assetBrowserTitle,
    filterTabs: assetFilterTabs,
    refineButton: assetRefineBtn,
    previewControllers: {
      postfx: { start: initPostFxPreview, stop: stopPostFxPreview, reset: () => { postfxKeyboardNavIndex = -1; } },
      shader: { start: initShaderPreview, stop: stopShaderPreview, reset: () => { shaderKeyboardNavIndex = -1; } },
      scenes: { start: initScenePreview, stop: stopScenePreview, reset: () => { sceneKeyboardNavIndex = -1; } },
      audio: { start: initAudioPreview, stop: stopAudioPreview, reset: () => { audioKeyboardNavIndex = -1; } },
      lut: { start: initLUTPreview, stop: stopLUTPreview, reset: () => { lutKeyboardNavIndex = -1; } },
    },
  });
  
  async function openAssetBrowser(mode, callback) {
    assetBrowserMode = mode;
    assetBrowserCallback = callback;
    selectedAsset = null;
    assetBrowserSelect.disabled = true;
    currentMediaPage = 0; // Reset pagination
    
    assetBrowserShell.open(mode);

    // Bind filter tab clicks
    assetFilterTabs.querySelectorAll('.asset-filter-tab').forEach(tab => {
      tab.addEventListener('click', () => {
        assetFilterTabs.querySelectorAll('.asset-filter-tab').forEach(t => t.classList.remove('active'));
        tab.classList.add('active');
        currentAssetFilter = tab.dataset.filter;
        currentMediaPage = 0; // Reset to first page when filter changes
        renderAssetGrid();
      });
    });
    
    // Scene search input handler
    const sceneSearchInput = document.getElementById('sceneSearchInput');
    if (sceneSearchInput) {
      sceneSearchInput.addEventListener('input', () => {
        renderAssetGrid();
      });
    }
    
    // Shader search input handler
    const shaderSearchInput = document.getElementById('shaderSearchInput');
    if (shaderSearchInput) {
      shaderSearchInput.addEventListener('input', () => {
        renderAssetGrid();
      });
    }
    
    // Media search input handler
    const mediaSearchInput = document.getElementById('mediaSearchInput');
    if (mediaSearchInput) {
      mediaSearchInput.addEventListener('input', () => {
        currentMediaPage = 0; // Reset to first page when searching
        renderAssetGrid();
      });
    }
    
    currentAssetFilter = 'all';
    assetBrowserModal.classList.add('show');
    
    // Load assets
    await loadAssets();
  }
  
  function closeAssetBrowser(cancelled = true) {
    assetBrowserShell.close();
    // Call callback with null if cancelled (so caller knows user cancelled)
    if (cancelled && assetBrowserCallback) {
      assetBrowserCallback(null);
    }
    assetBrowserCallback = null;
    selectedAsset = null;
  }
  
  // =========================
  // POST FX PREVIEW SYSTEM
  // =========================
  
  function initPostFxPreview() {
    postfxPreviewCanvas = document.getElementById('postfxPreviewCanvas');
    if (!postfxPreviewCanvas) return;
    
    // Match canvas size to viewport aspect ratio
    const aspectRatio = threeCanvas.width / threeCanvas.height;
    const previewHeight = 360;
    const previewWidth = Math.round(previewHeight * aspectRatio);
    postfxPreviewCanvas.width = previewWidth;
    postfxPreviewCanvas.height = previewHeight;
    
    postfxPreviewCtx = postfxPreviewCanvas.getContext('2d');
    postfxPreviewShader = null;
    
    // Reset effect name
    const effectNameEl = document.getElementById('postfxPreviewEffectName');
    if (effectNameEl) effectNameEl.textContent = 'No effect selected';
    
    // Start the preview loop
    startPostFxPreview();
  }
  
  function startPostFxPreview() {
    if (postfxPreviewAnimationId) return; // Already running
    
    function loop() {
      if (!assetBrowserModal.classList.contains('show') || assetBrowserMode !== 'postfx') {
        stopPostFxPreview();
        return;
      }
      updatePostFxPreview();
      postfxPreviewAnimationId = requestAnimationFrame(loop);
    }
    postfxPreviewAnimationId = requestAnimationFrame(loop);
  }
  
  function stopPostFxPreview() {
    if (postfxPreviewAnimationId) {
      cancelAnimationFrame(postfxPreviewAnimationId);
      postfxPreviewAnimationId = null;
    }
    postfxPreviewShader = null;
  }
  
  function updatePostFxPreview() {
    if (!postfxPreviewCanvas || !postfxPreviewCtx) return;

    const t = getCurrentTime();

    // Render the current scene to the main render target
    if (renderTargetA) {
      renderUnifiedLayers(t, renderTargetA, {
        shaderPass: postfxPreviewShader ? 'affected' : 'all',
      });
    }

    // If we have a preview shader, apply it
    if (postfxPreviewShader && renderTargetA) {
      // Composite "affected" media into render target BEFORE post-fx processing
      const allMediaPostFx = findActiveImages(t);
      const allTextPostFx = findActiveTextSegments(t);
      const hasAffectedMediaPostFx = allMediaPostFx.some(({ seg }) => !seg.ignorePostFx);
      const hasAffectedTextPostFx = allTextPostFx.some(({ seg }) => !seg.ignorePostFx);

      if ((hasAffectedMediaPostFx || hasAffectedTextPostFx) && mediaOverlayMaterial) {
        // Render scene to screen for blend mode reference
        renderer.setRenderTarget(null);
        compositeSceneTexture(renderTargetA.texture, 1.0, null);

        // Render affected media and text to overlay canvas
        const imagesRenderedPostFx = renderImageOverlays(t, 'affected', threeCanvas);
        renderTextOverlays(t, 'affected', imagesRenderedPostFx);

        // Composite overlay onto renderTargetA
        mediaOverlayTexture.needsUpdate = true;
        mediaOverlayMaterial.uniforms.uBase.value = renderTargetA.texture;
        mediaOverlayMaterial.uniforms.uOverlay.value = mediaOverlayTexture;

        postFxScene.add(mediaOverlayQuad);
        postFxQuad.visible = false;
        mediaOverlayQuad.visible = true;
        renderer.setRenderTarget(renderTargetB);
        renderer.render(postFxScene, postFxCamera);
        mediaOverlayQuad.visible = false;
        postFxQuad.visible = true;
        postFxScene.remove(mediaOverlayQuad);

        [renderTargetA, renderTargetB] = [renderTargetB, renderTargetA];
      }

      // Now apply post-fx (media is now included in renderTargetA)
      applyPreviewPostFx(t, postfxPreviewShader);

      // Shader details configured to bypass post-fx are composited last.
      renderIgnoredShaderLayers(t);

      // Render "ignored" media on top (not affected by post-fx)
      renderImageOverlays(t, 'ignored', threeCanvas);
    } else if (renderTargetA) {
      // No effect selected - composite scene to screen
      compositeSceneTexture(renderTargetA.texture, 1.0, null, null);

      // Render all media overlays on top
      renderImageOverlays(t, 'all', threeCanvas);
    }

    // Copy WebGL canvas to preview
    if (threeCanvas && postfxPreviewCtx) {
      postfxPreviewCtx.drawImage(
        threeCanvas,
        0, 0, threeCanvas.width, threeCanvas.height,
        0, 0, postfxPreviewCanvas.width, postfxPreviewCanvas.height
      );
    }

    // Composite the media overlay canvas on top
    if (imageOverlayCanvas && postfxPreviewCtx) {
      postfxPreviewCtx.drawImage(
        imageOverlayCanvas,
        0, 0, imageOverlayCanvas.width, imageOverlayCanvas.height,
        0, 0, postfxPreviewCanvas.width, postfxPreviewCanvas.height
      );
    }
  }
  
  function applyPreviewPostFx(t, shader) {
    if (!shader || !renderTargetA) return;
    
    // Create a temporary segment for the shader
    const tempSeg = {
      uniforms: {},
      shaderFile: shader.path
    };
    
    // Create material for the preview shader
    const material = createPostFxMaterial(shader, tempSeg);
    if (!material) return;
    
    // Set up uniforms
    material.uniforms.uTexture.value = renderTargetA.texture;
    material.uniforms.uTime.value = t;
    material.uniforms.uResolution.value.set(renderTargetA.width, renderTargetA.height);
    material.uniforms.uIntensity.value = 1.0;
    if (material.uniforms.uOpacity) material.uniforms.uOpacity.value = 1.0;
    
    // Set default values for common uniforms
    if (material.uniforms.uBloomStrength) material.uniforms.uBloomStrength.value = 0.6;
    if (material.uniforms.uBloomThreshold) material.uniforms.uBloomThreshold.value = 0.3;
    if (material.uniforms.uVignetteStrength) material.uniforms.uVignetteStrength.value = 0.5;
    if (material.uniforms.uChromaticAberration) material.uniforms.uChromaticAberration.value = 0.003;
    if (material.uniforms.uSaturation) material.uniforms.uSaturation.value = 1.0;
    if (material.uniforms.uContrast) material.uniforms.uContrast.value = 1.0;
    if (material.uniforms.uBrightness) material.uniforms.uBrightness.value = 1.0;
    
    // Set custom uniforms to defaults
    const shaderParams = shader.params || [];
    for (const param of shaderParams) {
      if (material.uniforms[param.uniform]) {
        material.uniforms[param.uniform].value = param.default;
      }
    }
    
    // Render to screen
    postFxQuad.material = material;
    renderer.setRenderTarget(null);
    renderer.render(postFxScene, postFxCamera);
  }
  
  async function loadPostFxForPreview(assetPath) {
    const loadingEl = document.getElementById('postfxPreviewLoading');
    const effectNameEl = document.getElementById('postfxPreviewEffectName');
    
    if (!assetPath) {
      postfxPreviewShader = null;
      if (effectNameEl) effectNameEl.textContent = 'No effect selected';
      return;
    }
    
    // Show loading
    if (loadingEl) loadingEl.classList.remove('hidden');
    
    try {
      // Check if already loaded
      if (loadedPostFxShaders[assetPath]) {
        postfxPreviewShader = loadedPostFxShaders[assetPath];
        postfxPreviewShader.path = assetPath;
      } else {
        // Load the shader
        const response = await fetch('/' + assetPath);
        if (!response.ok) throw new Error('Failed to load shader');
        const source = await response.text();
        
        // Parse params from source (returns { effectName, params, textureParams })
        const { effectName, params, textureParams } = parseShaderParams(source);
        
        postfxPreviewShader = {
          source,
          params: params || [],
          textureParams: textureParams || [],
          effectName,
          path: assetPath,
          threeMaterial: null
        };
        
        // Cache it
        loadedPostFxShaders[assetPath] = postfxPreviewShader;
      }
      
      // Update effect name
      const fileName = assetPath.split('/').pop().replace('.glsl', '');
      const displayName = fileName.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
      if (effectNameEl) effectNameEl.textContent = displayName;
      
    } catch (err) {
      console.error('[PostFX Preview] Failed to load shader:', err);
      postfxPreviewShader = null;
      if (effectNameEl) effectNameEl.textContent = 'Failed to load';
    } finally {
      if (loadingEl) loadingEl.classList.add('hidden');
    }
  }
  
  // Keyboard navigation for postfx browser
  function handlePostFxKeyNav(e) {
    if (assetBrowserMode !== 'postfx' || !assetBrowserModal.classList.contains('show')) return;
    
    const grid = document.getElementById('postfxAssetGrid');
    if (!grid) return;
    
    const items = grid.querySelectorAll('.asset-item');
    
    // Handle Enter key even if no items (as long as we have a selected asset)
    if (e.key === 'Enter') {
      e.preventDefault();
      if (selectedAsset) {
        const callback = assetBrowserCallback;
        const asset = selectedAsset; // Save asset before closeAssetBrowser clears it
        closeAssetBrowser(false);
        if (callback) callback(asset);
      }
      return;
    }
    
    // Handle Escape regardless of items
    if (e.key === 'Escape') {
      e.preventDefault();
      closeAssetBrowser(true);
      return;
    }
    
    // For arrow navigation, we need items
    if (items.length === 0) return;
    
    if (e.key === 'ArrowDown') {
      e.preventDefault();
      postfxKeyboardNavIndex = Math.min(postfxKeyboardNavIndex + 1, items.length - 1);
      selectPostFxItemByIndex(postfxKeyboardNavIndex, items);
    } else if (e.key === 'ArrowUp') {
      e.preventDefault();
      postfxKeyboardNavIndex = Math.max(postfxKeyboardNavIndex - 1, 0);
      selectPostFxItemByIndex(postfxKeyboardNavIndex, items);
    }
  }
  
  function selectPostFxItemByIndex(index, items) {
    if (!items || items.length === 0) return;
    
    // Remove previous selection
    items.forEach(item => {
      item.classList.remove('selected');
      item.classList.remove('keyboard-focus');
    });
    
    // Select new item
    const item = items[index];
    if (item) {
      item.classList.add('selected');
      item.classList.add('keyboard-focus');
      item.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
      
      // Update selectedAsset
      selectedAsset = {
        path: item.dataset.path,
        name: item.dataset.name
      };
      assetBrowserSelect.disabled = false;
      
      // Load shader for preview
      loadPostFxForPreview(item.dataset.path);
    }
  }
  
  // Add keyboard listener for postfx navigation
  document.addEventListener('keydown', handlePostFxKeyNav);
  
  // =========================
  // SHADER PREVIEW SYSTEM
  // =========================
  
  function initShaderPreview() {
    shaderPreviewCanvas = document.getElementById('shaderPreviewCanvas');
    if (!shaderPreviewCanvas) return;
    
    // Match canvas size to viewport aspect ratio
    const aspectRatio = threeCanvas.width / threeCanvas.height;
    const previewHeight = 360;
    const previewWidth = Math.round(previewHeight * aspectRatio);
    shaderPreviewCanvas.width = previewWidth;
    shaderPreviewCanvas.height = previewHeight;
    
    shaderPreviewCtx = shaderPreviewCanvas.getContext('2d');
    shaderPreviewShader = null;
    
    // Reset effect name
    const effectNameEl = document.getElementById('shaderPreviewName');
    if (effectNameEl) effectNameEl.textContent = 'No shader selected';
    
    // Start the preview loop
    startShaderPreview();
  }
  
  function startShaderPreview() {
    if (shaderPreviewAnimationId) return; // Already running
    
    function loop() {
      if (!assetBrowserModal.classList.contains('show') || assetBrowserMode !== 'shader') {
        stopShaderPreview();
        return;
      }
      updateShaderPreview();
      shaderPreviewAnimationId = requestAnimationFrame(loop);
    }
    shaderPreviewAnimationId = requestAnimationFrame(loop);
  }
  
  function stopShaderPreview() {
    if (shaderPreviewAnimationId) {
      cancelAnimationFrame(shaderPreviewAnimationId);
      shaderPreviewAnimationId = null;
    }
    shaderPreviewShader = null;
  }
  
  function updateShaderPreview() {
    if (!shaderPreviewCanvas || !shaderPreviewCtx) return;
    
    const t = getCurrentTime();
    
    // If we have a shader to preview, render it
    if (shaderPreviewShader) {
      renderShaderPreview(t, shaderPreviewShader);
    } else {
      // Just show the current scene without shader
      renderUnifiedLayers(t, null);
    }
    
    // Copy the result to the preview canvas
    if (threeCanvas && shaderPreviewCtx) {
      shaderPreviewCtx.drawImage(
        threeCanvas,
        0, 0, threeCanvas.width, threeCanvas.height,
        0, 0, shaderPreviewCanvas.width, shaderPreviewCanvas.height
      );
    }
  }
  
  function renderShaderPreview(t, shader) {
    if (!shader || !shader.source) return;
    
    // Ensure shader overlay scene exists
    ensureShaderOverlayScene();
    
    // Create material if not cached
    if (!shader.material) {
      const fragmentShader = `
        precision highp float;
        
        uniform float iTime;
        uniform float iSceneTime;
        uniform vec3 iResolution;
        uniform float iSceneStart;
        uniform float iSceneEnd;
        uniform sampler2D iChannel0;
        uniform float uOpacity;
        
        uniform float iBass;
        uniform float iMid;
        uniform float iTreble;
        uniform float iVolume;
        
        ${(shader.params || []).map(p => `uniform float ${p.uniform};`).join('\n')}
        
        varying vec2 vUv;
        
        ${shader.source}
        
        void main() {
          vec2 fragCoord = vUv * iResolution.xy;
          vec4 outColor;
          mainImage(outColor, fragCoord);
          outColor.a *= uOpacity;
          gl_FragColor = outColor;
        }
      `;
      
      const vertexShader = `
        varying vec2 vUv;
        void main() {
          vUv = uv;
          gl_Position = vec4(position, 1.0);
        }
      `;
      
      const uniforms = {
        iTime: { value: 0 },
        iSceneTime: { value: 0 },
        iResolution: { value: new THREE.Vector3() },
        iSceneStart: { value: 0 },
        iSceneEnd: { value: 10 },
        iChannel0: { value: null },
        uOpacity: { value: 1.0 },
        iBass: { value: 0 },
        iMid: { value: 0 },
        iTreble: { value: 0 },
        iVolume: { value: 0 }
      };
      
      for (const param of shader.params || []) {
        uniforms[param.uniform] = { value: param.default };
      }
      
      try {
        shader.material = new THREE.ShaderMaterial({
          vertexShader,
          fragmentShader,
          uniforms,
          transparent: true,
          depthTest: false,
          depthWrite: false
        });
      } catch (e) {
        console.error('Failed to create shader preview material:', e);
        return;
      }
    }
    
    // Update uniforms
    const material = shader.material;
    material.uniforms.iTime.value = t;
    material.uniforms.iSceneTime.value = t;
    material.uniforms.iResolution.value.set(threeCanvas.width, threeCanvas.height, 1);
    material.uniforms.uOpacity.value = 1.0;
    
    // Set params to defaults
    for (const param of shader.params || []) {
      if (material.uniforms[param.uniform]) {
        material.uniforms[param.uniform].value = param.default;
      }
    }
    
    // Render the shader as full-screen
    window.shaderOverlayQuad.material = material;
    renderer.setRenderTarget(null);
    renderer.clear();
    renderer.render(window.shaderOverlayScene, window.shaderOverlayCamera);
  }
  
  async function loadShaderForPreview(assetPath) {
    const loadingEl = document.getElementById('shaderPreviewLoading');
    const effectNameEl = document.getElementById('shaderPreviewName');
    
    if (!assetPath) {
      shaderPreviewShader = null;
      if (effectNameEl) effectNameEl.textContent = 'No shader selected';
      return;
    }
    
    // Show loading
    if (loadingEl) loadingEl.classList.remove('hidden');
    
    try {
      // Check if already loaded
      if (loadedShaders[assetPath]) {
        shaderPreviewShader = { ...loadedShaders[assetPath], path: assetPath, material: null };
      } else {
        // Load the shader
        const response = await fetch('/' + assetPath);
        if (!response.ok) throw new Error('Failed to load shader');
        const source = await response.text();
        
        // Parse params from source
        const { effectName, params, textureParams } = parseShaderParams(source);
        
        shaderPreviewShader = {
          source,
          params: params || [],
          textureParams: textureParams || [],
          effectName,
          path: assetPath,
          material: null
        };
        
        // Cache it
        loadedShaders[assetPath] = {
          source,
          params: params || [],
          textureParams: textureParams || [],
          effectName: effectName || assetPath.split('/').pop().replace('.glsl', ''),
          material: null
        };
      }
      
      // Update effect name
      const fileName = assetPath.split('/').pop().replace('.glsl', '');
      const displayName = fileName.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
      if (effectNameEl) effectNameEl.textContent = displayName;
      
    } catch (err) {
      console.error('[Shader Preview] Failed to load shader:', err);
      shaderPreviewShader = null;
      if (effectNameEl) effectNameEl.textContent = 'Failed to load';
    } finally {
      if (loadingEl) loadingEl.classList.add('hidden');
    }
  }
  
  // Keyboard navigation for shader browser
  function handleShaderKeyNav(e) {
    if (assetBrowserMode !== 'shader' || !assetBrowserModal.classList.contains('show')) return;
    
    const grid = document.getElementById('shaderPreviewAssetGrid');
    if (!grid) return;
    
    const items = grid.querySelectorAll('.asset-item');
    
    // Handle Enter key even if no items (as long as we have a selected asset)
    if (e.key === 'Enter') {
      e.preventDefault();
      if (selectedAsset) {
        const callback = assetBrowserCallback;
        const asset = selectedAsset; // Save asset before closeAssetBrowser clears it
        closeAssetBrowser(false);
        if (callback) callback(asset);
      }
      return;
    }
    
    // Handle Escape regardless of items
    if (e.key === 'Escape') {
      e.preventDefault();
      closeAssetBrowser(true);
      return;
    }
    
    // For arrow navigation, we need items
    if (items.length === 0) return;
    
    if (e.key === 'ArrowDown') {
      e.preventDefault();
      shaderKeyboardNavIndex = Math.min(shaderKeyboardNavIndex + 1, items.length - 1);
      selectShaderItemByIndex(shaderKeyboardNavIndex, items);
    } else if (e.key === 'ArrowUp') {
      e.preventDefault();
      shaderKeyboardNavIndex = Math.max(shaderKeyboardNavIndex - 1, 0);
      selectShaderItemByIndex(shaderKeyboardNavIndex, items);
    }
  }
  
  function selectShaderItemByIndex(index, items) {
    if (!items || items.length === 0) return;
    
    items.forEach(item => {
      item.classList.remove('selected');
      item.classList.remove('keyboard-focus');
    });
    
    const item = items[index];
    if (item) {
      item.classList.add('selected');
      item.classList.add('keyboard-focus');
      item.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
      
      selectedAsset = {
        path: item.dataset.path,
        name: item.dataset.name
      };
      assetBrowserSelect.disabled = false;
      
      loadShaderForPreview(item.dataset.path);
    }
  }
  
  document.addEventListener('keydown', handleShaderKeyNav);
  
  // =========================
  // SCENE PREVIEW SYSTEM
  // =========================
  
  function initScenePreview() {
    scenePreviewCanvas = document.getElementById('scenePreviewCanvas');
    if (!scenePreviewCanvas) return;
    
    // Match canvas size to viewport aspect ratio
    const aspectRatio = threeCanvas.width / threeCanvas.height;
    const previewHeight = 360;
    const previewWidth = Math.round(previewHeight * aspectRatio);
    scenePreviewCanvas.width = previewWidth;
    scenePreviewCanvas.height = previewHeight;
    
    // Use 2D canvas to copy rendered result
    scenePreviewCtx = scenePreviewCanvas.getContext('2d');
    scenePreviewSceneObj = null;
    scenePreviewStartTime = performance.now() / 1000;
    
    // Reset preview name
    const nameEl = document.getElementById('scenePreviewName');
    if (nameEl) nameEl.textContent = 'No scene selected';
    
    // Start the preview loop
    startScenePreview();
  }
  
  function startScenePreview() {
    if (scenePreviewAnimationId) return; // Already running
    
    function loop() {
      if (!assetBrowserModal.classList.contains('show') || assetBrowserMode !== 'scenes') {
        stopScenePreview();
        return;
      }
      updateScenePreview();
      scenePreviewAnimationId = requestAnimationFrame(loop);
    }
    scenePreviewAnimationId = requestAnimationFrame(loop);
  }
  
  function stopScenePreview() {
    if (scenePreviewAnimationId) {
      cancelAnimationFrame(scenePreviewAnimationId);
      scenePreviewAnimationId = null;
    }
    // Properly dispose of the preview scene to free memory
    disposeScenePreview();
  }
  
  function updateScenePreview() {
    if (!scenePreviewCanvas || !scenePreviewCtx) return;
    
    const w = scenePreviewCanvas.width;
    const h = scenePreviewCanvas.height;
    
    if (scenePreviewSceneObj && scenePreviewSceneObj.threeScene && scenePreviewSceneObj.camera) {
      const sceneObj = scenePreviewSceneObj;
      const { threeScene, camera, data } = sceneObj;
      
      // Calculate scene time (time since preview started)
      const sceneTime = performance.now() / 1000 - scenePreviewStartTime;
      
      // Animate scene objects using the same animation function as the main editor
      animateScene(threeScene, data, sceneTime, sceneTime);
      
      // Animate camera
      if (data.camera?.animation) {
        animateCamera(camera, data.camera, sceneTime, sceneTime);
      }
      
      // Animate lights
      if (sceneObj.animatedLights) {
        animateLights(sceneObj.animatedLights, sceneTime, sceneTime);
      }
      
      animateSceneSystems(sceneObj, sceneTime, { viewportHeight: h });
      
      // Render the scene to the screen
      renderer.setRenderTarget(null);
      renderer.render(threeScene, camera);
      
      // Copy to preview canvas
      scenePreviewCtx.drawImage(
        threeCanvas,
        0, 0, threeCanvas.width, threeCanvas.height,
        0, 0, w, h
      );
      
    } else {
      // No scene selected - show placeholder
      scenePreviewCtx.fillStyle = '#111111';
      scenePreviewCtx.fillRect(0, 0, w, h);
      scenePreviewCtx.fillStyle = '#666666';
      scenePreviewCtx.font = '14px sans-serif';
      scenePreviewCtx.textAlign = 'center';
      scenePreviewCtx.fillText('Select a scene to preview', w / 2, h / 2);
    }
  }
  
  async function loadSceneForPreview(assetPath) {
    const loadingEl = document.getElementById('scenePreviewLoading');
    const nameEl = document.getElementById('scenePreviewName');
    
    if (!assetPath) {
      // Dispose previous scene
      disposeScenePreview();
      if (nameEl) nameEl.textContent = 'No scene selected';
      return;
    }
    
    // Show loading
    if (loadingEl) loadingEl.classList.remove('hidden');
    
    try {
      // Dispose previous scene first
      disposeScenePreview();
      
      // Load the scene JSON
      const response = await fetch('/' + assetPath);
      if (!response.ok) throw new Error('Failed to load scene');
      const sceneJson = await response.json();
      
      console.log('[Scene Preview] Loading scene:', assetPath, {
        name: sceneJson.name,
        hasCloner: !!sceneJson.sphereCloner?.enabled,
        hasCrystal: !!sceneJson.crystalLattice?.enabled,
        hasVoxel: !!sceneJson.voxelTunnel?.enabled,
        hasParticles: !!sceneJson.particles?.enabled
      });
      
      // Create the actual Three.js scene using the same function as the main editor
      scenePreviewSceneObj = await createThreeScene(sceneJson);
      scenePreviewStartTime = performance.now() / 1000;
      
      // Update display name
      const displayName = sceneJson.name || assetPath.split('/').pop().replace('.json', '').replace(/_/g, ' ');
      if (nameEl) nameEl.textContent = displayName;
      
      console.log('[Scene Preview] Scene created successfully');
      
    } catch (err) {
      console.error('[Scene Preview] Failed to load scene:', err);
      scenePreviewSceneObj = null;
      if (nameEl) nameEl.textContent = 'Failed to load';
    } finally {
      if (loadingEl) loadingEl.classList.add('hidden');
    }
  }
  
  // Properly dispose of a preview scene to free memory
  function disposeScenePreview() {
    if (!scenePreviewSceneObj) return;
    
    const sceneObj = scenePreviewSceneObj;
    
    disposeSceneSystems(sceneObj);
    
    // Dispose of all objects in the scene
    disposeObjectTree(sceneObj.threeScene, { releaseMixer: object => animationMixers.release(object) });
    
    scenePreviewSceneObj = null;
  }
  
  // Keyboard navigation for scene browser
  function handleSceneKeyNav(e) {
    if (assetBrowserMode !== 'scenes' || !assetBrowserModal.classList.contains('show')) return;
    
    const grid = document.getElementById('scenePreviewAssetGrid');
    if (!grid) return;
    
    const items = grid.querySelectorAll('.asset-item');
    
    // Handle Enter key even if no items (as long as we have a selected asset)
    if (e.key === 'Enter') {
      e.preventDefault();
      if (selectedAsset) {
        const callback = assetBrowserCallback;
        const asset = selectedAsset; // Save asset before closeAssetBrowser clears it
        closeAssetBrowser(false);
        if (callback) callback(asset);
      }
      return;
    }
    
    // Handle Escape regardless of items
    if (e.key === 'Escape') {
      e.preventDefault();
      closeAssetBrowser(true);
      return;
    }
    
    // For arrow navigation, we need items
    if (items.length === 0) return;
    
    if (e.key === 'ArrowDown') {
      e.preventDefault();
      sceneKeyboardNavIndex = Math.min(sceneKeyboardNavIndex + 1, items.length - 1);
      selectSceneItemByIndex(sceneKeyboardNavIndex, items);
    } else if (e.key === 'ArrowUp') {
      e.preventDefault();
      sceneKeyboardNavIndex = Math.max(sceneKeyboardNavIndex - 1, 0);
      selectSceneItemByIndex(sceneKeyboardNavIndex, items);
    }
  }
  
  function selectSceneItemByIndex(index, items) {
    if (!items || items.length === 0) return;
    
    items.forEach(item => {
      item.classList.remove('selected');
      item.classList.remove('keyboard-focus');
    });
    
    const item = items[index];
    if (item) {
      item.classList.add('selected');
      item.classList.add('keyboard-focus');
      item.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
      
      selectedAsset = {
        path: item.dataset.path,
        name: item.dataset.name
      };
      assetBrowserSelect.disabled = false;
      
      loadSceneForPreview(item.dataset.path);
    }
  }
  
  document.addEventListener('keydown', handleSceneKeyNav);
  
  // =========================
  // AUDIO PREVIEW SYSTEM
  // =========================
  
  function initAudioPreview() {
    audioPreviewCanvas = document.getElementById('audioPreviewCanvas');
    if (!audioPreviewCanvas) return;
    
    audioPreviewCtx = audioPreviewCanvas.getContext('2d');
    
    // Reset audio preview name
    const nameEl = document.getElementById('audioPreviewName');
    if (nameEl) nameEl.textContent = 'No audio selected';
    
    // Reset play button
    const playBtn = document.getElementById('audioPreviewPlayBtn');
    if (playBtn) {
      playBtn.classList.remove('playing');
      const icon = document.getElementById('audioPreviewPlayIcon');
      if (icon) icon.textContent = '▶';
    }
    
    // Reset time displays
    const timeStart = document.getElementById('audioPreviewTimeStart');
    const timeEnd = document.getElementById('audioPreviewTimeEnd');
    if (timeStart) timeStart.textContent = '0:00';
    if (timeEnd) timeEnd.textContent = '0:00';
    
    // Mute the timeline audio if playing
    if (silentPlaying) {
      timelineWasPlaying = true;
      silentPlaying = false;
      audioClipPlayback?.pauseAll();
    } else {
      timelineWasPlaying = false;
    }
    if (audioEl) {
      timelineWasMuted = audioEl.muted;
      audioEl.muted = true;
    }
    
    // Set up play button handler
    const playBtnEl = document.getElementById('audioPreviewPlayBtn');
    if (playBtnEl) {
      playBtnEl.onclick = toggleAudioPreviewPlayback;
    }
    
    // Start the preview loop (for VU meter animation)
    startAudioPreview();
  }
  
  function startAudioPreview() {
    if (audioPreviewAnimationId) return; // Already running
    
    function loop() {
      if (!assetBrowserModal.classList.contains('show') || assetBrowserMode !== 'audio') {
        stopAudioPreview();
        return;
      }
      updateAudioPreview();
      audioPreviewAnimationId = requestAnimationFrame(loop);
    }
    audioPreviewAnimationId = requestAnimationFrame(loop);
  }
  
  function stopAudioPreview() {
    if (audioPreviewAnimationId) {
      cancelAnimationFrame(audioPreviewAnimationId);
      audioPreviewAnimationId = null;
    }
    
    // Stop and clean up audio
    if (audioPreviewAudio) {
      audioPreviewAudio.pause();
      audioPreviewAudio.src = '';
      audioPreviewAudio = null;
    }
    
    // Disconnect analyser
    if (audioPreviewSource) {
      try {
        audioPreviewSource.disconnect();
      } catch (e) { /* ignore */ }
      audioPreviewSource = null;
    }
    
    // Close audio context
    if (audioPreviewAudioCtx && audioPreviewAudioCtx.state !== 'closed') {
      audioPreviewAudioCtx.close().catch(() => {});
      audioPreviewAudioCtx = null;
    }
    
    audioPreviewAnalyser = null;
    audioPreviewDataArray = null;
    
    // Restore timeline audio state
    if (audioEl) {
      audioEl.muted = timelineWasMuted;
      if (timelineWasPlaying) {
        silentPlaying = true;
        audioClipPlayback?.sync(timelineTracks, silentTime, true);
      }
    }
  }
  
  function updateAudioPreview() {
    if (!audioPreviewCanvas || !audioPreviewCtx) return;
    
    const ctx = audioPreviewCtx;
    const w = audioPreviewCanvas.width;
    const h = audioPreviewCanvas.height;
    const centerX = w / 2;
    const centerY = h / 2;
    const time = performance.now() / 1000;
    
    // Clear with dark background
    ctx.fillStyle = '#050508';
    ctx.fillRect(0, 0, w, h);
    
    // Draw background circles (subtle)
    ctx.strokeStyle = 'rgba(255, 107, 157, 0.05)';
    ctx.lineWidth = 1;
    for (let r = 50; r < 200; r += 30) {
      ctx.beginPath();
      ctx.arc(centerX, centerY, r, 0, Math.PI * 2);
      ctx.stroke();
    }
    
    // Get frequency data
    let freqData = null;
    if (audioPreviewAnalyser && audioPreviewDataArray) {
      audioPreviewAnalyser.getByteFrequencyData(audioPreviewDataArray);
      freqData = audioPreviewDataArray;
    }
    
    // Number of bars and radius setup
    const numBars = 64;
    const innerRadius = 60;
    const maxBarHeight = 100;
    
    // Color palette for different frequencies (low to high)
    const colors = [
      '#ff1744', // Red (bass)
      '#ff4081', // Pink
      '#e040fb', // Purple
      '#7c4dff', // Deep Purple
      '#536dfe', // Indigo
      '#448aff', // Blue
      '#40c4ff', // Light Blue
      '#64ffda', // Cyan/Teal
      '#69f0ae', // Green
      '#b2ff59', // Light Green
      '#eeff41', // Yellow
      '#ffd740', // Amber
      '#ffab40', // Orange
      '#ff6e40', // Deep Orange
    ];
    
    for (let i = 0; i < numBars; i++) {
      const angle = (i / numBars) * Math.PI * 2 - Math.PI / 2; // Start from top
      
      // Get frequency value (or animated placeholder if no audio)
      let value;
      if (freqData && freqData.length > 0) {
        const freqIndex = Math.floor((i / numBars) * freqData.length);
        value = freqData[freqIndex] / 255;
      } else {
        // Animated placeholder when no audio
        value = (Math.sin(time * 2 + i * 0.3) * 0.3 + 0.3) * 0.5;
      }
      
      const barHeight = value * maxBarHeight + 5; // Minimum 5px height
      
      // Calculate bar positions
      const x1 = centerX + Math.cos(angle) * innerRadius;
      const y1 = centerY + Math.sin(angle) * innerRadius;
      const x2 = centerX + Math.cos(angle) * (innerRadius + barHeight);
      const y2 = centerY + Math.sin(angle) * (innerRadius + barHeight);
      
      // Select color based on position (frequency range)
      const colorIndex = Math.floor((i / numBars) * colors.length);
      const color = colors[colorIndex % colors.length];
      
      // Draw bar
      ctx.strokeStyle = color;
      ctx.lineWidth = 4;
      ctx.lineCap = 'round';
      ctx.beginPath();
      ctx.moveTo(x1, y1);
      ctx.lineTo(x2, y2);
      ctx.stroke();
      
      // Add glow effect for high values
      if (value > 0.6) {
        ctx.strokeStyle = color + '40'; // Add transparency
        ctx.lineWidth = 10;
        ctx.beginPath();
        ctx.moveTo(x1, y1);
        ctx.lineTo(x2, y2);
        ctx.stroke();
      }
      
      // Mirror bar on inner side for symmetry (smaller)
      const innerBarHeight = barHeight * 0.3;
      const x3 = centerX + Math.cos(angle) * (innerRadius - innerBarHeight);
      const y3 = centerY + Math.sin(angle) * (innerRadius - innerBarHeight);
      
      ctx.strokeStyle = color;
      ctx.lineWidth = 2;
      ctx.lineCap = 'round';
      ctx.beginPath();
      ctx.moveTo(x1, y1);
      ctx.lineTo(x3, y3);
      ctx.stroke();
    }
    
    // Draw center circle (decorative)
    const pulse = freqData ? 
      (freqData[0] + freqData[1] + freqData[2] + freqData[3]) / 4 / 255 : 
      Math.sin(time * 3) * 0.3 + 0.5;
    
    ctx.beginPath();
    ctx.arc(centerX, centerY, 25 + pulse * 15, 0, Math.PI * 2);
    ctx.fillStyle = `rgba(255, 107, 157, ${0.1 + pulse * 0.2})`;
    ctx.fill();
    
    ctx.beginPath();
    ctx.arc(centerX, centerY, 20 + pulse * 10, 0, Math.PI * 2);
    ctx.fillStyle = `rgba(255, 107, 157, ${0.2 + pulse * 0.3})`;
    ctx.fill();
    
    // Update time display if playing
    if (audioPreviewAudio) {
      const timeStart = document.getElementById('audioPreviewTimeStart');
      const timeEnd = document.getElementById('audioPreviewTimeEnd');
      if (timeStart) timeStart.textContent = formatTime(audioPreviewAudio.currentTime);
      if (timeEnd) timeEnd.textContent = formatTime(audioPreviewAudio.duration || 0);
    }
  }
  
  function toggleAudioPreviewPlayback() {
    if (!audioPreviewAudio) return;
    
    const playBtn = document.getElementById('audioPreviewPlayBtn');
    const icon = document.getElementById('audioPreviewPlayIcon');
    
    if (audioPreviewAudio.paused) {
      audioPreviewAudio.play().then(() => {
        if (playBtn) playBtn.classList.add('playing');
        if (icon) icon.textContent = '⏸';
      }).catch(err => {
        console.warn('[Audio Preview] Play failed:', err);
      });
    } else {
      audioPreviewAudio.pause();
      if (playBtn) playBtn.classList.remove('playing');
      if (icon) icon.textContent = '▶';
    }
  }
  
  async function loadAudioForPreview(assetPath, autoPlay = false) {
    const loadingEl = document.getElementById('audioPreviewLoading');
    const nameEl = document.getElementById('audioPreviewName');
    const playBtn = document.getElementById('audioPreviewPlayBtn');
    const icon = document.getElementById('audioPreviewPlayIcon');
    const timeStart = document.getElementById('audioPreviewTimeStart');
    const timeEnd = document.getElementById('audioPreviewTimeEnd');
    
    // Check if audio was playing before we stop it
    const wasPlaying = audioPreviewAudio && !audioPreviewAudio.paused;
    const shouldAutoPlay = autoPlay || wasPlaying;
    
    // Stop any existing playback
    if (audioPreviewAudio) {
      audioPreviewAudio.pause();
      audioPreviewAudio.src = '';
    }
    
    // Disconnect any existing audio nodes
    if (audioPreviewSource) {
      try { audioPreviewSource.disconnect(); } catch (e) { /* ignore */ }
      audioPreviewSource = null;
    }
    
    // Reset UI
    if (playBtn) playBtn.classList.remove('playing');
    if (icon) icon.textContent = '▶';
    if (timeStart) timeStart.textContent = '0:00';
    if (timeEnd) timeEnd.textContent = '0:00';
    
    if (!assetPath) {
      if (nameEl) nameEl.textContent = 'No audio selected';
      audioPreviewAnalyser = null;
      audioPreviewDataArray = null;
      return;
    }
    
    // Show loading
    if (loadingEl) loadingEl.classList.remove('hidden');
    
    try {
      // Create new audio element
      audioPreviewAudio = new Audio();
      audioPreviewAudio.crossOrigin = 'anonymous';
      audioPreviewAudio.src = '/' + assetPath;
      
      // Wait for metadata to load
      await new Promise((resolve, reject) => {
        audioPreviewAudio.addEventListener('loadedmetadata', resolve, { once: true });
        audioPreviewAudio.addEventListener('error', reject, { once: true });
      });
      
      // Create audio context and analyser
      if (!audioPreviewAudioCtx || audioPreviewAudioCtx.state === 'closed') {
        audioPreviewAudioCtx = new (window.AudioContext || window.webkitAudioContext)();
      }
      
      audioPreviewSource = audioPreviewAudioCtx.createMediaElementSource(audioPreviewAudio);
      audioPreviewAnalyser = audioPreviewAudioCtx.createAnalyser();
      audioPreviewAnalyser.fftSize = 256;
      audioPreviewAnalyser.smoothingTimeConstant = 0.7;
      
      audioPreviewSource.connect(audioPreviewAnalyser);
      audioPreviewAnalyser.connect(audioPreviewAudioCtx.destination);
      
      audioPreviewDataArray = new Uint8Array(audioPreviewAnalyser.frequencyBinCount);
      
      // Update display name
      const displayName = assetPath.split('/').pop().replace(/\.[^.]+$/, '').replace(/_/g, ' ');
      if (nameEl) nameEl.textContent = displayName;
      
      // Update duration
      if (timeEnd) timeEnd.textContent = formatTime(audioPreviewAudio.duration);
      
      // Handle ended event
      audioPreviewAudio.onended = () => {
        if (playBtn) playBtn.classList.remove('playing');
        if (icon) icon.textContent = '▶';
      };
      
      // Auto-play if previous track was playing
      if (shouldAutoPlay) {
        audioPreviewAudio.play().then(() => {
          if (playBtn) playBtn.classList.add('playing');
          if (icon) icon.textContent = '⏸';
        }).catch(err => {
          console.warn('[Audio Preview] Auto-play failed:', err);
        });
      }
      
      console.log('[Audio Preview] Loaded:', assetPath, shouldAutoPlay ? '(auto-playing)' : '');
      
    } catch (err) {
      console.error('[Audio Preview] Failed to load:', err);
      if (nameEl) nameEl.textContent = 'Failed to load';
      audioPreviewAnalyser = null;
      audioPreviewDataArray = null;
    } finally {
      if (loadingEl) loadingEl.classList.add('hidden');
    }
  }
  
  // Keyboard navigation for audio browser
  function handleAudioKeyNav(e) {
    if (assetBrowserMode !== 'audio' || !assetBrowserModal.classList.contains('show')) return;
    
    const grid = document.getElementById('audioPreviewAssetGrid');
    if (!grid) return;
    
    const items = grid.querySelectorAll('.asset-item');
    
    // Handle Enter key even if no items (as long as we have a selected asset)
    if (e.key === 'Enter') {
      e.preventDefault();
      if (selectedAsset) {
        const callback = assetBrowserCallback;
        const asset = selectedAsset; // Save asset before closeAssetBrowser clears it
        closeAssetBrowser(false);
        if (callback) callback(asset);
      }
      return;
    }
    
    // Handle Escape regardless of items
    if (e.key === 'Escape') {
      e.preventDefault();
      closeAssetBrowser(true);
      return;
    }
    
    // For arrow navigation and space, we need items
    if (items.length === 0) return;
    
    if (e.key === 'ArrowDown') {
      e.preventDefault();
      audioKeyboardNavIndex = Math.min(audioKeyboardNavIndex + 1, items.length - 1);
      selectAudioItemByIndex(audioKeyboardNavIndex, items);
    } else if (e.key === 'ArrowUp') {
      e.preventDefault();
      audioKeyboardNavIndex = Math.max(audioKeyboardNavIndex - 1, 0);
      selectAudioItemByIndex(audioKeyboardNavIndex, items);
    } else if (e.key === ' ') {
      // Space bar - toggle playback
      e.preventDefault();
      toggleAudioPreviewPlayback();
    }
  }
  
  function selectAudioItemByIndex(index, items) {
    if (!items || items.length === 0) return;
    
    items.forEach(item => {
      item.classList.remove('selected');
      item.classList.remove('keyboard-focus');
    });
    
    const item = items[index];
    if (item) {
      item.classList.add('selected');
      item.classList.add('keyboard-focus');
      item.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
      
      selectedAsset = {
        path: item.dataset.path,
        name: item.dataset.name
      };
      assetBrowserSelect.disabled = false;
      
      loadAudioForPreview(item.dataset.path);
    }
  }
  
  document.addEventListener('keydown', handleAudioKeyNav);

  // ==========================
  // =========================
  // LUT PREVIEW SYSTEM
  // (Live preview like PostFX)
  // =========================

  let lutPreviewAnimationId = null;

  function initLUTPreview() {
    lutPreviewCanvas = document.getElementById('lutPreviewCanvas');
    if (!lutPreviewCanvas) return;

    // Match canvas size to viewport aspect ratio
    const aspectRatio = threeCanvas.width / threeCanvas.height;
    const previewHeight = 360;
    const previewWidth = Math.round(previewHeight * aspectRatio);
    lutPreviewCanvas.width = previewWidth;
    lutPreviewCanvas.height = previewHeight;

    lutPreviewCtx = lutPreviewCanvas.getContext('2d');
    lutPreviewCurrentLUT = null;
    lutKeyboardNavIndex = -1;

    // Reset LUT name
    const nameEl = document.getElementById('lutPreviewName');
    if (nameEl) nameEl.textContent = 'No LUT selected';

    // Start the preview loop
    startLUTPreview();

    console.log('[LUT Preview] Initialized with live view');
  }

  function startLUTPreview() {
    if (lutPreviewAnimationId) return; // Already running

    function loop() {
      if (!assetBrowserModal.classList.contains('show') || assetBrowserMode !== 'lut') {
        stopLUTPreview();
        return;
      }
      updateLUTPreview();
      lutPreviewAnimationId = requestAnimationFrame(loop);
    }
    lutPreviewAnimationId = requestAnimationFrame(loop);
  }

  function stopLUTPreview() {
    if (lutPreviewAnimationId) {
      cancelAnimationFrame(lutPreviewAnimationId);
      lutPreviewAnimationId = null;
    }
    lutPreviewCurrentLUT = null;
  }

  function updateLUTPreview() {
    if (!lutPreviewCanvas || !lutPreviewCtx) return;

    const t = getCurrentTime();

    // Render the current scene to the main render target
    if (renderTargetA) {
      renderUnifiedLayers(t, renderTargetA, {
        shaderPass: lutPreviewCurrentLUT ? 'affected' : 'all',
      });
    }

    // Apply preview LUT if selected
    if (lutPreviewCurrentLUT && lutPreviewCurrentLUT.texture3D && renderTargetA) {
      // Composite "affected" media into render target BEFORE LUT processing
      const allMediaPreview = findActiveImages(t);
      const allTextPreview = findActiveTextSegments(t);
      const hasAffectedMediaPreview = allMediaPreview.some(({ seg }) => !seg.ignorePostFx);
      const hasAffectedTextPreview = allTextPreview.some(({ seg }) => !seg.ignorePostFx);

      if ((hasAffectedMediaPreview || hasAffectedTextPreview) && mediaOverlayMaterial) {
        // Render scene to screen for blend mode reference
        renderer.setRenderTarget(null);
        compositeSceneTexture(renderTargetA.texture, 1.0, null);

        // Render affected media and text to overlay canvas
        const imagesRenderedPreview = renderImageOverlays(t, 'affected', threeCanvas);
        renderTextOverlays(t, 'affected', imagesRenderedPreview);

        // Composite overlay onto renderTargetA
        mediaOverlayTexture.needsUpdate = true;
        mediaOverlayMaterial.uniforms.uBase.value = renderTargetA.texture;
        mediaOverlayMaterial.uniforms.uOverlay.value = mediaOverlayTexture;

        postFxScene.add(mediaOverlayQuad);
        postFxQuad.visible = false;
        mediaOverlayQuad.visible = true;
        renderer.setRenderTarget(renderTargetB);
        renderer.render(postFxScene, postFxCamera);
        mediaOverlayQuad.visible = false;
        postFxQuad.visible = true;
        postFxScene.remove(mediaOverlayQuad);

        [renderTargetA, renderTargetB] = [renderTargetB, renderTargetA];
      }

      // Now apply LUT (media is now included in renderTargetA)
      applyPreviewLUT(t, lutPreviewCurrentLUT);

      // Shader details configured to bypass LUT/post-fx are composited last.
      renderIgnoredShaderLayers(t);

      // Render "ignored" media on top (not affected by LUT)
      renderImageOverlays(t, 'ignored', threeCanvas);
    } else if (renderTargetA) {
      // No LUT selected - composite scene to screen
      compositeSceneTexture(renderTargetA.texture, 1.0, null, null);

      // Render all media overlays on top
      renderImageOverlays(t, 'all', threeCanvas);
    }

    // Copy WebGL canvas to preview
    if (threeCanvas && lutPreviewCtx) {
      lutPreviewCtx.drawImage(
        threeCanvas,
        0, 0, threeCanvas.width, threeCanvas.height,
        0, 0, lutPreviewCanvas.width, lutPreviewCanvas.height
      );
    }

    // Composite the media overlay canvas on top
    if (imageOverlayCanvas && lutPreviewCtx) {
      lutPreviewCtx.drawImage(
        imageOverlayCanvas,
        0, 0, imageOverlayCanvas.width, imageOverlayCanvas.height,
        0, 0, lutPreviewCanvas.width, lutPreviewCanvas.height
      );
    }
  }

  function applyPreviewLUT(t, lutData) {
    if (!lutData || !lutData.texture3D || !renderTargetA) return;

    // Get or create LUT material
    const material = createLUTMaterial();
    if (!material) return;

    // Set up uniforms for preview (default values, no lift/gamma/gain adjustments)
    material.uniforms.uTexture.value = renderTargetA.texture;
    material.uniforms.uLUT.value = lutData.texture3D;
    material.uniforms.uLUTSize.value = lutData.size || 17;
    material.uniforms.uIntensity.value = 1.0;
    material.uniforms.uLiftR.value = 0.0;
    material.uniforms.uLiftG.value = 0.0;
    material.uniforms.uLiftB.value = 0.0;
    material.uniforms.uGammaR.value = 1.0;
    material.uniforms.uGammaG.value = 1.0;
    material.uniforms.uGammaB.value = 1.0;
    material.uniforms.uGainR.value = 1.0;
    material.uniforms.uGainG.value = 1.0;
    material.uniforms.uGainB.value = 1.0;

    // Render to screen
    postFxQuad.material = material;
    renderer.setRenderTarget(null);
    renderer.render(postFxScene, postFxCamera);
  }

  async function loadLUTForPreview(assetPath) {
    const loadingEl = document.getElementById('lutPreviewLoading');
    const nameEl = document.getElementById('lutPreviewName');
    const refineBtn = document.getElementById('lutRefineBtn');

    if (!assetPath) {
      if (nameEl) nameEl.textContent = 'No LUT selected';
      if (refineBtn) refineBtn.style.display = 'none';
      lutPreviewCurrentLUT = null;
      return;
    }

    // Show loading
    if (loadingEl) loadingEl.classList.remove('hidden');

    try {
      // Load the LUT
      const lutData = await loadLUT(assetPath);

      // Store for preview (use texture3D for actual GPU rendering)
      lutPreviewCurrentLUT = {
        path: assetPath,
        texture3D: lutData.texture3D,
        size: lutData.size,
        name: lutData.name
      };

      // Update display name
      const displayName = assetPath.split('/').pop().replace(/\.[^.]+$/, '').replace(/_/g, ' ');
      if (nameEl) nameEl.textContent = displayName;

      // Show refine button
      if (refineBtn) refineBtn.style.display = 'block';

      console.log('[LUT Preview] Loaded:', assetPath);

    } catch (err) {
      console.error('[LUT Preview] Failed to load:', err);
      if (nameEl) nameEl.textContent = 'Failed to load';
      lutPreviewCurrentLUT = null;
    } finally {
      if (loadingEl) loadingEl.classList.add('hidden');
    }
  }

  // Keyboard navigation for LUT browser
  function handleLUTKeyNav(e) {
    if (assetBrowserMode !== 'lut' || !assetBrowserModal.classList.contains('show')) return;

    const grid = document.getElementById('lutAssetGrid');
    if (!grid) return;

    const items = grid.querySelectorAll('.asset-item');

    // Handle Enter key
    if (e.key === 'Enter') {
      e.preventDefault();
      if (selectedAsset) {
        const callback = assetBrowserCallback;
        const asset = selectedAsset;
        closeAssetBrowser(false);
        if (callback) callback(asset);
      }
      return;
    }

    // Handle Escape
    if (e.key === 'Escape') {
      e.preventDefault();
      closeAssetBrowser(true);
      return;
    }

    // For arrow navigation, we need items
    if (items.length === 0) return;

    if (e.key === 'ArrowDown') {
      e.preventDefault();
      lutKeyboardNavIndex = Math.min(lutKeyboardNavIndex + 1, items.length - 1);
      selectLUTItemByIndex(lutKeyboardNavIndex, items);
    } else if (e.key === 'ArrowUp') {
      e.preventDefault();
      lutKeyboardNavIndex = Math.max(lutKeyboardNavIndex - 1, 0);
      selectLUTItemByIndex(lutKeyboardNavIndex, items);
    }
  }

  function selectLUTItemByIndex(index, items) {
    if (!items || items.length === 0) return;

    items.forEach(item => {
      item.classList.remove('selected');
      item.classList.remove('keyboard-focus');
    });

    const item = items[index];
    if (item) {
      item.classList.add('selected');
      item.classList.add('keyboard-focus');
      item.scrollIntoView({ block: 'nearest', behavior: 'smooth' });

      selectedAsset = {
        path: item.dataset.path,
        name: item.dataset.name
      };
      assetBrowserSelect.disabled = false;

      loadLUTForPreview(item.dataset.path);
    }
  }

  document.addEventListener('keydown', handleLUTKeyNav);

  // Quick-add asset from "My Assets" menu
  function openAssetBrowserForQuickAdd(assetType) {
    quickAddAssetType = assetType;
    
    // Open asset browser with a callback that handles quick-add
    openAssetBrowser(assetType, (asset) => {
      if (!asset) {
        quickAddAssetType = null;
        return; // User cancelled
      }
      
      handleQuickAddAsset(assetType, asset);
      quickAddAssetType = null;
    });
  }
  
  // Handle adding an asset from quick-add menu
  async function handleQuickAddAsset(assetType, asset) {
    console.log('[QuickAdd] Adding asset:', assetType, asset);
    
    if (assetType === 'audio') {
      saveUndoState();
      const track = findOrCreateTrack('audio');
      return handleQuickAddAssetToTrack(assetType, asset, track);
    }
    
    // Save undo state before making changes (for non-audio assets)
    saveUndoState();
    
    // For other asset types, find or create a track and add a clip
    const trackType = getTrackTypeForAsset(assetType);
    console.log('[QuickAdd] Asset type:', assetType, '-> Track type:', trackType);
    
    if (!trackType) {
      console.error('[QuickAdd] Unknown asset type:', assetType);
      return;
    }
    
    const track = findOrCreateTrack(trackType);
    
    if (!track) {
      console.error('[QuickAdd] Failed to find or create track for', assetType);
      return;
    }
    
    // Validate track type matches what we expected
    if (track.type !== trackType) {
      console.error('[QuickAdd] Track type mismatch! Expected:', trackType, 'Got:', track.type);
      // Try to create a new track of the correct type
      const newTrack = addTrack(trackType);
      if (newTrack && newTrack.type === trackType) {
        console.log('[QuickAdd] Created correct track type:', newTrack.type);
        // Use the new track instead
        return handleQuickAddAssetToTrack(assetType, asset, newTrack);
      }
      return;
    }
    
    return handleQuickAddAssetToTrack(assetType, asset, track);
  }
  
  // Helper to add asset to a specific track (after validation)
  async function handleQuickAddAssetToTrack(assetType, asset, track) {
    console.log('[QuickAdd] Adding to track:', track.id, 'type:', track.type);
    
    // Create the clip at playhead position
    const playhead = getCurrentTime();
    const clip = await createClipForAsset(assetType, asset, playhead);
    
    if (!clip) {
      console.error('[QuickAdd] Failed to create clip for', asset);
      return;
    }

    const clipDuration = clip.end - clip.start;
    const placement = newClipPlacement(
      track,
      clip.start,
      clipDuration,
      assetType === 'audio' ? clip.sourceDuration : Infinity,
    );
    clip.start = placement.start;
    clip.end = placement.end;
    
    // Stable clip IDs keep existing animation state attached regardless of insertion.
    let insertIndex = track.segments.findIndex(segment => clip.start < segment.start);
    if (insertIndex < 0) insertIndex = track.segments.length;
    track.segments.splice(insertIndex, 0, clip);
    
    // Post-add initialization based on asset type
    // This ensures scenes, shaders, etc. are properly loaded just like the double-click method
    try {
      if (assetType === 'scenes') {
        // Load scene from file to loadedScenes (for legacy compatibility)
        const sceneIdx = await loadSceneFromFile(asset.path);
        clip.sceneIndex = sceneIdx;
        
        // Create a unique scene instance for this segment
        await getOrCreateSegmentSceneInstance(track.id, insertIndex, clip);
        console.log('[QuickAdd] Scene initialized:', asset.path, 'sceneIndex:', sceneIdx);
      } else if (assetType === 'shader') {
        // Load shader into loadedShaders if not already loaded
        if (!loadedShaders[asset.path]) {
          const code = await fetch(`/${asset.path}`).then(r => r.text());
          const { effectName, params, textureParams } = parseShaderParams(code);
          loadedShaders[asset.path] = {
            source: code,
            threeMaterial: null,
            effectName: effectName || asset.name.replace('.glsl', ''),
            params: params,
            textureParams: textureParams || []
          };
        }
        console.log('[QuickAdd] Shader loaded:', asset.path);
      } else if (assetType === 'postfx') {
        // Load postfx shader into loadedPostFxShaders if not already loaded
        if (!loadedPostFxShaders[asset.path]) {
          const code = await fetch(`/${asset.path}`).then(r => r.text());
          const { effectName, params, textureParams } = parseShaderParams(code);
          loadedPostFxShaders[asset.path] = {
            source: code,
            threeMaterial: null,
            effectName: effectName || asset.name.replace('.glsl', ''),
            params: params,
            textureParams: textureParams || []
          };
        }
        console.log('[QuickAdd] PostFX loaded:', asset.path);
      } else if (assetType === 'media') {
        // Pre-load the media file
        await loadMedia(asset.path);
        console.log('[QuickAdd] Media loaded:', asset.path);
      } else if (assetType === 'lut') {
        // Load the LUT into the loadedLUTs cache
        await loadLUT(asset.path);
        console.log('[QuickAdd] LUT loaded:', asset.path);
      } else if (assetType === 'audio') {
        loadAudioClipWaveform(asset.path);
        refreshPrimaryAudioState();
      }
    } catch (err) {
      console.error('[QuickAdd] Failed to initialize asset:', err);
    }

    extendSilentTimelineTo(clip.end);
    
    // Re-render and select the new clip
    renderTracks();
    selectSegmentInTrack(track.id, insertIndex);
    
    scheduleTimelineAutoSave();
    scheduleDemoSave();
    
    console.log('[QuickAdd] Added clip to track:', track.id, 'at index:', insertIndex);
  }
  
  // Map asset browser mode to track type
  function getTrackTypeForAsset(assetType) {
    switch (assetType) {
      case 'media': return 'media';
      case 'scenes': return 'scene';
      case 'shader': return 'shader';
      case 'postfx': return 'postfx';
      case 'lut': return 'lut';
      case 'audio': return 'audio';
      default: return null;
    }
  }
  
  // Find the first (topmost) track of a type, or create one
  function findOrCreateTrack(trackType) {
    // Find existing tracks of this type
    const existingTracks = getTracksByType(trackType);
    
    if (existingTracks.length > 0) {
      // Return the topmost (first) track of this type
      return existingTracks[0];
    }
    
    // No track of this type exists - create one using addTrack (which properly sets order)
    console.log('[QuickAdd] Creating new track of type:', trackType);
    return addTrack(trackType);
  }
  
  // Create a clip for the given asset type
  async function createClipForAsset(assetType, asset, startTime) {
    const defaultDuration = currentAudioDuration > 0 ? Math.min(10, currentAudioDuration - startTime) : 5;
    const duration = Math.max(1, defaultDuration); // At least 1 second
    
    const endTime = startTime + duration;
    
    switch (assetType) {
      case 'audio': {
        audioIdCounter++;
        const sourceDuration = await readAudioDuration(asset.path);
        return {
          id: `audio${audioIdCounter}`,
          audioFile: asset.path,
          name: asset.name.replace(/\.(mp3|wav|ogg|m4a|aac|flac)$/i, ''),
          start: startTime,
          end: startTime + sourceDuration,
          sourceOffset: 0,
          sourceDuration,
          volume: 1,
          muted: false,
          color: `hsl(${188 + audioIdCounter * 13}, 72%, 48%)`,
        };
      }

      case 'media': {
        imageIdCounter++;
        const isVideo = isVideoFile(asset.path);
        // Get clean name without extension
        const mediaName = asset.name.replace(/\.(mp4|webm|mov|ogg|m4v|png|jpg|jpeg|gif|webp)$/i, '');
        return {
          id: `image${imageIdCounter}`,
          name: mediaName,
          imageFile: asset.path,
          isVideo: isVideo,
          start: startTime,
          end: endTime,
          x: 0.5,
          y: 0.5,
          scale: 1.0,
          rotation: 0,
          opacity: 1.0,
          blendMode: 'source-over',
          audioEnabled: false,
          volume: 0.5,
          color: `hsl(${120 + imageIdCounter * 30}, 60%, 50%)`
        };
      }
      
      case 'scenes': {
        sceneIdCounter++;
        // Load scene data
        try {
          const sceneData = await loadJSON(asset.path);
          return {
            id: `scene${sceneIdCounter}`,
            sceneFile: asset.path,
            sceneIndex: -1, // Not using legacy index
            sceneOverrides: {},
            name: asset.name.replace('.json', ''),
            start: startTime,
            end: endTime,
            opacity: 1.0,
            blendMode: 'normal',
            transparentBackground: false,
            color: `hsl(${210 + sceneIdCounter * 18}, 70%, 60%)`
          };
        } catch (err) {
          console.error('[QuickAdd] Failed to load scene:', err);
          return null;
        }
      }
      
      case 'shader': {
        shaderIdCounter++;
        // Load shader to get default uniforms
        let uniforms = { opacity: 1.0, intensity: 1.0 };
        try {
          const code = await fetch(`/${asset.path}`).then(r => r.text());
          const parsed = parseShaderParams(code);
          for (const param of parsed.params) {
            uniforms[param.uniform] = param.default;
          }
        } catch (err) {
          console.warn('[QuickAdd] Failed to parse shader params:', err);
        }
        
        return {
          id: `shader${shaderIdCounter}`,
          shaderFile: asset.path,
          name: asset.name.replace('.glsl', ''),
          start: startTime,
          end: endTime,
          opacity: 1.0,
          blendMode: 'source-over',
          uniforms: uniforms,
          color: `hsl(${270 + shaderIdCounter * 25}, 60%, 55%)`
        };
      }
      
      case 'postfx': {
        postfxIdCounter++;
        // Load shader to get default uniforms
        let uniforms = { opacity: 1.0, intensity: 1.0 };
        try {
          const code = await fetch(`/${asset.path}`).then(r => r.text());
          const parsed = parseShaderParams(code);
          for (const param of parsed.params) {
            uniforms[param.uniform] = param.default;
          }
        } catch (err) {
          console.warn('[QuickAdd] Failed to parse postfx params:', err);
        }
        
        return {
          id: `postfx${postfxIdCounter}`,
          shaderFile: asset.path,
          name: asset.name.replace('.glsl', ''),
          start: startTime,
          end: endTime,
          uniforms: uniforms,
          color: `hsl(${30 + postfxIdCounter * 15}, 70%, 55%)`
        };
      }

      case 'lut': {
        lutIdCounter++;
        return {
          id: `lut${lutIdCounter}`,
          lutFile: asset.path,
          name: asset.name.replace(/\.cube$/i, ''),
          start: startTime,
          end: endTime,
          intensity: 1.0,
          liftR: 0.0, liftG: 0.0, liftB: 0.0,
          gammaR: 1.0, gammaG: 1.0, gammaB: 1.0,
          gainR: 1.0, gainG: 1.0, gainB: 1.0,
          liftMaster: 0.0, gammaMaster: 1.0, gainMaster: 1.0,
          color: `hsl(${45 + lutIdCounter * 20}, 65%, 55%)`
        };
      }

      default:
        return null;
    }
  }
  
  const confirmDialogController = createConfirmDialogController({ documentTarget: document });
  const clipRenameDialogController = createTextInputDialogController({ documentTarget: document });
  const keyframeValueDialogController = createTextInputDialogController({ documentTarget: document });

  // Show audio replacement confirmation modal
  function showAudioReplaceConfirm(newAudioPath) {
    confirmDialogController.show({
      id: 'audioReplaceModal',
      title: 'Replace Audio?',
      message: 'Your project already has an audio file. Do you want to replace it with the new one?',
      details: [
        `Current: ${currentAudioFile?.split('/').pop() || 'Unknown'}`,
        `New: ${newAudioPath.split('/').pop()}`,
      ],
      confirmLabel: 'Replace Audio',
      onConfirm: () => setProjectAudio(newAudioPath),
    });
  }
  
  // Set project audio file - reuses the audio loading logic from selectAudioFile
  async function setProjectAudio(audioPath) {
    console.log('[QuickAdd] Setting audio to:', audioPath);
    try {
      const result = await loadAudioPath(audioPath);
      console.log(`[QuickAdd] Audio loaded: ${audioPath}, duration: ${formatDuration(result.duration)}`);
    } catch (err) {
      console.error('[QuickAdd] Failed to load audio:', err);
      alert('Failed to load audio file');
    }
  }
  
  async function loadAssets() {
    // Show loading in the correct grid based on mode
    const loadingGrid = assetBrowserGrid(document, assetGrid, assetBrowserMode);
    loadingGrid.innerHTML = '<div class="empty-state">Loading assets...</div>';
    
    try {
      loadedAssets = await loadAssetCatalog(api, assetBrowserMode);
      
      renderAssetGrid();
    } catch (e) {
      console.error('Failed to load assets:', e);
      const errorGrid = assetBrowserGrid(document, assetGrid, assetBrowserMode);
      errorGrid.innerHTML = '<div class="empty-state">Failed to load assets</div>';
    }
  }
  
  function renderAssetGrid() {
    const searchTerms = [];
    
    // Apply search filter for scenes
    const sceneSearchInput = document.getElementById('sceneSearchInput');
    if (sceneSearchInput && sceneSearchInput.value.trim()) {
      searchTerms.push(sceneSearchInput.value);
    }
    
    // Apply search filter for shaders (standard layout)
    const shaderSearchInput = document.getElementById('shaderSearchInput');
    if (shaderSearchInput && shaderSearchInput.value.trim()) {
      searchTerms.push(shaderSearchInput.value);
    }
    
    // Apply search filter for postfx (preview layout)
    const postfxSearchInput = document.getElementById('postfxSearchInput');
    if (postfxSearchInput && postfxSearchInput.value.trim()) {
      searchTerms.push(postfxSearchInput.value);
    }
    
    // Apply search filter for shader (preview layout)
    const shaderPreviewSearchInput = document.getElementById('shaderPreviewSearchInput');
    if (shaderPreviewSearchInput && shaderPreviewSearchInput.value.trim()) {
      searchTerms.push(shaderPreviewSearchInput.value);
    }
    
    // Apply search filter for scene (preview layout)
    const scenePreviewSearchInput = document.getElementById('scenePreviewSearchInput');
    if (scenePreviewSearchInput && scenePreviewSearchInput.value.trim()) {
      searchTerms.push(scenePreviewSearchInput.value);
    }
    
    // Apply search filter for audio (preview layout)
    const audioPreviewSearchInput = document.getElementById('audioPreviewSearchInput');
    if (audioPreviewSearchInput && audioPreviewSearchInput.value.trim()) {
      searchTerms.push(audioPreviewSearchInput.value);
    }

    // Apply search filter for LUT (preview layout)
    const lutSearchInput = document.getElementById('lutSearchInput');
    if (lutSearchInput && lutSearchInput.value.trim()) {
      searchTerms.push(lutSearchInput.value);
    }

    // Apply search filter for media
    const mediaSearchInput = document.getElementById('mediaSearchInput');
    if (mediaSearchInput && mediaSearchInput.value.trim()) {
      searchTerms.push(mediaSearchInput.value);
    }
    const filtered = filterAssetCatalog(loadedAssets, currentAssetFilter, searchTerms);
    
    // Determine which grid to render to based on mode
    const targetGrid = assetBrowserGrid(document, assetGrid, assetBrowserMode);
    
    if (filtered.length === 0) {
      targetGrid.innerHTML = '<div class="empty-state">No assets found</div>';
      if (targetGrid === assetGrid) {
      assetGrid.classList.remove('list-view');
      delete assetGrid.dataset.mode;
      }
      return;
    }
    
    // Helper to generate badges
    function getBadgesHTML(asset) {
      let badges = '';
      if (asset.owner === editorState.currentUser) {
        badges += '<span class="asset-badge owner">Mine</span>';
      }
      if (asset.is_default) {
        badges += '<span class="asset-badge default-badge">Default</span>';
      }
      return badges;
    }
    
    // Helper to generate action buttons
    function getActionsHTML(asset) {
      // Admins can edit anything; regular users can only edit their own assets
      const canEdit = isAdmin || asset.owner === editorState.currentUser;
      if (!canEdit) return '';
      
      let actions = '<div class="asset-actions">';
      actions += `<button class="asset-action-btn rename" data-path="${asset.path}" data-name="${asset.name}" title="Rename">✏️</button>`;
      actions += `<button class="asset-action-btn delete" data-path="${asset.path}" data-name="${asset.name}" title="Delete">🗑</button>`;
      if (isAdmin) {
        actions += `<button class="asset-action-btn default ${asset.is_default ? 'active' : ''}" data-path="${asset.path}" data-default="${asset.is_default ? 'true' : 'false'}" title="Toggle default (visible to all)">⭐</button>`;
      }
      actions += '</div>';
      return actions;
    }
    
    // Use list view for audio, shaders, post-fx, scenes, 3d, lut; grid view for media
    const useListView = assetBrowserMode === 'audio' || assetBrowserMode === 'shader' || assetBrowserMode === 'postfx' || assetBrowserMode === 'scenes' || assetBrowserMode === '3d' || assetBrowserMode === 'lut';
    
    // Helper to get clean display name (strips timestamp suffix like _1234567890)
    const getDisplayName = (filename) => {
      const baseName = filename.replace(/\.[^.]+$/, ''); // Remove extension
      // Strip timestamp suffix (underscore followed by 10+ digits at end)
      return baseName.replace(/_\d{10,}$/, '');
    };
    
    if (useListView) {
      targetGrid.classList.add('list-view');
      targetGrid.dataset.mode = assetBrowserMode;
      
      targetGrid.innerHTML = filtered.map(asset => {
        const isSelected = selectedAsset?.path === asset.path;
        const ext = asset.name.split('.').pop().toLowerCase();
        const baseName = getDisplayName(asset.name);
        
        // For postfx, shader, scene, audio, and lut modes, use a cleaner minimal design
        if (assetBrowserMode === 'postfx' || assetBrowserMode === 'shader' || assetBrowserMode === 'scenes' || assetBrowserMode === 'audio' || assetBrowserMode === 'lut') {
          return `
            <div class="asset-item ${isSelected ? 'selected' : ''}" data-path="${asset.path}" data-name="${asset.name}">
              <span class="asset-item-name">${baseName}</span>
              ${getActionsHTML(asset)}
            </div>
          `;
        }
        
        let icon = '◆';
        if (ext === 'glsl') {
          icon = '◉';
        } else if (['mp3', 'wav', 'ogg'].includes(ext)) {
          icon = '♪';
        } else if (ext === 'json' && assetBrowserMode === 'scenes') {
          icon = '◈';
        } else if (['glb', 'gltf', 'obj', 'fbx', 'stl'].includes(ext) || assetBrowserMode === '3d') {
          icon = '▣';
        }
        
        return `
          <div class="asset-item ${isSelected ? 'selected' : ''}" data-path="${asset.path}" data-name="${asset.name}">
            <div class="asset-item-icon">${icon}</div>
            <span class="asset-item-name">${baseName}</span>
            ${getBadgesHTML(asset)}
            <span class="asset-item-type">${ext}</span>
            ${getActionsHTML(asset)}
          </div>
        `;
      }).join('');
    } else {
      assetGrid.classList.remove('list-view');
      delete assetGrid.dataset.mode;
    
      // Pagination for media mode
      const totalItems = filtered.length;
      const totalPages = Math.ceil(totalItems / MEDIA_ITEMS_PER_PAGE);
      
      // Ensure current page is valid
      if (currentMediaPage >= totalPages) {
        currentMediaPage = Math.max(0, totalPages - 1);
      }
      
      // Get items for current page only
      const startIdx = currentMediaPage * MEDIA_ITEMS_PER_PAGE;
      const endIdx = startIdx + MEDIA_ITEMS_PER_PAGE;
      const pageItems = filtered.slice(startIdx, endIdx);
    
      // Render grid items
      let gridHTML = pageItems.map(asset => {
      const isSelected = selectedAsset?.path === asset.path;
      const ext = asset.name.split('.').pop().toLowerCase();
      
      let preview = '';
      if (['jpg', 'jpeg', 'png', 'gif', 'webp'].includes(ext)) {
        preview = `<img src="/${asset.path}" alt="${asset.name}" loading="lazy">`;
      } else if (['mp4', 'webm'].includes(ext)) {
          preview = `<video src="/${asset.path}" muted preload="metadata"></video>`;
      } else {
          preview = `<div class="asset-item-icon">◆</div>`;
      }
        
        const badges = getBadgesHTML(asset);
      
      return `
          <div class="asset-item" data-path="${asset.path}" data-name="${asset.name}">
            ${getActionsHTML(asset)}
          ${preview}
          <span class="asset-item-type">${ext}</span>
            ${badges ? `<div class="asset-badges">${badges}</div>` : ''}
          <div class="asset-item-overlay">${getDisplayName(asset.name)}</div>
        </div>
      `;
    }).join('');
      
      // Add pagination controls if more than one page
      if (totalPages > 1) {
        gridHTML += `
          <div class="asset-pagination" style="grid-column: 1 / -1;">
            <button id="mediaPrevPage" ${currentMediaPage === 0 ? 'disabled' : ''}>← Previous</button>
            <span class="page-info">Page ${currentMediaPage + 1} of ${totalPages}</span>
            <button id="mediaNextPage" ${currentMediaPage >= totalPages - 1 ? 'disabled' : ''}>Next →</button>
          </div>
        `;
      }
      
      assetGrid.innerHTML = gridHTML;
      
      // Re-apply selection if selected asset is on this page
      if (selectedAsset) {
        const selectedItem = assetGrid.querySelector(`.asset-item[data-path="${selectedAsset.path}"]`);
        if (selectedItem) {
          selectedItem.classList.add('selected');
        }
      }
      
      // Bind pagination handlers
      const prevBtn = document.getElementById('mediaPrevPage');
      const nextBtn = document.getElementById('mediaNextPage');
      
      if (prevBtn) {
        prevBtn.addEventListener('click', () => {
          if (currentMediaPage > 0) {
            currentMediaPage--;
            renderAssetGrid();
          }
        });
      }
      
      if (nextBtn) {
        nextBtn.addEventListener('click', () => {
          if (currentMediaPage < totalPages - 1) {
            currentMediaPage++;
            renderAssetGrid();
          }
        });
      }
    }
    
    // Bind click handlers (use targetGrid which could be assetGrid or postfxAssetGrid)
    targetGrid.querySelectorAll('.asset-item').forEach((item, index) => {
      item.addEventListener('click', (e) => {
        // Don't select if clicking action buttons
        if (e.target.closest('.asset-actions')) return;
        
        // Deselect others
        targetGrid.querySelectorAll('.asset-item').forEach(i => {
          i.classList.remove('selected');
          i.classList.remove('keyboard-focus');
        });
        item.classList.add('selected');
        
        selectedAsset = {
          path: item.dataset.path,
          name: item.dataset.name
        };
        assetBrowserSelect.disabled = false;
        
        // Update keyboard nav index and load preview
        if (assetBrowserMode === 'postfx') {
          postfxKeyboardNavIndex = index;
          loadPostFxForPreview(item.dataset.path);
        } else if (assetBrowserMode === 'shader') {
          shaderKeyboardNavIndex = index;
          loadShaderForPreview(item.dataset.path);
        } else if (assetBrowserMode === 'scenes') {
          sceneKeyboardNavIndex = index;
          loadSceneForPreview(item.dataset.path);
        } else if (assetBrowserMode === 'audio') {
          audioKeyboardNavIndex = index;
          loadAudioForPreview(item.dataset.path);
        } else if (assetBrowserMode === 'lut') {
          lutKeyboardNavIndex = index;
          loadLUTForPreview(item.dataset.path);
        }
        
        // Show/hide refine button based on mode and selection
        const canRefine = (assetBrowserMode === 'shader' || assetBrowserMode === 'postfx') && selectedAsset;
        assetRefineBtn.style.display = canRefine ? 'inline-flex' : 'none';
      });
      
      // Double-click to select immediately
      item.addEventListener('dblclick', (e) => {
        if (e.target.closest('.asset-actions')) return;
        selectedAsset = {
          path: item.dataset.path,
          name: item.dataset.name
        };
        confirmAssetSelection();
      });
      
      // Video preview on hover
      const video = item.querySelector('video');
      if (video) {
        item.addEventListener('mouseenter', () => video.play());
        item.addEventListener('mouseleave', () => {
          video.pause();
          video.currentTime = 0;
        });
      }
    });
    
    // Bind action button handlers
    targetGrid.querySelectorAll('.asset-action-btn.delete').forEach(btn => {
      btn.addEventListener('click', (e) => {
        e.stopPropagation();
        const path = btn.dataset.path;
        const name = btn.dataset.name;
        deleteAsset(path, name);
      });
    });
    
    targetGrid.querySelectorAll('.asset-action-btn.rename').forEach(btn => {
      btn.addEventListener('click', (e) => {
        e.stopPropagation();
        const path = btn.dataset.path;
        const name = btn.dataset.name;
        renameAsset(path, name);
      });
    });
    
    targetGrid.querySelectorAll('.asset-action-btn.default').forEach(btn => {
      btn.addEventListener('click', (e) => {
        e.stopPropagation();
        const path = btn.dataset.path;
        const currentDefault = btn.dataset.default === 'true';
        toggleAssetDefault(path, !currentDefault);
      });
    });
    
    // For postfx mode, bind the search input handler
    if (assetBrowserMode === 'postfx') {
      const postfxSearchInput = document.getElementById('postfxSearchInput');
      if (postfxSearchInput && !postfxSearchInput._bound) {
        postfxSearchInput._bound = true;
        postfxSearchInput.addEventListener('input', () => {
          postfxKeyboardNavIndex = -1;
          renderAssetGrid();
        });
        // Handle Enter key in search input to confirm selection
        postfxSearchInput.addEventListener('keydown', (e) => {
          if (e.key === 'Enter' && selectedAsset) {
            e.preventDefault();
            const callback = assetBrowserCallback;
            const asset = selectedAsset; // Save asset before closeAssetBrowser clears it
            closeAssetBrowser(false);
            if (callback) callback(asset);
          }
        });
      }
      
      // Auto-select first item if none selected
      if (!selectedAsset && filtered.length > 0) {
        const firstItem = targetGrid.querySelector('.asset-item');
        if (firstItem) {
          postfxKeyboardNavIndex = 0;
          firstItem.classList.add('selected');
          selectedAsset = {
            path: firstItem.dataset.path,
            name: firstItem.dataset.name
          };
          assetBrowserSelect.disabled = false;
          loadPostFxForPreview(firstItem.dataset.path);
        }
      }
    }
    
    // For shader mode, bind the search input handler
    if (assetBrowserMode === 'shader') {
      const shaderPreviewSearchInput = document.getElementById('shaderPreviewSearchInput');
      if (shaderPreviewSearchInput && !shaderPreviewSearchInput._bound) {
        shaderPreviewSearchInput._bound = true;
        shaderPreviewSearchInput.addEventListener('input', () => {
          shaderKeyboardNavIndex = -1;
          renderAssetGrid();
        });
        // Handle Enter key in search input to confirm selection
        shaderPreviewSearchInput.addEventListener('keydown', (e) => {
          if (e.key === 'Enter' && selectedAsset) {
            e.preventDefault();
            const callback = assetBrowserCallback;
            const asset = selectedAsset; // Save asset before closeAssetBrowser clears it
            closeAssetBrowser(false);
            if (callback) callback(asset);
          }
        });
      }
      
      // Auto-select first item if none selected
      if (!selectedAsset && filtered.length > 0) {
        const firstItem = targetGrid.querySelector('.asset-item');
        if (firstItem) {
          shaderKeyboardNavIndex = 0;
          firstItem.classList.add('selected');
          selectedAsset = {
            path: firstItem.dataset.path,
            name: firstItem.dataset.name
          };
          assetBrowserSelect.disabled = false;
          loadShaderForPreview(firstItem.dataset.path);
        }
      }
    }
    
    // For scenes mode, bind the search input handler
    if (assetBrowserMode === 'scenes') {
      const scenePreviewSearchInput = document.getElementById('scenePreviewSearchInput');
      if (scenePreviewSearchInput && !scenePreviewSearchInput._bound) {
        scenePreviewSearchInput._bound = true;
        scenePreviewSearchInput.addEventListener('input', () => {
          sceneKeyboardNavIndex = -1;
          renderAssetGrid();
        });
        // Handle Enter key in search input to confirm selection
        scenePreviewSearchInput.addEventListener('keydown', (e) => {
          if (e.key === 'Enter' && selectedAsset) {
            e.preventDefault();
            const callback = assetBrowserCallback;
            const asset = selectedAsset; // Save asset before closeAssetBrowser clears it
            closeAssetBrowser(false);
            if (callback) callback(asset);
          }
        });
      }
      
      // Auto-select first item if none selected
      if (!selectedAsset && filtered.length > 0) {
        const firstItem = targetGrid.querySelector('.asset-item');
        if (firstItem) {
          sceneKeyboardNavIndex = 0;
          firstItem.classList.add('selected');
          selectedAsset = {
            path: firstItem.dataset.path,
            name: firstItem.dataset.name
          };
          assetBrowserSelect.disabled = false;
          loadSceneForPreview(firstItem.dataset.path);
        }
      }
    }
    
    // For audio mode, bind the search input handler
    if (assetBrowserMode === 'audio') {
      const audioPreviewSearchInput = document.getElementById('audioPreviewSearchInput');
      if (audioPreviewSearchInput && !audioPreviewSearchInput._bound) {
        audioPreviewSearchInput._bound = true;
        audioPreviewSearchInput.addEventListener('input', () => {
          audioKeyboardNavIndex = -1;
          renderAssetGrid();
        });
        // Handle Enter key in search input to confirm selection
        audioPreviewSearchInput.addEventListener('keydown', (e) => {
          if (e.key === 'Enter' && selectedAsset) {
            e.preventDefault();
            const callback = assetBrowserCallback;
            const asset = selectedAsset; // Save asset before closeAssetBrowser clears it
            closeAssetBrowser(false);
            if (callback) callback(asset);
          }
        });
      }

      // Auto-select first item if none selected
      if (!selectedAsset && filtered.length > 0) {
        const firstItem = targetGrid.querySelector('.asset-item');
        if (firstItem) {
          audioKeyboardNavIndex = 0;
          firstItem.classList.add('selected');
          selectedAsset = {
            path: firstItem.dataset.path,
            name: firstItem.dataset.name
          };
          assetBrowserSelect.disabled = false;
          loadAudioForPreview(firstItem.dataset.path);
        }
      }
    }

    // For LUT mode, bind the search input handler
    if (assetBrowserMode === 'lut') {
      const lutSearchInput = document.getElementById('lutSearchInput');
      if (lutSearchInput && !lutSearchInput._bound) {
        lutSearchInput._bound = true;
        lutSearchInput.addEventListener('input', () => {
          lutKeyboardNavIndex = -1;
          renderAssetGrid();
        });
        // Handle Enter key in search input to confirm selection
        lutSearchInput.addEventListener('keydown', (e) => {
          if (e.key === 'Enter' && selectedAsset) {
            e.preventDefault();
            const callback = assetBrowserCallback;
            const asset = selectedAsset;
            closeAssetBrowser(false);
            if (callback) callback(asset);
          }
        });
      }

      // Auto-select first item if none selected
      if (!selectedAsset && filtered.length > 0) {
        const firstItem = targetGrid.querySelector('.asset-item');
        if (firstItem) {
          lutKeyboardNavIndex = 0;
          firstItem.classList.add('selected');
          selectedAsset = {
            path: firstItem.dataset.path,
            name: firstItem.dataset.name
          };
          assetBrowserSelect.disabled = false;
          loadLUTForPreview(firstItem.dataset.path);
        }
      }
    }
  }
  
  function confirmAssetSelection() {
    console.log('[AssetBrowser] confirmAssetSelection called, selectedAsset:', selectedAsset, 'mode:', assetBrowserMode);
    if (selectedAsset && assetBrowserCallback) {
      console.log('[AssetBrowser] Calling callback with asset');
      try {
      assetBrowserCallback(selectedAsset);
      } catch (err) {
        console.error('[AssetBrowser] Callback error:', err);
      }
    } else {
      console.warn('[AssetBrowser] No selectedAsset or callback!', { selectedAsset, hasCallback: !!assetBrowserCallback });
    }
    closeAssetBrowser(false); // Not cancelled - already called callback
  }
  
  // Asset management functions
  async function deleteAsset(path, name) {
    if (!confirm(`Delete "${name}"?\n\nThis cannot be undone.`)) return;
    
    try {
      const res = await api.assets.delete(path);
      const data = await res.json();
      
      if (res.ok && data.success) {
        // Remove from loadedAssets and re-render
        loadedAssets = loadedAssets.filter(a => a.path !== path);
        if (selectedAsset?.path === path) {
          selectedAsset = null;
          assetBrowserSelect.disabled = true;
        }
        renderAssetGrid();
        console.log('[ASSET] Deleted:', path);
      } else {
        alert(data.error || 'Failed to delete asset');
      }
    } catch (e) {
      console.error('Delete failed:', e);
      alert('Failed to delete asset');
    }
  }
  
  async function renameAsset(path, currentName) {
    const ext = currentName.split('.').pop();
    const baseName = currentName.replace(/\.[^.]+$/, '');
    
    const newBaseName = prompt(`Rename "${currentName}":`, baseName);
    if (!newBaseName || newBaseName === baseName) return;
    
    const newName = `${newBaseName}.${ext}`;
    
    try {
      const res = await api.assets.rename(path, newName);
      const data = await res.json();
      
      if (res.ok && data.success) {
        // Update loadedAssets and re-render
        const asset = loadedAssets.find(a => a.path === path);
        if (asset) {
          asset.path = data.new_path;
          asset.name = newName;
        }
        if (selectedAsset?.path === path) {
          selectedAsset.path = data.new_path;
          selectedAsset.name = newName;
        }
        renderAssetGrid();
        console.log('[ASSET] Renamed:', path, '->', data.new_path);
      } else {
        alert(data.error || 'Failed to rename asset');
      }
    } catch (e) {
      console.error('Rename failed:', e);
      alert('Failed to rename asset');
    }
  }
  
  async function toggleAssetDefault(path, isDefault) {
    try {
      const res = await api.assets.setDefault(path, isDefault);
      const data = await res.json();
      
      if (res.ok && data.success) {
        // Update loadedAssets and re-render
        const asset = loadedAssets.find(a => a.path === path);
        if (asset) {
          asset.is_default = isDefault;
        }
        renderAssetGrid();
        console.log('[ASSET] Set default:', path, isDefault);
      } else {
        alert(data.error || 'Failed to update default status');
      }
    } catch (e) {
      console.error('Set default failed:', e);
      alert('Failed to update default status');
    }
  }
  
  // Asset Browser event listeners
  document.getElementById('assetBrowserClose').addEventListener('click', closeAssetBrowser);
  document.getElementById('assetBrowserCancel').addEventListener('click', closeAssetBrowser);
  assetBrowserSelect.addEventListener('click', confirmAssetSelection);
  assetBrowserModal.addEventListener('click', (e) => {
    if (e.target === assetBrowserModal) closeAssetBrowser();
  });
  
  // Asset Upload functionality
  const assetUploadBtn = document.getElementById('assetUploadBtn');
  const audioPreviewUploadBtn = document.getElementById('audioPreviewUploadBtn');
  const assetFileInput = document.getElementById('assetFileInput');
  const mediaReplaceFileInput = document.getElementById('mediaReplaceFileInput');
  const uploadProgress = document.getElementById('uploadProgress');
  const uploadProgressBar = document.getElementById('uploadProgressBar');
  const uploadProgressText = document.getElementById('uploadProgressText');
  
  function openAssetUploadPicker() {
    // Set accepted file types based on current mode
    if (assetBrowserMode === 'media') {
      assetFileInput.accept = 'image/jpeg,image/png,image/gif,image/webp,video/mp4,video/webm';
    } else if (assetBrowserMode === 'postfx') {
      assetFileInput.accept = '.glsl,.frag,.fs';
    } else if (assetBrowserMode === 'shader') {
      assetFileInput.accept = '.glsl,.frag,.fs';
    } else if (assetBrowserMode === 'audio') {
      assetFileInput.accept = 'audio/mpeg,audio/wav,audio/ogg,.mp3,.wav,.ogg';
    } else if (assetBrowserMode === 'scenes') {
      assetFileInput.accept = '.json,application/json';
    } else if (assetBrowserMode === '3d') {
      assetFileInput.accept = '.glb,.gltf,.obj,.fbx,.stl';
    } else {
      assetFileInput.accept = '*/*';
    }
    assetFileInput.click();
  }

  assetUploadBtn.addEventListener('click', () => openAssetUploadPicker());
  audioPreviewUploadBtn?.addEventListener('click', () => openAssetUploadPicker());
  
  assetFileInput.addEventListener('change', async () => {
    const files = assetFileInput.files;
    if (!files || files.length === 0) return;
    
    uploadProgress.style.display = 'block';
    if (audioPreviewUploadBtn) {
      audioPreviewUploadBtn.disabled = true;
      audioPreviewUploadBtn.textContent = 'Uploading 0%';
    }
    let completed = 0;
    const total = files.length;
    
    for (const file of files) {
      try {
        await uploadAssetFile(file, (progress) => {
          const overallProgress = ((completed + progress) / total) * 100;
          uploadProgressBar.style.width = overallProgress + '%';
          uploadProgressText.textContent = `${Math.round(overallProgress)}%`;
          if (audioPreviewUploadBtn) {
            audioPreviewUploadBtn.textContent = `Uploading ${Math.round(overallProgress)}%`;
          }
        });
        completed++;
      } catch (err) {
        console.error('Upload failed:', err);
        alert(`Failed to upload ${file.name}: ${err.message}`);
      }
    }
    
    // Reset
    uploadProgressBar.style.width = '100%';
    uploadProgressText.textContent = 'Done!';
    
    setTimeout(() => {
      uploadProgress.style.display = 'none';
      uploadProgressBar.style.width = '0%';
      uploadProgressText.textContent = '0%';
    }, 1000);
    
    // Refresh asset list
    loadAssets();
    if (audioPreviewUploadBtn) {
      audioPreviewUploadBtn.disabled = false;
      audioPreviewUploadBtn.textContent = 'Upload Audio';
    }
    
    // Clear file input
    assetFileInput.value = '';
  });
  
  async function uploadAssetFile(file, onProgress) {
    const types = {
      media: 'images',
      postfx: 'postfx',
      shader: 'shaders',
      audio: 'audio',
      scenes: 'scenes',
      '3d': '3d',
    };
    return api.assets.upload(file, types[assetBrowserMode] || 'auto', onProgress);
  }

  let pendingMediaReplacementTarget = null;
  mediaReplaceFileInput?.addEventListener('change', async () => {
    const target = pendingMediaReplacementTarget;
    pendingMediaReplacementTarget = null;
    const file = mediaReplaceFileInput.files?.[0];
    mediaReplaceFileInput.value = '';
    if (!target || !file) return;

    showToast(`Uploading ${file.name}…`);
    try {
      const uploaded = await api.assets.upload(file, 'images');
      await replaceMediaClipAsset(target, {
        path: uploaded.path,
        name: uploaded.filename || file.name,
      });
    } catch (error) {
      console.error('Media replacement upload failed:', error);
      showToast(`Upload failed: ${error.message || file.name}`);
    }
  });
  
  // =========================
  // AI GENERATOR (Shaders & Scenes)
  // =========================
  const aiGeneratorModal = document.getElementById('aiGeneratorModal');
  const aiGeneratorTitle = aiGeneratorModal.querySelector('h2');
  const aiApiKeySection = document.getElementById('aiApiKeySection');
  const aiPromptSection = document.getElementById('aiPromptSection');
  const aiPromptLabel = document.getElementById('aiPromptLabel');
  const aiPromptHint = document.getElementById('aiPromptHint');
  const aiNameLabel = document.getElementById('aiNameLabel');
  const geminiApiKeyInput = document.getElementById('geminiApiKey');
  const shaderPromptInput = document.getElementById('shaderPrompt');
  const aiShaderNameInput = document.getElementById('aiShaderName');
  const aiGenerateStatus = document.getElementById('aiGenerateStatus');
  const saveApiKeyBtn = document.getElementById('saveApiKeyBtn');
  const generateShaderBtn = document.getElementById('generateShaderBtn');
  const changeApiKeyBtn = document.getElementById('changeApiKeyBtn');
  
  // Track what type we're generating
  let aiGeneratorType = 'shader'; // 'shader', 'postfx', or 'scene'
  let aiRefineMode = false; // true when refining existing shader
  let aiRefineCode = ''; // existing shader code to refine
  let aiRefineFilePath = ''; // path to the shader being refined
  const aiPreferences = createAiPreferences(localStorage);
  const aiStatusPresenter = createAiStatusPresenter({
    container: aiGenerateStatus,
    textarea: shaderPromptInput,
  });
  
  // System prompt for AI shader generation
  const SHADER_SYSTEM_PROMPT = `You are an expert GLSL shader programmer for a demoscene tool. Generate creative, visually stunning fragment shaders.

THIS IS A REGULAR SHADER (not post-fx). Regular shaders:
- Generate visuals FROM SCRATCH (fractals, patterns, procedural art)
- Do NOT have uTexture - that's only for post-fx shaders!
- If you need images/videos, use @texture to let users select them

!!!!! ABSOLUTE RULES - READ FIRST !!!!!
1. NEVER write "uniform float", "uniform vec2", or ANY uniform declaration - causes REDEFINITION ERROR!
2. All @param uniforms are FLOATS - for loop limits MUST use int(): for(int i=0; i<int(uIterations); i++)
3. NEVER use uTexture - it does NOT exist in regular shaders!

CRITICAL FORMAT RULES:

1. Parameters declared via @param comments ONLY:
   // @param uSpeed "Speed" 1.0 0.1 5.0 0.1
   Just USE the variable: float t = iTime * uSpeed;
   WRONG: uniform float uSpeed; // NEVER - causes error!

2. FOR LOOPS WITH PARAMS - params are floats, loops need ints! MUST cast:
   // @param uIterations "Iterations" 100.0 10.0 500.0 10.0
   CORRECT: for (int i = 0; i < int(uIterations); i++) { ... }
   WRONG: for (int i = 0; i < uIterations; i++) { ... } // TYPE ERROR!

3. @param syntax: // @param varName "Label" default min max step
   All are floats. For colors use separate params:
   // @param uHue "Hue" 0.5 0.0 1.0 0.01

4. TEXTURE PARAMETERS - For user-selectable textures (images, videos, noise, patterns):
   When the user asks for textures, overlays, images, videos, noise maps, patterns, or any custom media input:
   // @texture uMyTexture "My Texture"
   DO NOT declare uniform - it's auto-generated! Just use: vec4 texColor = texture(uMyTexture, uv);
   The user gets a "Select" button to pick an IMAGE or VIDEO from the media library.
   VIDEO TEXTURES: Videos play automatically and loop - perfect for animated textures, motion backgrounds, etc.
   
   ⚠️ RESERVED/UNAVAILABLE NAMES - NEVER use these:
   - iChannel0, iChannel1, iChannel2, iChannel3 (built-in system textures)
   - uTexture (does NOT exist in regular shaders - only in post-fx!)
   Use descriptive names instead: uOverlay, uNoiseMap, uPattern, uMask, uTexture1, uTexture2, etc.
   
   COMMON TEXTURE USE CASES - generate @texture when user asks for:
   - "add a texture" / "use an image" / "overlay an image" → // @texture uOverlay "Overlay Image"
   - "add noise texture" / "use noise map" → // @texture uNoiseMap "Noise Texture"
   - "add a pattern" / "tile pattern" → // @texture uPattern "Pattern Texture"
   - "background image" / "use a backdrop" → // @texture uBackground "Background Image"
   - "displacement map" / "height map" → // @texture uDisplacement "Displacement Map"
   - "mask" / "alpha mask" → // @texture uMask "Effect Mask"
   - "add video" / "video texture" / "animated texture" → // @texture uVideo "Video Texture"
   - "motion background" / "video overlay" → // @texture uMotionBg "Motion Background"
   
   TEXTURE BEHAVIOR: When no texture is selected, texture uniforms return WHITE (1,1,1,1).
   Design your shader so it looks good with white, then the user's texture replaces it.
   
   TEXTURE EXAMPLE - Masked texture fill (NO uniform declaration needed!):
   // @texture uFillTexture "Fill Texture"
   // @param uScale "Texture Scale" 1.0 0.1 5.0 0.1
   
   void mainImage(out vec4 fragColor, in vec2 fragCoord) {
       vec2 uv = fragCoord / iResolution.xy;
       vec2 centeredUV = (fragCoord - 0.5 * iResolution.xy) / iResolution.y;
       
       // Create a shape (circle mask)
       float mask = smoothstep(0.3, 0.28, length(centeredUV));
       
       // Sample texture with scaling
       vec4 texColor = texture(uFillTexture, uv * uScale);
       
       // Apply texture to masked area (transparent outside)
       fragColor = vec4(texColor.rgb, mask);
   }
   
   TEXTURE EXAMPLE - Procedural + texture blend:
   // @texture uOverlay "Overlay Image"
   // @param uBlend "Texture Blend" 0.5 0.0 1.0 0.01
   
   void mainImage(out vec4 fragColor, in vec2 fragCoord) {
       vec2 uv = fragCoord / iResolution.xy;
       
       // Procedural pattern
       vec3 procColor = vec3(sin(uv.x * 10.0 + iTime) * 0.5 + 0.5);
       
       // Sample user texture
       vec4 texColor = texture(uOverlay, uv);
       
       // Blend procedural with texture
       vec3 col = mix(procColor, texColor.rgb, uBlend);
       fragColor = vec4(col, 1.0);
   }

5. Pre-defined uniforms (just use them):
   - iTime (float), iResolution (vec2)
   ⚠️ Regular shaders do NOT have uTexture! That's only for post-fx shaders.
   If you need textures in a regular shader, use @texture for ALL of them.

6. NEVER use audio uniforms (iBass, iMid, iTreble, iVolume).

7. Function signature: void mainImage(out vec4 fragColor, in vec2 fragCoord)

8. UV setup: vec2 uv = (fragCoord - 0.5 * iResolution.xy) / iResolution.y;

9. No markdown. Raw GLSL only. Complete code, every { has }.

FRACTAL EXAMPLE (showing correct int cast for iterations):

// @name Mandelbrot
// @param uZoom "Zoom" 1.0 0.1 100.0 0.1
// @param uIterations "Iterations" 100.0 20.0 500.0 10.0
// @param uCenterX "Center X" -0.5 -2.0 2.0 0.01
// @param uCenterY "Center Y" 0.0 -2.0 2.0 0.01

void mainImage(out vec4 fragColor, in vec2 fragCoord) {
    vec2 uv = (fragCoord - 0.5 * iResolution.xy) / iResolution.y;
    vec2 c = uv / uZoom + vec2(uCenterX, uCenterY);
    vec2 z = vec2(0.0);
    float iter = 0.0;
    
    for (int i = 0; i < int(uIterations); i++) {  // NOTE: int() cast!
        z = vec2(z.x*z.x - z.y*z.y, 2.0*z.x*z.y) + c;
        if (dot(z,z) > 4.0) break;
        iter += 1.0;
    }
    
    float t = iter / uIterations;
    vec3 col = 0.5 + 0.5 * cos(6.28 * (t + vec3(0.0, 0.33, 0.67)));
    if (iter >= uIterations - 0.5) col = vec3(0.0);
    fragColor = vec4(col, 1.0);
}

REMEMBER: NO uniform keyword! Cast to int() for loop limits! Complete code ending with }.`;

  // System prompt for AI post-FX shader generation
  const POSTFX_SYSTEM_PROMPT = `You are an expert GLSL shader programmer specializing in POST-PROCESSING EFFECTS for a demoscene tool. Your effects are applied ON TOP of rendered content (scenes, images, videos).

!!!!! ABSOLUTE RULES - READ FIRST !!!!!
1. NEVER write "uniform float", "uniform vec2", or ANY uniform declaration - causes REDEFINITION ERROR!
2. All @param uniforms are FLOATS - for loop limits MUST use int(): for(int i=0; i<int(uSamples); i++)

Post-FX shaders PROCESS an existing image via uTexture.

CRITICAL FORMAT RULES:

1. Parameters declared via @param comments ONLY:
   // @param uStrength "Strength" 0.5 0.0 1.0 0.05
   Just USE it: col *= uStrength;
   WRONG: uniform float uStrength; // NEVER!

2. FOR LOOPS WITH PARAMS - must cast to int:
   // @param uSamples "Samples" 8.0 2.0 32.0 2.0
   CORRECT: for (int i = 0; i < int(uSamples); i++) { ... }
   WRONG: for (int i = 0; i < uSamples; i++) { ... } // TYPE ERROR!

3. Pre-defined uniforms (just use them):
   - uTexture (sampler2D) - THE INPUT IMAGE being processed. Always sample this for the original!
   - uResolution (vec2), uTime (float), uIntensity (float)

4. TEXTURE PARAMETERS - For user-selectable ADDITIONAL textures (overlays, masks, LUTs, noise, patterns):
   When the user asks for textures, overlays, images, videos, LUTs, noise, masks, or any custom media input:
   // @texture uMyTexture "Display Name"
   DO NOT declare uniform - it's auto-generated! Just use: vec4 texValue = texture(uMyTexture, uv);
   The user gets a "Select" button to pick an IMAGE or VIDEO from the media library.
   VIDEO TEXTURES: Videos play automatically and loop - perfect for animated overlays, motion effects, etc.
   
   ⚠️ RESERVED NAME - NEVER use "uTexture" for @texture! It's the built-in input texture.
   Use descriptive names instead: uOverlay, uNoiseMap, uLUT, uMask, etc.
   
   TEXTURE BEHAVIOR: When no texture is selected, @texture uniforms return WHITE (1,1,1,1).
   Design effects so they look neutral with white, then user's texture adds the effect.
   
   COMMON TEXTURE USE CASES - generate @texture when user asks for:
   - "add overlay" / "blend image" / "overlay texture" → // @texture uOverlay "Overlay Image"
   - "add LUT" / "color LUT" / "color grading" → // @texture uLUT "Color LUT"
   - "add mask" / "effect mask" / "alpha mask" → // @texture uMask "Effect Mask"
   - "add noise" / "noise texture" / "film grain texture" → // @texture uNoiseMap "Noise Texture"
   - "add pattern" / "tile pattern" / "texture pattern" → // @texture uPattern "Pattern Texture"
   - "displacement" / "distortion map" → // @texture uDisplacement "Displacement Map"
   - "dirt texture" / "lens dirt" / "scratches" → // @texture uDirt "Dirt/Scratches"
   - "vignette texture" / "custom vignette" → // @texture uVignetteTex "Vignette Texture"
   
   TEXTURE EXAMPLE (overlay blend - NO uniform declaration needed!):
   // @texture uOverlay "Overlay Texture"
   // @param uOverlayOpacity "Overlay Opacity" 0.5 0.0 1.0 0.01
   
   void mainImage(out vec4 fragColor, in vec2 fragCoord) {
       vec2 uv = fragCoord / uResolution.xy;
       vec4 original = texture(uTexture, uv);
       vec4 overlay = texture(uOverlay, uv);  // Just use the texture!
       vec3 blended = mix(original.rgb, overlay.rgb, overlay.a * uOverlayOpacity * uIntensity);
       fragColor = vec4(blended, original.a);
   }
   
   TEXTURE EXAMPLE (noise/grain - NO uniform declaration!):
   // @texture uNoiseMap "Noise Texture"
   // @param uGrainAmount "Grain Amount" 0.1 0.0 0.5 0.01
   // Sample: vec4 noise = texture(uNoiseMap, uv * 10.0); then add to image

5. Function: void mainImage(out vec4 fragColor, in vec2 fragCoord)

6. UV setup: vec2 uv = fragCoord / uResolution.xy;

7. Sample input: vec4 original = texture(uTexture, uv);

8. Blend with uIntensity: fragColor = mix(original, effect, uIntensity);

9. No markdown. Raw GLSL. Complete code, every { has }.

POST-FX EFFECT TYPES YOU CAN CREATE:
- Color grading: contrast, saturation, hue shift, color curves, LUT emulation
- Bloom & glow: extract bright areas, blur and add back
- Blur effects: gaussian, radial, motion blur, depth of field
- Distortion: chromatic aberration, barrel/pincushion, wave, ripple
- Glitch effects: RGB split, block displacement, scanlines, noise
- Film effects: grain, vignette, film damage, VHS, CRT
- Stylization: posterize, edge detection, halftone, pixelate, ASCII
- Color effects: duotone, sepia, thermal, night vision
- Lens effects: flare, dirt, anamorphic streaks
- Temporal: frame blending, trails, echo, datamosh

EXAMPLE of correct POST-FX output format:

// @name Retro CRT
// @group Scanlines
// @param uScanlineIntensity "Scanline Strength" 0.5 0.0 1.0 0.05
// @param uScanlineCount "Scanline Count" 400.0 100.0 800.0 50.0
// @group Curvature
// @param uCurvature "Screen Curvature" 0.3 0.0 1.0 0.05
// @group Bloom
// @param uBloom "Bloom Amount" 0.2 0.0 1.0 0.05
// @group Noise
// @param uNoiseAmount "Static Noise" 0.05 0.0 0.3 0.01
// @group Vignette
// @param uVignette "Vignette" 0.4 0.0 1.0 0.05

void mainImage(out vec4 fragColor, in vec2 fragCoord) {
    vec2 uv = fragCoord / uResolution.xy;
    vec2 center = vec2(0.5);
    
    // Apply CRT curvature
    vec2 curvedUV = uv;
    vec2 dc = uv - center;
    curvedUV = center + dc * (1.0 + dot(dc, dc) * uCurvature * uIntensity);
    
    // Sample original
    vec4 col = texture(uTexture, curvedUV);
    
    // Scanlines
    float scanline = sin(curvedUV.y * uScanlineCount * 3.14159) * 0.5 + 0.5;
    col.rgb *= 1.0 - (1.0 - scanline) * uScanlineIntensity * uIntensity;
    
    // Simple bloom
    vec4 bloom = vec4(0.0);
    float samples = 0.0;
    for (float x = -2.0; x <= 2.0; x += 1.0) {
        for (float y = -2.0; y <= 2.0; y += 1.0) {
            vec2 offset = vec2(x, y) / uResolution.xy * 3.0;
            bloom += texture(uTexture, curvedUV + offset);
            samples += 1.0;
        }
    }
    bloom /= samples;
    col.rgb += bloom.rgb * uBloom * uIntensity;
    
    // Static noise
    float noise = fract(sin(dot(fragCoord + uTime * 100.0, vec2(12.9898, 78.233))) * 43758.5453);
    col.rgb += (noise - 0.5) * uNoiseAmount * uIntensity;
    
    // Vignette
    float vig = 1.0 - dot(dc, dc) * uVignette * 2.0 * uIntensity;
    col.rgb *= vig;
    
    fragColor = col;
}

QUALITY GUIDELINES:
- Include at least 3-5 meaningful parameters with clear @group organization
- Always sample uTexture - this is the input you're processing!
- Scale all effects by uIntensity so users can control strength
- Use sensible default values that show the effect clearly
- Parameter ranges should allow both subtle and extreme settings
- For blur effects, use multiple samples for quality
- Consider edge cases (UV clamping, division by zero)

Output ONLY raw GLSL code. No markdown. No code fences. No explanations. No uniform declarations.
CRITICAL: Your shader MUST be syntactically complete. The final line must be a closing brace }.`;

  // System prompt for AI scene generation
  const SCENE_SYSTEM_PROMPT = `You are a WORLD-CLASS Three.js scene designer and 3D artist for a professional demoscene tool. Your scenes are known for being BREATHTAKINGLY BEAUTIFUL, technically sophisticated, and rich with detail.

YOUR GOAL: Create the most visually stunning, professionally crafted 3D scene possible. Think like a cinematographer and VFX artist combined. Every scene should be demo-worthy.

QUALITY PRINCIPLES:
- COMPOSITION: Use rule of thirds, leading lines, depth layering (foreground/mid/background)
- LIGHTING: Multiple light sources with purposeful colors, dramatic shadows, rim lighting
- MATERIALS: Rich PBR settings - vary metalness, roughness, add emissive accents for glow
- ATMOSPHERE: Always include fog, ambient occlusion, environmental depth
- DETAIL: Many objects, varying scales, interesting arrangements, not just centered primitives
- COLOR HARMONY: Cohesive color palette - complementary, triadic, or analogous schemes
- CONTRAST: Mix smooth and rough surfaces, large and small objects, bright and dark areas

Generate creative, visually stunning 3D scenes as JSON.

CRITICAL RULES:

1. Output valid JSON only. No markdown code fences. No explanations. Just the JSON object.

2. NEVER include animations for cameras or objects. All animations will be keyframed by the user in the timeline editor. Do NOT include "animation" properties on cameras or objects.

2. REQUIRED TOP-LEVEL STRUCTURE:
{
  "name": "Scene Name",
  "description": "Brief description of the scene",
  "background": "#hexcolor",
  "fog": { "color": "#hexcolor", "near": number, "far": number },
  "camera": { ... },
  "lights": [ ... ],
  "objects": [ ... ]
}

3. CAMERA CONFIGURATION - Include ALL parameters (NO animation - user will keyframe):
{
  "type": "perspective",  // or "orthographic"
  "fov": 60,              // Field of view 10-120 (perspective only)
  "near": 0.1,            // Near clipping plane
  "far": 1000,            // Far clipping plane
  "zoom": 1,              // Zoom factor
  "position": { "x": 0, "y": 2, "z": 10 },
  "rotation": { "x": 0, "y": 0, "z": 0 }  // ALWAYS use rotation instead of lookAt! Rotation in degrees. (0,0,0) = facing straight forward along -Z axis
  // DO NOT include "animation" - camera movement will be keyframed by user
  // DO NOT use "lookAt" - use "rotation" instead so users can keyframe camera direction
}

IMPORTANT CAMERA RULES:
- ALWAYS include "rotation" property with x, y, z in degrees
- NEVER use "lookAt" - it prevents proper keyframing of camera direction  
- Default rotation (0,0,0) means camera faces straight forward (-Z direction)
- To tilt camera down: use positive rotation.x (e.g., 15 degrees to look slightly down)
- To turn camera left: use positive rotation.y
- Increasing camera position.z moves camera backward, decreasing moves forward

4. LIGHTS - All Three.js light types with FULL parameters:

AMBIENT LIGHT:
{ 
  "type": "ambient", 
  "name": "Ambient",
  "color": "#hexcolor", 
  "intensity": 0.5 
}

DIRECTIONAL LIGHT (sun-like):
{ 
  "type": "directional", 
  "name": "Sun",
  "color": "#ffffff", 
  "intensity": 1.0,
  "position": { "x": 5, "y": 10, "z": 7 },
  "target": { "x": 0, "y": 0, "z": 0 },
  "castShadow": true,
  "shadow": {
    "mapSize": 2048,
    "bias": -0.0001,
    "normalBias": 0.02,
    "radius": 1
  }
}

POINT LIGHT (lightbulb):
{
  "type": "point", 
  "name": "Point Light",
  "color": "#hexcolor", 
  "intensity": 1.0,
  "position": { "x": 0, "y": 5, "z": 0 },
  "distance": 50,        // 0 = infinite range
  "decay": 2,            // Physically correct = 2
  "power": 800,          // Lumens (alternative to intensity)
  "castShadow": true
}

SPOT LIGHT (flashlight/stage light):
{
  "type": "spot", 
  "name": "Spotlight",
  "color": "#hexcolor", 
  "intensity": 1.0,
  "position": { "x": 0, "y": 10, "z": 0 },
  "target": { "x": 0, "y": 0, "z": 0 },
  "distance": 100,       // 0 = infinite
  "angle": 0.5,          // Max spread angle (radians, max PI/2)
  "penumbra": 0.3,       // Soft edge 0-1
  "decay": 2,
  "power": 800,
  "castShadow": true
}

HEMISPHERE LIGHT (sky/ground):
{
  "type": "hemisphere",
  "name": "Sky Light", 
  "skyColor": "#87ceeb",
  "groundColor": "#362312",
  "intensity": 0.6,
  "position": { "x": 0, "y": 50, "z": 0 }
}

RECT AREA LIGHT (soft box/window):
{
  "type": "rectArea",
  "name": "Soft Box",
  "color": "#ffffff",
  "intensity": 5,
  "width": 10,
  "height": 10,
  "position": { "x": 0, "y": 5, "z": 5 },
  "lookAt": { "x": 0, "y": 0, "z": 0 }
}

5. GEOMETRIES - All Three.js geometry types with ALL parameters:

BOX:
"geometry": "box",
"width": 1, "height": 1, "depth": 1,
"widthSegments": 1, "heightSegments": 1, "depthSegments": 1

SPHERE:
"geometry": "sphere",
"radius": 1,
"widthSegments": 32, "heightSegments": 16,
"phiStart": 0, "phiLength": 6.283,      // Horizontal sweep
"thetaStart": 0, "thetaLength": 3.142   // Vertical sweep

CYLINDER:
"geometry": "cylinder",
"radiusTop": 1, "radiusBottom": 1, "height": 2,
"radialSegments": 32, "heightSegments": 1,
"openEnded": false,
"thetaStart": 0, "thetaLength": 6.283

CONE:
"geometry": "cone",
"radius": 1, "height": 2,
"radialSegments": 32, "heightSegments": 1,
"openEnded": false,
"thetaStart": 0, "thetaLength": 6.283

TORUS (donut):
"geometry": "torus",
"radius": 1, "tube": 0.4,
"radialSegments": 16, "tubularSegments": 48,
"arc": 6.283

TORUS KNOT:
"geometry": "torusKnot",
"radius": 1, "tube": 0.4,
"tubularSegments": 64, "radialSegments": 8,
"p": 2, "q": 3   // Winding numbers

PLANE:
"geometry": "plane",
"width": 10, "height": 10,
"widthSegments": 1, "heightSegments": 1

CIRCLE:
"geometry": "circle",
"radius": 1, "segments": 32,
"thetaStart": 0, "thetaLength": 6.283

RING:
"geometry": "ring",
"innerRadius": 0.5, "outerRadius": 1,
"thetaSegments": 32, "phiSegments": 1,
"thetaStart": 0, "thetaLength": 6.283

DODECAHEDRON:
"geometry": "dodecahedron",
"radius": 1, "detail": 0

ICOSAHEDRON:
"geometry": "icosahedron",
"radius": 1, "detail": 0

OCTAHEDRON:
"geometry": "octahedron",
"radius": 1, "detail": 0

TETRAHEDRON:
"geometry": "tetrahedron",
"radius": 1, "detail": 0

CAPSULE:
"geometry": "capsule",
"radius": 1, "length": 2,
"capSegments": 10, "radialSegments": 20

LATHE (rotational sweep):
"geometry": "lathe",
"segments": 32, "phiStart": 0, "phiLength": 6.283,
"points": [[0,0], [0.5,0.5], [0.3,1], [0,1.5]]

EXTRUDE:
"geometry": "extrude",
"depth": 1, "bevelEnabled": true,
"bevelThickness": 0.1, "bevelSize": 0.1, "bevelSegments": 3

TUBE:
"geometry": "tube",
"radius": 0.5, "tubularSegments": 64, "radialSegments": 8,
"closed": false

6. MATERIALS - Complete Three.js material properties:

ALWAYS USE MeshStandardMaterial with ALL parameters exposed:

"material": {
  "type": "standard",
  
  // === COLOR & BASE APPEARANCE ===
  "color": "#ffffff",                    // Base color of the material
  "opacity": 1.0,                        // 0-1, requires transparent:true
  "transparent": false,                  // Enable transparency
  "alphaTest": 0,                        // 0-1, discard pixels below this alpha
  "visible": true,                       // Whether object is visible
  
  // === PBR PROPERTIES ===
  "metalness": 0.0,                      // 0=plastic/wood, 1=metal
  "roughness": 0.5,                      // 0=mirror smooth, 1=fully diffuse
  
  // === EMISSIVE (GLOW) ===
  "emissive": "#000000",                 // Emissive/glow color
  "emissiveIntensity": 1.0,              // Strength of emissive
  "emissiveMap": null,                   // Texture for emissive glow pattern
  
  // === TEXTURE MAPS ===
  "map": null,                           // Color/diffuse texture map
  "alphaMap": null,                      // Grayscale texture for opacity
  
  // === NORMAL MAPPING ===
  "normalMap": null,                     // RGB normal map texture
  "normalMapType": "tangent",            // "tangent" or "object"
  "normalScale": { "x": 1.0, "y": 1.0 }, // Normal map intensity
  
  // === BUMP MAPPING ===
  "bumpMap": null,                       // Grayscale bump texture
  "bumpScale": 1.0,                      // 0-1 bump intensity
  
  // === DISPLACEMENT MAPPING ===
  "displacementMap": null,               // Grayscale displacement texture
  "displacementScale": 1.0,              // Displacement amount
  "displacementBias": 0.0,               // Offset for displacement
  
  // === ROUGHNESS MAP ===
  "roughnessMap": null,                  // Texture for varying roughness (green channel)
  
  // === METALNESS MAP ===
  "metalnessMap": null,                  // Texture for varying metalness (blue channel)
  
  // === AMBIENT OCCLUSION ===
  "aoMap": null,                         // Ambient occlusion texture (red channel)
  "aoMapIntensity": 1.0,                 // AO intensity 0-1
  
  // === LIGHT MAP ===
  "lightMap": null,                      // Baked lighting texture
  "lightMapIntensity": 1.0,              // Light map intensity
  
  // === ENVIRONMENT MAP ===
  "envMap": null,                        // Environment/reflection map path
  "envMapIntensity": 1.0,                // Reflection intensity
  "envMapRotation": { "x": 0, "y": 0, "z": 0 }, // Env map rotation in radians
  
  // === RENDERING OPTIONS ===
  "side": "front",                       // "front", "back", "double"
  "flatShading": false,                  // Flat vs smooth shading
  "wireframe": false,                    // Render as wireframe
  "wireframeLinewidth": 1,               // Wireframe line thickness
  "fog": true,                           // Affected by scene fog
  "depthTest": true,                     // Enable depth testing
  "depthWrite": true,                    // Write to depth buffer
  
  // === BLENDING ===
  "blending": "normal",                  // "normal", "additive", "subtractive", "multiply"
  "premultipliedAlpha": false,
  "dithering": false,
  "toneMapped": true,
  "vertexColors": false
}

7. OBJECT STRUCTURE (NO animation - user will keyframe):
{
  "name": "object-name",
  "geometry": "sphere",
  "radius": 1,
  // ... geometry params
  
  "position": { "x": 0, "y": 0, "z": 0 },
  "rotation": { "x": 0, "y": 0, "z": 0 },  // degrees
  "scale": 1,  // or { "x": 1, "y": 1, "z": 1 }
  
  "material": { /* full MeshStandardMaterial object - see section 6 */ },
  
  "castShadow": true,
  "receiveShadow": true
  // DO NOT include "animation" - object animations will be keyframed by user
}

8. SPECIAL EFFECTS - UNLIMITED CREATIVITY!

Create ANY effect you can imagine. Use descriptive names. Include MAXIMUM parameters.

Examples (not limited to):
- Particle systems: snow, rain, sparks, fireflies, confetti, embers, magic dust
- Fields: grass, flowers, crystals, fur, coral, mycelium, neural networks
- Tunnels: voxel, neon, organic, data streams, wormholes
- Cloners: sphere arrays, grids, spirals, fractals, galaxies
- Volumetric: fog, aurora, nebulae, light shafts, smoke
- Liquid: water, lava, mercury, slime, oil
- Energy: plasma, electricity, force fields, magic circles, shields
- Organic: tentacles, roots, veins, branches, vines

STRUCTURE - include ALL relevant params:
"yourEffectName": {
  "enabled": true,
  "count": 10000,
  "size": 0.1, "sizeVariation": 0.5,
  "radius": 10, "length": 100,
  "density": 0.7, "segments": 32,
  "spread": { "x": 10, "y": 10, "z": 10 },
  "distribution": "random",
  
  "animation": {
    "speed": 1.0,
    "rotationSpeed": { "x": 0, "y": 0.1, "z": 0 },
    "waveAmplitude": 0.5, "waveFrequency": 2.0,
    "pulseAmount": 0.1, "pulseSpeed": 1.0,
    "flowSpeed": 0.5, "turbulence": 0.2,
    "noiseScale": 1.0, "noiseSpeed": 0.5
  },
  
  "displacement": {
    "enabled": true,
    "strength": 1.0, "frequency": 2.0,
    "speed": 0.5, "noiseScale": 1.0
  },
  
  "colors": {
    "primary": "#ffffff", "secondary": "#888888",
    "accent": "#ff0000", "emissive": "#000000",
    "emissiveIntensity": 0.5,
    "variation": 0.2, "hueShift": 0,
    "saturation": 1.0, "brightness": 1.0
  },
  
  "material": {
    "type": "physical",
    "metalness": 0.5, "roughness": 0.5,
    "transmission": 0, "ior": 1.5,
    "clearcoat": 0, "sheen": 0,
    "opacity": 1.0, "transparent": false,
    "envMapIntensity": 1.0, "wireframe": false
  },
  
  "glow": {
    "enabled": true, "strength": 1.0,
    "falloff": 2.0, "color": "#ffffff"
  }
}

CLONER EFFECTS - Clone objects on geometry of other objects:

Use "cloner" object type to distribute objects on vertices, faces, or surfaces of a source geometry.

CLONER STRUCTURE:
{
  "name": "Crystal Cloner",
  "cloner": {
    "enabled": true,
    
    // SOURCE GEOMETRY - where clones are placed
    "sourceGeometry": "sphere",        // "sphere", "box", "torus", "torusKnot", "icosahedron", "dodecahedron", "plane", "cylinder", "custom"
    "sourceRadius": 5,                  // For sphere/torus source
    "sourceSize": { "x": 10, "y": 10, "z": 10 },  // For box source
    "sourceSegments": 32,               // More segments = more clone positions
    
    // DISTRIBUTION MODE
    "mode": "vertices",                 // "vertices", "faces", "surface", "edges", "grid", "radial", "spiral"
    "count": 100,                       // Number of clones (for surface/random modes)
    "density": 1.0,                     // Density multiplier for distribution
    
    // CLONE GEOMETRY - what gets cloned
    "cloneGeometry": "box",             // Any geometry type
    "cloneSize": 0.2,                   // Base size of clones
    "cloneWidth": 0.2,                  // For box/plane clones
    "cloneHeight": 0.5,
    "cloneDepth": 0.2,
    "cloneRadius": 0.1,                 // For sphere/cylinder clones
    "cloneSegments": 8,                 // Geometry detail of clones
    
    // SIZE VARIATION
    "sizeVariation": 0.5,               // 0-1, random size variation
    "sizeMin": 0.1,                     // Minimum scale multiplier
    "sizeMax": 2.0,                     // Maximum scale multiplier
    "sizeByDistance": false,            // Scale based on distance from center
    "sizeByNormal": false,              // Scale based on surface normal
    
    // POSITION OFFSET
    "offset": { "x": 0, "y": 0, "z": 0 },        // Global offset
    "offsetVariation": { "x": 0.1, "y": 0.1, "z": 0.1 },  // Random position jitter
    "normalOffset": 0.0,                // Offset along surface normal
    "normalOffsetVariation": 0.2,       // Random normal offset variation
    
    // ROTATION
    "alignToNormal": true,              // Orient clones to surface normal
    "rotation": { "x": 0, "y": 0, "z": 0 },      // Base rotation (degrees)
    "rotationVariation": { "x": 0, "y": 360, "z": 0 },   // Random rotation variation
    "lookAtCenter": false,              // Orient clones toward center
    
    // ANIMATION (built-in)
    "animation": {
      "enabled": true,
      "rotationSpeed": { "x": 0, "y": 0.1, "z": 0 },
      "pulseSpeed": 1.0,
      "pulseAmount": 0.2,
      "waveSpeed": 0.5,
      "waveAmplitude": 0.3,
      "stagger": 0.1                    // Animation delay between clones
    },
    
    // MATERIAL
    "material": {
      "color": "#ffffff",
      "metalness": 0.8,
      "roughness": 0.2,
      "emissive": "#000000",
      "emissiveIntensity": 0,
      "colorVariation": 0.2,            // Random color hue variation
      "opacity": 1.0,
      "transparent": false
    }
  }
}

CLONER EXAMPLES:

1. Crystals on Icosahedron vertices:
"cloner": {
  "enabled": true,
  "sourceGeometry": "icosahedron",
  "sourceRadius": 3,
  "mode": "vertices",
  "cloneGeometry": "cone",
  "cloneRadius": 0.1,
  "cloneHeight": 0.8,
  "alignToNormal": true,
  "normalOffset": 0.1,
  "sizeVariation": 0.4,
  "rotationVariation": { "x": 10, "y": 0, "z": 10 }
}

2. Spheres scattered on torus surface:
"cloner": {
  "enabled": true,
  "sourceGeometry": "torus",
  "sourceRadius": 4,
  "mode": "surface",
  "count": 200,
  "cloneGeometry": "sphere",
  "cloneRadius": 0.15,
  "sizeVariation": 0.6,
  "normalOffset": 0.1,
  "material": { "metalness": 1.0, "roughness": 0.1 }
}

3. Grid array of cubes:
"cloner": {
  "enabled": true,
  "mode": "grid",
  "gridSize": { "x": 5, "y": 5, "z": 5 },
  "spacing": 1.5,
  "cloneGeometry": "box",
  "cloneSize": 0.3,
  "sizeVariation": 0.3,
  "rotationVariation": { "x": 45, "y": 45, "z": 45 },
  "animation": { "rotationSpeed": { "y": 0.2 } }
}

4. Spiral arrangement:
"cloner": {
  "enabled": true,
  "mode": "spiral",
  "count": 100,
  "spiralRadius": 5,
  "spiralHeight": 10,
  "spiralTurns": 3,
  "cloneGeometry": "sphere",
  "cloneRadius": 0.2,
  "sizeByDistance": true
}

9. CREATE RICH SCENES:
- Multiple objects with varied materials
- Background elements (large shapes with side: "back")
- Layered depth (foreground, mid, background)
- Multiple light types for interesting shadows
- At least one special effect
- Fog for atmosphere

10. MAXIMIZE PARAMETERS - For EVERY element include:
- ALL geometry properties
- ALL material properties (dozens of options above)
- ALL animation options
- ALL color variations
- Think: "What would an artist want to tweak?"

11. JSON VALIDATION:
- All strings quoted
- No trailing commas
- Brackets/braces matched
- Numbers unquoted

OUTPUT ONLY VALID JSON. No markdown. No code fences. No explanations.`;

  // System prompt for refining existing shaders
  const SHADER_REFINE_PROMPT = `You are an expert GLSL shader programmer. You are REFINING an existing shader based on user feedback.

IMPORTANT: You must return a COMPLETE, WORKING shader - not just the changes. The user cannot merge partial code.

The user will provide:
1. Their existing shader code
2. What changes they want

Your task:
1. Understand the existing shader
2. Apply the requested modifications
3. Return the COMPLETE modified shader

CRITICAL RULES (same as generating new shaders):
1. NEVER write "uniform float" or ANY uniform declaration - causes REDEFINITION ERROR!
2. Parameters via @param comments ONLY: // @param uSpeed "Speed" 1.0 0.1 5.0 0.1
3. For loop limits MUST cast to int: for(int i=0; i<int(uIterations); i++)
4. Pre-defined: iTime (float), iResolution (vec2) - NO uTexture in regular shaders!
5. Function: void mainImage(out vec4 fragColor, in vec2 fragCoord)
6. No markdown. Raw GLSL only. Complete code.

If adding new parameters, use the @param format:
// @param uNewParam "Label" default min max step

TEXTURE PARAMETERS - If user asks to add textures, images, noise maps, patterns:
Use @texture format: // @texture uTextureName "Display Name"
DO NOT declare uniform - it's auto-generated! Just use: vec4 tex = texture(uTextureName, uv);
The user gets a "Select" button to pick from media library.

⚠️ Regular shaders do NOT have uTexture! Use @texture for ALL textures you need.
⚠️ RESERVED NAMES - NEVER use these for @texture: iChannel0, iChannel1, iChannel2, iChannel3, uTexture
Use descriptive names: uOverlay, uNoiseMap, uPattern, uMask, uTexture1, uTexture2, etc.

Common texture requests:
- "add texture/image" → // @texture uOverlay "Overlay Image"
- "add noise texture" → // @texture uNoiseMap "Noise Texture"
- "add pattern" → // @texture uPattern "Pattern Texture"
- "add mask" → // @texture uMask "Effect Mask"

Output the COMPLETE shader code with all modifications applied. No explanations, just the code.`;

  const POSTFX_REFINE_PROMPT = `You are an expert GLSL shader programmer. You are REFINING an existing POST-PROCESSING effect based on user feedback.

IMPORTANT: You must return a COMPLETE, WORKING shader - not just the changes.

The user will provide:
1. Their existing post-fx code
2. What changes they want

CRITICAL RULES (same as generating new post-fx):
1. NEVER write "uniform float" or ANY uniform declaration!
2. Parameters via @param comments ONLY
3. Pre-defined: uTexture (sampler2D), uResolution (vec2), uTime (float), uIntensity (float)
4. Sample input: vec4 original = texture(uTexture, uv);
5. Blend with uIntensity: fragColor = mix(original, effect, uIntensity);
6. No markdown. Raw GLSL only.

TEXTURE PARAMETERS - If user asks to add textures, overlays, LUTs, noise, masks, patterns:
Use @texture format: // @texture uTextureName "Display Name"
DO NOT declare uniform - it's auto-generated! Just use: vec4 tex = texture(uTextureName, uv);
The user gets a "Select" button to pick from media library.

⚠️ RESERVED NAME - NEVER use "uTexture" for @texture! It's the built-in input texture.
Use descriptive names: uOverlay, uNoiseMap, uLUT, uMask, etc.

Common texture requests:
- "add overlay" / "blend image" → // @texture uOverlay "Overlay Image"
- "add LUT" / "color grading" → // @texture uLUT "Color LUT"
- "add mask" → // @texture uMask "Effect Mask"
- "add noise texture" / "grain texture" → // @texture uNoiseMap "Noise Texture"
- "add pattern" → // @texture uPattern "Pattern Texture"
- "lens dirt" / "scratches" → // @texture uDirt "Dirt/Scratches"

Output the COMPLETE shader code with all modifications applied. No explanations.`;

  const LUT_SYSTEM_PROMPT = `You are an expert colorist creating .cube LUT files for color grading.

Generate a .cube format Look-Up Table (LUT) file based on the user's description.

.CUBE FILE FORMAT:
- Line 1: TITLE "LUT Name"
- Line 2: LUT_3D_SIZE 17  (ALWAYS use 17 - optimal size)
- Line 3: Empty line
- Lines 4+: RGB triplets (R G B), values 0.0 to 1.0

For a 17x17x17 LUT, you need 4913 lines of RGB values (17^3).
Values are organized as: for each blue (0-16), for each green (0-16), for each red (0-16).

COLOR GRADING TECHNIQUES:
- Cinematic looks: Teal shadows + orange highlights (Hollywood look)
- Film emulation: Simulate Kodak Portra, Fuji, Ektachrome, etc.
- Mood-based: Warm/cold, desaturated, high contrast
- Creative: Color inversion, duo-tone, tritone
- Corrections: White balance shifts, exposure compensation

MATHEMATICAL TRANSFORMS:
For each input color (r, g, b) where each is 0.0 to 1.0:
- Apply your color transformation
- Output the transformed (r, g, b) values

EXAMPLE - Sepia tone:
Transform: new_r = 0.393*r + 0.769*g + 0.189*b (clamped 0-1)
           new_g = 0.349*r + 0.686*g + 0.168*b
           new_b = 0.272*r + 0.534*g + 0.131*b

OUTPUT: Return ONLY the complete .cube file. No markdown, no explanations.
The first line is TITLE, second is LUT_3D_SIZE 17, then 4913 RGB triplet lines.`;

  const LUT_REFINE_PROMPT = `You are an expert colorist refining an existing .cube LUT file.

The user will provide:
1. Their existing LUT file content
2. What changes they want (e.g., "make it warmer", "more contrast", "less saturated")

ANALYZE the existing LUT to understand its current color transformation, then MODIFY it based on the user's request.

RETURN the complete modified .cube file. Keep TITLE updated to reflect changes.
LUT_3D_SIZE must stay 17.
Return ONLY the .cube file content. No markdown, no explanations.`;

  function getGeminiApiKey() {
    return aiPreferences.getKey('gemini');
  }
  
  function setGeminiApiKey(key) {
    aiPreferences.setKey('gemini', key);
  }
  
  function getClaudeApiKey() {
    return aiPreferences.getKey('claude');
  }
  
  function setClaudeApiKey(key) {
    aiPreferences.setKey('claude', key);
  }
  
  function getAiProvider() {
    return aiPreferences.getProvider();
  }
  
  function setAiProvider(provider) {
    aiPreferences.setProvider(provider);
  }
  
  function hasValidApiKey() {
    return aiPreferences.hasCurrentKey();
  }
  
  function getCurrentApiKey() {
    return aiPreferences.getCurrentKey();
  }
  
  function openAiGenerator(forceType = null) {
    // Reset refine mode
    aiRefineMode = false;
    aiRefineCode = '';
    aiRefineFilePath = '';

    // Determine what we're generating based on asset browser mode or forced type
    if (forceType) {
      aiGeneratorType = forceType;
    } else if (assetBrowserMode === 'scenes') {
      aiGeneratorType = 'scene';
    } else if (assetBrowserMode === 'postfx') {
      aiGeneratorType = 'postfx';
    } else if (assetBrowserMode === 'lut') {
      aiGeneratorType = 'lut';
    } else {
      aiGeneratorType = 'shader';
    }

    // Update modal title and labels based on type
    if (aiGeneratorType === 'scene') {
      aiGeneratorTitle.textContent = 'AI Scene Generator';
      aiPromptHint.textContent = 'Describe the 3D scene you want to create. Include details about objects, lighting, colors, special effects, and animations.';
      aiPromptLabel.textContent = 'DESCRIBE YOUR SCENE';
      shaderPromptInput.placeholder = 'e.g., A futuristic city with floating neon cubes, metallic spheres orbiting a central glowing core, purple and cyan lighting...';
      aiNameLabel.textContent = 'SCENE NAME';
      aiShaderNameInput.placeholder = 'My Custom Scene';
      generateShaderBtn.innerHTML = '<span class="btn-icon">✨</span> Generate Scene';
    } else if (aiGeneratorType === 'postfx') {
      aiGeneratorTitle.textContent = 'AI Post-FX Generator';
      aiPromptHint.textContent = 'Describe the post-processing effect you want. Post-FX are applied on top of the rendered scene (bloom, blur, color grading, glitch, etc).';
      aiPromptLabel.textContent = 'DESCRIBE YOUR POST-FX';
      shaderPromptInput.placeholder = 'e.g., A dreamy bloom effect with chromatic aberration, subtle film grain, and warm color grading...';
      aiNameLabel.textContent = 'POST-FX NAME';
      aiShaderNameInput.placeholder = 'My Custom Post-FX';
      generateShaderBtn.innerHTML = '<span class="btn-icon">✨</span> Generate Post-FX';
    } else if (aiGeneratorType === 'lut') {
      aiGeneratorTitle.textContent = 'AI LUT Generator';
      aiPromptHint.textContent = 'Describe the color grading look you want. Include mood, style, reference films, or color characteristics (warm, cool, vintage, cinematic, etc).';
      aiPromptLabel.textContent = 'DESCRIBE YOUR LUT';
      shaderPromptInput.placeholder = 'e.g., Cinematic teal and orange look, warm vintage film with faded blacks, cool moody night scene...';
      aiNameLabel.textContent = 'LUT NAME';
      aiShaderNameInput.placeholder = 'My Custom LUT';
      generateShaderBtn.innerHTML = '<span class="btn-icon">✨</span> Generate LUT';
    } else {
      aiGeneratorTitle.textContent = 'AI Shader Generator';
      aiPromptHint.textContent = 'Describe the shader effect you want to create. Be specific about colors, patterns, animations, and effects.';
      aiPromptLabel.textContent = 'DESCRIBE YOUR SHADER';
      shaderPromptInput.placeholder = 'e.g., A colorful spiral tunnel with neon colors shifting through the rainbow...';
      aiNameLabel.textContent = 'SHADER NAME';
      aiShaderNameInput.placeholder = 'My Custom Shader';
      generateShaderBtn.innerHTML = '<span class="btn-icon">✨</span> Generate Shader';
    }
    
    const hasKey = hasValidApiKey();
    
    // Restore saved provider selection
    const savedProvider = getAiProvider();
    const providerSelect = document.getElementById('aiProviderSelect');
    if (providerSelect) {
      providerSelect.value = savedProvider;
      updateProviderKeySection(savedProvider);
    }
    
    // Pre-fill API keys
    const geminiKeyInput = document.getElementById('geminiApiKey');
    const claudeKeyInput = document.getElementById('claudeApiKey');
    if (geminiKeyInput) geminiKeyInput.value = getGeminiApiKey();
    if (claudeKeyInput) claudeKeyInput.value = getClaudeApiKey();
    
    aiApiKeySection.style.display = hasKey ? 'none' : 'block';
    aiPromptSection.style.display = hasKey ? 'block' : 'none';
    changeApiKeyBtn.style.display = hasKey ? 'block' : 'none';
    hideAiStatus();
    shaderPromptInput.value = '';
    aiShaderNameInput.value = '';
    aiGeneratorModal.classList.add('show');
  }
  
  // Open AI generator in refine mode for an existing shader/postfx
  async function openAiRefiner(shaderFilePath, type = 'shader') {
    console.log('[AI-REFINE] Opening refiner for:', shaderFilePath, 'type:', type);

    // Construct full path if only filename is provided
    let fullPath = shaderFilePath;
    if (!shaderFilePath.includes('/')) {
      // Just a filename, construct full path based on type
      if (type === 'postfx') {
        fullPath = 'assets/post-fx/' + shaderFilePath;
      } else if (type === 'shader') {
        fullPath = 'assets/shaders/' + shaderFilePath;
      } else if (type === 'lut') {
        fullPath = 'assets/luts/' + shaderFilePath;
      }
    }

    // Fetch the existing shader code
    try {
      const response = await fetch('/' + fullPath);
      if (!response.ok) throw new Error('Failed to fetch shader');
      aiRefineCode = await response.text();
    } catch (err) {
      console.error('[AI-REFINE] Failed to load shader:', err);
      alert('Failed to load shader code for refinement');
      return;
    }
    
    // Set refine mode
    aiRefineMode = true;
    aiRefineFilePath = shaderFilePath;
    aiGeneratorType = type;
    
    // Extract name from @name comment, TITLE line (for LUTs), or filename
    let itemName;
    if (type === 'lut') {
      const titleMatch = aiRefineCode.match(/TITLE\s+"?([^"\n]+)"?/);
      itemName = titleMatch ? titleMatch[1].trim() : shaderFilePath.split('/').pop().replace('.cube', '');
    } else {
      const nameMatch = aiRefineCode.match(/\/\/\s*@name\s+(.+)/);
      itemName = nameMatch ? nameMatch[1].trim() : shaderFilePath.split('/').pop().replace('.glsl', '');
    }

    // Update modal for refine mode
    const typeLabel = type === 'postfx' ? 'Post-FX' : (type === 'lut' ? 'LUT' : 'Shader');
    aiGeneratorTitle.textContent = `Refine ${typeLabel} with AI`;
    if (type === 'lut') {
      aiPromptHint.textContent = `Describe what changes you want to make to "${itemName}". Examples: "Make it warmer", "Add more contrast", "Shift towards teal and orange".`;
      shaderPromptInput.placeholder = 'e.g., Make it warmer, add more contrast, increase saturation in shadows...';
    } else {
      aiPromptHint.textContent = `Describe what changes you want to make to "${itemName}". Examples: "Add a parameter for background color", "Make it pulse to the beat", "Change colors to blue and gold".`;
      shaderPromptInput.placeholder = 'e.g., Add a parameter for glow intensity, make the pattern rotate faster, add a vignette effect...';
    }
    aiPromptLabel.textContent = 'DESCRIBE YOUR CHANGES';
    aiNameLabel.textContent = `${typeLabel.toUpperCase()} NAME`;
    aiShaderNameInput.value = itemName;
    aiShaderNameInput.placeholder = itemName;
    generateShaderBtn.innerHTML = `<span class="btn-icon">🔧</span> Refine ${typeLabel}`;
    
    const hasKey = hasValidApiKey();
    
    // Restore saved provider selection
    const savedProvider = getAiProvider();
    const providerSelect = document.getElementById('aiProviderSelect');
    if (providerSelect) {
      providerSelect.value = savedProvider;
      updateProviderKeySection(savedProvider);
    }
    
    // Pre-fill API keys
    const geminiKeyInput = document.getElementById('geminiApiKey');
    const claudeKeyInput = document.getElementById('claudeApiKey');
    if (geminiKeyInput) geminiKeyInput.value = getGeminiApiKey();
    if (claudeKeyInput) claudeKeyInput.value = getClaudeApiKey();
    
    aiApiKeySection.style.display = hasKey ? 'none' : 'block';
    aiPromptSection.style.display = hasKey ? 'block' : 'none';
    changeApiKeyBtn.style.display = hasKey ? 'block' : 'none';
    hideAiStatus();
    shaderPromptInput.value = '';
    aiGeneratorModal.classList.add('show');
    
    console.log('[AI-REFINE] Loaded shader code:', aiRefineCode.length, 'chars');
  }
  
  function updateProviderKeySection(provider) {
    const geminiSection = document.getElementById('geminiKeySection');
    const claudeSection = document.getElementById('claudeKeySection');
    if (geminiSection) geminiSection.style.display = provider === 'gemini' ? 'block' : 'none';
    if (claudeSection) claudeSection.style.display = provider === 'claude' ? 'block' : 'none';
  }
  
  function closeAiGenerator() {
    aiGeneratorModal.classList.remove('show');
  }
  
  function showAiStatus(message, type = 'info', progress = null, streamText = null) {
    aiStatusPresenter.show(message, type, progress, streamText);
  }
  
  function hideAiStatus() {
    aiStatusPresenter.hide();
  }
  
  // Gemini API generation with streaming
  
  // Claude API generation via server proxy (to handle CORS and keep API key secure)
  
  async function generateShaderWithAI() {
    const provider = getAiProvider();
    const apiKey = getCurrentApiKey();
    const prompt = shaderPromptInput.value.trim();
    const isScene = aiGeneratorType === 'scene';
    const isPostFx = aiGeneratorType === 'postfx';
    const isShader = aiGeneratorType === 'shader';
    const isLut = aiGeneratorType === 'lut';
    const isRefine = aiRefineMode && (isPostFx || isShader || isLut); // Refine mode for shaders/postfx/luts

    let itemName = aiShaderNameInput.value.trim();
    if (!itemName) {
      if (isScene) itemName = 'AI Generated Scene';
      else if (isPostFx) itemName = 'AI Generated Post-FX';
      else if (isLut) itemName = 'AI Generated LUT';
      else itemName = 'AI Generated Shader';
    }

    const itemType = isScene ? 'scene' : (isPostFx ? 'post-FX' : (isLut ? 'LUT' : 'shader'));
    const buttonText = isRefine
      ? `<span class="btn-icon">🔧</span> Refine ${isPostFx ? 'Post-FX' : 'Shader'}`
      : (isScene ? '<span class="btn-icon">✨</span> Generate Scene' : (isPostFx ? '<span class="btn-icon">✨</span> Generate Post-FX' : (isLut ? '<span class="btn-icon">✨</span> Generate LUT' : '<span class="btn-icon">✨</span> Generate Shader')));
    
    if (!prompt) {
      const promptType = isRefine ? 'changes' : 'description';
      showAiStatus(`Please enter ${promptType} for your ${itemType}.`, 'error');
      return;
    }
    
    const providerName = provider === 'claude' ? 'Claude AI' : 'Gemini AI';
    
    generateShaderBtn.disabled = true;
    generateShaderBtn.innerHTML = isRefine ? '<span class="btn-icon">⏳</span> Refining...' : '<span class="btn-icon">⏳</span> Generating...';
    showAiStatus(`Connecting to ${providerName}...`, 'info', 5);
    
    try {
      // Select system prompt and user prompt based on type and mode
      let systemPrompt, userPrompt;
      
      if (isRefine) {
        // Refine mode - include existing code
        if (isPostFx) {
          systemPrompt = POSTFX_REFINE_PROMPT;
          userPrompt = `Here is the existing post-fx shader code:

\`\`\`glsl
${aiRefineCode}
\`\`\`

Please modify this shader with the following changes: ${prompt}

Return the COMPLETE modified shader code.`;
        } else if (isLut) {
          systemPrompt = LUT_REFINE_PROMPT;
          userPrompt = `Here is the existing LUT file:

${aiRefineCode}

Please modify this LUT with the following changes: ${prompt}

Return the COMPLETE modified .cube LUT file.`;
        } else {
          systemPrompt = SHADER_REFINE_PROMPT;
          userPrompt = `Here is the existing shader code:

\`\`\`glsl
${aiRefineCode}
\`\`\`

Please modify this shader with the following changes: ${prompt}

Return the COMPLETE modified shader code.`;
        }
        console.log('[AI-GEN] Refine mode - existing code:', aiRefineCode.length, 'chars');
      } else if (isScene) {
        systemPrompt = SCENE_SYSTEM_PROMPT;
        userPrompt = `Create a Three.js scene based on this description: ${prompt}`;
      } else if (isPostFx) {
        systemPrompt = POSTFX_SYSTEM_PROMPT;
        userPrompt = `Create a post-processing effect based on this description: ${prompt}`;
      } else if (isLut) {
        systemPrompt = LUT_SYSTEM_PROMPT;
        userPrompt = `Create a LUT based on this description: ${prompt}`;
      } else {
        systemPrompt = SHADER_SYSTEM_PROMPT;
        userPrompt = `Create a shader based on this description: ${prompt}`;
      }
      
      showAiStatus('Starting generation...', 'info', 10);
      
      let generatedCode = '';
      
      const statusMessages = [
        'Analyzing your request...',
        'Designing the structure...',
        'Writing the code...',
        'Adding details...',
        'Refining the output...',
        'Almost there...',
        'Finalizing...'
      ];
      
      if (provider === 'claude') {
        // Claude API call via proxy (to handle CORS)
        generatedCode = await generateWithClaude(
          apiKey, systemPrompt, userPrompt, isScene, statusMessages,
          { onStatus: showAiStatus }
        );
      } else {
        // Gemini API call (direct, no CORS issues with their API)
        generatedCode = await generateWithGemini(
          apiKey, systemPrompt, userPrompt, isScene, statusMessages,
          { onStatus: showAiStatus }
        );
      }
      
      console.log('[AI-GEN] Raw generated code length:', generatedCode.length);
      
      // Extract code from markdown code blocks if present
      const jsonMatch = generatedCode.match(/\`\`\`(?:json)?\n?([\s\S]*?)\`\`\`/);
      const glslMatch = generatedCode.match(/\`\`\`(?:glsl)?\n?([\s\S]*?)\`\`\`/);
      if (isScene && jsonMatch) {
        console.log('[AI-GEN] Extracted JSON from markdown block');
        generatedCode = jsonMatch[1].trim();
      } else if (!isScene && glslMatch) {
        console.log('[AI-GEN] Extracted GLSL from markdown block');
        generatedCode = glslMatch[1].trim();
      }
      
      // Remove any remaining markdown code fences
      generatedCode = generatedCode.replace(/^```(?:json|glsl)?[\r\n]*/gm, '');
      generatedCode = generatedCode.replace(/[\r\n]*```$/gm, '');
      generatedCode = generatedCode.trim();
      
      console.log('[AI-GEN] Cleaned code length:', generatedCode.length);
      console.log('[AI-GEN] Code preview:', generatedCode.slice(0, 200));
      
      showAiStatus('Validating generated code...', 'info', 88);
      
      if (isScene) {
        // SCENE VALIDATION
        let sceneData;
        try {
          sceneData = JSON.parse(generatedCode);
        } catch (e) {
          throw new Error(`Invalid JSON: ${e.message}. The AI may have generated malformed JSON.`);
        }
        
        // Validate required fields
        if (!sceneData.name) sceneData.name = itemName;
        if (!sceneData.camera) {
          throw new Error('Invalid scene: missing camera configuration.');
        }
        if (!sceneData.lights || !Array.isArray(sceneData.lights)) {
          sceneData.lights = [{ type: 'ambient', color: '#ffffff', intensity: 0.5 }];
        }
        if (!sceneData.objects) {
          sceneData.objects = [];
        }
        
        // Update name from scene data
        if (sceneData.name && !aiShaderNameInput.value.trim()) {
          itemName = sceneData.name;
        }
        
        // Re-stringify with proper formatting
        generatedCode = JSON.stringify(sceneData, null, 2);
        
        showAiStatus('Saving scene...', 'info', 90);
        
        // Save the scene file
        const filename = itemName.toLowerCase().replace(/[^a-z0-9]+/g, '_') + '_' + Date.now() + '.json';
        const saveResponse = await api.assets.saveGenerated(
          filename, generatedCode, 'scenes'
        );
        
        if (!saveResponse.ok) {
          throw new Error('Failed to save scene file');
        }
        
        showAiStatus(`Scene "${itemName}" created successfully!`, 'success', 100);

      } else if (isLut) {
        // LUT VALIDATION
        console.log('[AI-GEN] Starting LUT validation...');

        // Clean up any markdown code blocks
        generatedCode = generatedCode.replace(/```(?:cube)?\n?/g, '').replace(/```$/g, '').trim();

        // Validate LUT format
        if (!generatedCode.includes('TITLE') || !generatedCode.includes('LUT_3D_SIZE')) {
          throw new Error('Generated content does not appear to be a valid .cube LUT file.');
        }

        showAiStatus('Saving LUT...', 'info', 90);

        // Generate filename
        const lutFilename = itemName.toLowerCase().replace(/[^a-z0-9]+/g, '_') + '_' + Date.now() + '.cube';

        const saveResponse = await api.assets.saveGenerated(
          lutFilename, generatedCode, 'luts'
        );

        if (!saveResponse.ok) {
          throw new Error('Failed to save LUT file');
        }

        const saveResult = await saveResponse.json();
        console.log('[AI-GEN] LUT saved:', saveResult);

        showAiStatus(`LUT "${itemName}" created successfully!`, 'success', 100);

      } else {
        // SHADER VALIDATION
        console.log('[AI-GEN] Starting shader validation...');
        const openBraces = (generatedCode.match(/\{/g) || []).length;
        const closeBraces = (generatedCode.match(/\}/g) || []).length;
        const openParens = (generatedCode.match(/\(/g) || []).length;
        const closeParens = (generatedCode.match(/\)/g) || []).length;
        
        console.log('[AI-GEN] Brace check:', { openBraces, closeBraces });
        console.log('[AI-GEN] Paren check:', { openParens, closeParens });
        
        if (openBraces !== closeBraces) {
          throw new Error(`Incomplete shader: mismatched braces (${openBraces} open, ${closeBraces} close). The AI may have hit its output limit. Try requesting a simpler effect.`);
        }
        
        if (openParens !== closeParens) {
          throw new Error(`Incomplete shader: mismatched parentheses (${openParens} open, ${closeParens} close). The AI may have hit its output limit. Try requesting a simpler effect.`);
        }
        
        // Check for mainImage function
        console.log('[AI-GEN] Has mainImage:', generatedCode.includes('void mainImage'));
        if (!generatedCode.includes('void mainImage')) {
          throw new Error('Invalid shader: missing mainImage function. The AI may have hit its output limit.');
        }
        
        // Check that mainImage has a proper closing
        const mainImageMatch = generatedCode.match(/void\s+mainImage\s*\([^)]*\)\s*\{/);
        if (mainImageMatch) {
          const afterMainImage = generatedCode.slice(mainImageMatch.index + mainImageMatch[0].length);
          if (!afterMainImage.includes('fragColor') && !afterMainImage.includes('gl_FragColor')) {
            throw new Error('Incomplete shader: mainImage function appears incomplete. Try requesting a simpler effect.');
          }
        }
        
        // Update shader name from generated code if present
        const nameMatch = generatedCode.match(/\/\/\s*@name\s+(.+)/);
        if (nameMatch && !aiShaderNameInput.value.trim()) {
          itemName = nameMatch[1].trim();
        }
        
        console.log('[AI-GEN] Validation passed, saving shader...');
        showAiStatus(`Saving ${isPostFx ? 'post-FX' : 'shader'}...`, 'info', 90);
        
        // Determine filename - in refine mode, create a new version with -2, -3 etc suffix
        let filename;
        if (isRefine && aiRefineFilePath) {
          // Create versioned filename based on original
          const originalName = aiRefineFilePath.split('/').pop();
          const baseName = originalName.replace('.glsl', '');
          
          // Check if already has version suffix like -2, -3, etc
          const versionMatch = baseName.match(/-(\d+)$/);
          let newBaseName;
          if (versionMatch) {
            // Increment existing version: shader-2 -> shader-3
            const currentVersion = parseInt(versionMatch[1]);
            newBaseName = baseName.replace(/-\d+$/, `-${currentVersion + 1}`);
          } else {
            // Add version suffix: shader -> shader-2
            newBaseName = `${baseName}-2`;
          }
          
          filename = `${newBaseName}.glsl`;
          itemName = newBaseName.replace(/_/g, ' '); // Update display name too
          console.log('[AI-GEN] Refine mode: creating new version:', filename);
        } else {
          filename = itemName.toLowerCase().replace(/[^a-z0-9]+/g, '_') + '_' + Date.now() + '.glsl';
        }
        console.log('[AI-GEN] Saving to filename:', filename);
        
        // Ensure @name is at the top (update if exists, add if not)
        if (generatedCode.match(/\/\/\s*@name\s+.+/)) {
          generatedCode = generatedCode.replace(/\/\/\s*@name\s+.+/, `// @name ${itemName}`);
        } else {
          generatedCode = `// @name ${itemName}\n${generatedCode}`;
        }
        
        try {
          // Add timeout to save request
          const saveController = new AbortController();
          const saveTimeout = setTimeout(() => saveController.abort(), 30000);
          
          console.log('[AI-GEN] Sending save request...');
          const saveResponse = await api.assets.saveGenerated(
            filename,
            generatedCode,
            isPostFx ? 'postfx' : 'shaders',
            { signal: saveController.signal },
          );
          
          clearTimeout(saveTimeout);
          console.log('[AI-GEN] Save response received, status:', saveResponse.status);
          
          if (!saveResponse.ok) {
            const errorText = await saveResponse.text();
            console.error('[AI-GEN] Save failed:', errorText);
            throw new Error(`Failed to save ${isPostFx ? 'post-FX' : 'shader'} file`);
          }
          
          const saveResult = await saveResponse.json();
          console.log('[AI-GEN] Save successful! Result:', saveResult);
          
          // If refining, swap the old shader with the new one on the timeline
          if (isRefine && aiRefineFilePath) {
            const newShaderPath = saveResult.path || `assets/${isPostFx ? 'post-fx' : 'shaders'}/${filename}`;
            console.log('[AI-GEN] Swapping shader on timeline:', aiRefineFilePath, '->', newShaderPath);
            
            // Find and update all segments using the old shader
            let swapCount = 0;
            
            for (const track of timelineTracks) {
              if (!track.segments) continue;
              
              for (const seg of track.segments) {
                // Check both shader and postfx segments
                if (seg.shaderFile === aiRefineFilePath) {
                  seg.shaderFile = newShaderPath;
                  swapCount++;
                  console.log('[AI-GEN] Updated segment to use new shader');
                }
              }
            }
            
            if (swapCount > 0) {
              // Reload the new shader so it's available for rendering
              try {
                if (isPostFx) {
                  // Load post-fx shader inline
                  const pfxResponse = await fetch(newShaderPath.startsWith('/') ? newShaderPath : '/' + newShaderPath);
                  if (pfxResponse.ok) {
                    const shaderSource = await pfxResponse.text();
                    const { effectName, params, textureParams } = parseShaderParams(shaderSource);
                    loadedPostFxShaders[newShaderPath] = {
                      source: shaderSource,
                      threeMaterial: null,
                      effectName: effectName || filename.replace(/\.(glsl|frag|fs)$/i, ''),
                      params: params,
                      textureParams: textureParams || [],
                    };
                    console.log('[AI-GEN] Loaded new post-fx shader:', newShaderPath);
                  }
                } else {
                  await loadShaderEffect(newShaderPath);
                }
              } catch (loadErr) {
                console.warn('[AI-GEN] Could not preload new shader:', loadErr);
              }
              
              // Save timeline and refresh UI
              scheduleTimelineAutoSave();
              renderTracks();
              
              // Re-populate params if we have a selected object
              if (selectedSceneObject?.seg?.shaderFile === newShaderPath) {
                if (isPostFx) {
                  populatePostFxParams(selectedSceneObject.seg);
                } else {
                  populateShaderParams(selectedSceneObject.seg);
                }
              }
              
              console.log('[AI-GEN] Swapped', swapCount, 'segment(s) to new shader');
            }
          } else if (!isRefine && (isPostFx || isShader)) {
            // Auto-add new shader/postfx to timeline
            const newShaderPath = saveResult.path || `assets/${isPostFx ? 'post-fx' : 'shaders'}/${filename}`;
            console.log('[AI-GEN] Auto-adding to timeline:', newShaderPath);

            try {
              // Find or create a track of the appropriate type
              const trackType = isPostFx ? 'postfx' : 'shader';
              let track = timelineTracks.find(t => t.type === trackType);
              if (!track) {
                track = addTrack(trackType);
              }

              // Create new segment
              const currentTime = getCurrentTime();
              const defaultDuration = 15;
              const placement = newClipPlacement(track, currentTime, defaultDuration);
              let newSeg;

              if (isPostFx) {
                postfxIdCounter++;
                newSeg = {
                  id: `postfx${postfxIdCounter}`,
                  name: itemName,
                  shaderFile: newShaderPath,
                  start: placement.start,
                  end: placement.end,
                  color: `hsl(${30 + postfxIdCounter * 15}, 70%, 55%)`,
                };

                // Preload the shader
                const pfxResponse = await fetch(newShaderPath.startsWith('/') ? newShaderPath : '/' + newShaderPath);
                if (pfxResponse.ok) {
                  const shaderSource = await pfxResponse.text();
                  const { effectName, params, textureParams } = parseShaderParams(shaderSource);
                  loadedPostFxShaders[newShaderPath] = {
                    source: shaderSource,
                    threeMaterial: null,
                    effectName: effectName || itemName,
                    params: params,
                    textureParams: textureParams || [],
                  };
                }
              } else {
                shaderIdCounter++;
                newSeg = {
                  id: `shader${shaderIdCounter}`,
                  name: itemName,
                  shaderFile: newShaderPath,
                  start: placement.start,
                  end: placement.end,
                  color: `hsl(${270 + shaderIdCounter * 25}, 60%, 55%)`,
                  blendMode: 'source-over',
                  opacity: 1.0,
                  uniforms: {}
                };

                // Preload the shader
                await loadShaderEffect(newShaderPath);
              }

              // Add segment to track
              track.segments.push(newSeg);
              extendSilentTimelineTo(newSeg.end);

              // Save and render
              scheduleTimelineAutoSave();
              renderTracks();

              // Select the new segment
              const segIndex = track.segments.length - 1;
              selectSegmentInTrack(track.id, segIndex);

              // Close the asset browser since we've handled the selection
              closeAssetBrowser(false);

              console.log('[AI-GEN] Added new segment to timeline:', newSeg.id);
            } catch (addErr) {
              console.warn('[AI-GEN] Could not auto-add to timeline:', addErr);
            }
          }
        } catch (saveErr) {
          if (saveErr.name === 'AbortError') {
            console.error('[AI-GEN] Save request timed out after 30s');
            throw new Error('Save request timed out');
          }
          console.error('[AI-GEN] Save error:', saveErr);
          throw saveErr;
        }
        
        const successVerb = isRefine ? 'refined' : 'created';
        const swapNote = isRefine ? ' (swapped on timeline)' : '';
        showAiStatus(`${isPostFx ? 'Post-FX' : 'Shader'} "${itemName}" ${successVerb} successfully!${swapNote}`, 'success', 100);
      }
      
      console.log('[AI-GEN] Generation complete, refreshing assets in 1.5s...');
      // Refresh asset list and close modal after a short delay
      setTimeout(async () => {
        try {
          console.log('[AI-GEN] Refreshing assets...');
          await loadAssets();
          console.log('[AI-GEN] Assets refreshed');
        } catch (refreshErr) {
          console.warn('[AI-GEN] Asset refresh failed:', refreshErr);
        }
        console.log('[AI-GEN] Closing modal...');
        closeAiGenerator();
        generateShaderBtn.disabled = false;
        generateShaderBtn.innerHTML = buttonText;
        console.log('[AI-GEN] Done!');
      }, 1500);
      
      // Fallback: force close modal after 5 seconds in case something goes wrong
      setTimeout(() => {
        if (aiGeneratorModal.classList.contains('show')) {
          console.log('[AI-GEN] Fallback: forcing modal close after 5s');
          closeAiGenerator();
          generateShaderBtn.disabled = false;
          generateShaderBtn.innerHTML = buttonText;
        }
      }, 5000);
      
    } catch (err) {
      console.error('AI generation failed:', err);
      showAiStatus(`Error: ${err.message}`, 'error');
      generateShaderBtn.disabled = false;
      generateShaderBtn.innerHTML = buttonText;
    }
  }
  
  // AI Generator event listeners
  assetGenerateBtn.addEventListener('click', () => openAiGenerator());
  document.getElementById('assetGenerateSceneBtn')?.addEventListener('click', () => openAiGenerator());
  document.getElementById('postfxGenerateBtn')?.addEventListener('click', () => openAiGenerator());
  document.getElementById('shaderPreviewGenerateBtn')?.addEventListener('click', () => openAiGenerator());
  document.getElementById('scenePreviewGenerateBtn')?.addEventListener('click', () => openAiGenerator());
  document.getElementById('scenePreviewNewBtn')?.addEventListener('click', () => {
    // Create new empty scene through the existing handler
    document.getElementById('assetNewSceneBtn')?.click();
  });
  assetRefineBtn.addEventListener('click', () => {
    if (selectedAsset && (assetBrowserMode === 'shader' || assetBrowserMode === 'postfx')) {
      openAiRefiner(selectedAsset.path, assetBrowserMode === 'postfx' ? 'postfx' : 'shader');
    }
  });
  document.getElementById('aiGeneratorClose').addEventListener('click', closeAiGenerator);
  document.getElementById('aiGeneratorCancel').addEventListener('click', closeAiGenerator);

  // LUT generation with AI

  // LUT generate/refine button handlers
  document.getElementById('lutGenerateBtn')?.addEventListener('click', () => openAiGenerator('lut'));
  document.getElementById('lutRefineBtn')?.addEventListener('click', () => {
    if (selectedAsset && assetBrowserMode === 'lut') {
      openAiRefiner(selectedAsset.path, 'lut');
    }
  });
  aiGeneratorModal.addEventListener('click', (e) => {
    if (e.target === aiGeneratorModal) closeAiGenerator();
  });
  
  // =========================
  // NEW SCENE MODAL
  // =========================
  const newSceneModal = document.getElementById('newSceneModal');
  const newSceneNameInput = document.getElementById('newSceneName');
  const newSceneStatus = document.getElementById('newSceneStatus');
  
  // Track pending segment when creating new scene from asset browser
  let pendingNewSceneSegment = null; // { trackId, segIndex }
  
  function openNewSceneModal() {
    newSceneNameInput.value = '';
    newSceneStatus.style.display = 'none';
    newSceneModal.classList.add('show');
    newSceneNameInput.focus();
  }
  
  function closeNewSceneModal() {
    newSceneModal.classList.remove('show');
    // Note: Don't clear pendingNewSceneSegment here - it's cleared after use in createNewEmptyScene
    // or when the asset browser callback is invoked
  }
  
  function showNewSceneStatus(message, type = 'info') {
    newSceneStatus.style.display = 'block';
    newSceneStatus.textContent = message;
    newSceneStatus.style.background = type === 'error' ? 'rgba(255,80,80,0.2)' : 
                                       type === 'success' ? 'rgba(80,200,80,0.2)' : 
                                       'rgba(100,150,255,0.2)';
    newSceneStatus.style.color = type === 'error' ? '#ff8080' : 
                                  type === 'success' ? '#80c880' : 
                                  '#a0c0ff';
  }
  
  async function createNewEmptyScene() {
    const sceneName = newSceneNameInput.value.trim() || 'New Scene';
    
    if (!editorState.currentDemoId) {
      showNewSceneStatus('No demo loaded - cannot create scene', 'error');
      return;
    }
    
    showNewSceneStatus('Creating scene...', 'info');
    
    // Create the empty scene JSON
    const sceneData = {
      name: sceneName,
      description: "An empty scene",
      background: "#000000",
      fog: {
        color: "#000000",
        near: 50,
        far: 200
      },
      camera: {
        type: "perspective",
        fov: 60,
        near: 0.1,
        far: 1000,
        position: { x: 0, y: 2, z: 10 },
        rotation: { x: 0, y: 0, z: 0 }  // Facing straight forward along -Z axis
      },
      lights: [
        {
          type: "ambient",
          name: "Ambient Light",
          color: "#ffffff",
          intensity: 0.4
        },
        {
          type: "directional",
          name: "Main Light",
          color: "#ffffff",
          intensity: 1.0,
          position: { x: 10, y: 20, z: 10 },
          castShadow: true
        }
      ],
      objects: []
    };
    
    try {
      // Create as working copy directly (not saved to library)
      // This scene only exists within this demo
      const workingFilename = `wc_${editorState.currentDemoId}_${sceneName.toLowerCase().replace(/[^a-z0-9]+/g, '_')}_${Date.now()}.json`;
      
      // Add working copy metadata
      const workingCopyData = {
        ...sceneData,
        _workingCopy: true,
        _originalSource: null, // No original - this is a new scene
        _demoId: editorState.currentDemoId,
        _createdAt: Date.now(),
        _isNewScene: true // Flag to indicate this was created fresh, not from library
      };
      
      // Save working copy to server
      const res = await api.assets.saveGenerated(
        workingFilename, JSON.stringify(workingCopyData, null, 2), 'scenes'
      );
      
      if (!res.ok) {
        throw new Error('Failed to save scene file');
      }
      
      const result = await res.json();
      const scenePath = result.path;
      
      // Load the scene and create Three.js scene
      workingCopyData._sourceFile = scenePath;
      const sceneObj = await createThreeScene(workingCopyData);
      
      // Add to loadedScenes
      const sceneIdx = loadedScenes.length;
      loadedScenes.push(sceneObj);
      
      // Check if we have a pending segment to update (from double-click on timeline)
      let sceneTrack, segIndex;
      if (pendingNewSceneSegment) {
        // Update the existing pending segment instead of creating a new one
        sceneTrack = getTrackById(pendingNewSceneSegment.trackId);
        segIndex = pendingNewSceneSegment.segIndex;
        
        if (sceneTrack && segIndex < sceneTrack.segments.length) {
          const seg = sceneTrack.segments[segIndex];
          seg.sceneIndex = sceneIdx;
          seg.sceneFile = scenePath;
          seg.sceneOverrides = seg.sceneOverrides || {};
          seg.originalSource = null;
          seg.name = sceneName;
        }
        pendingNewSceneSegment = null;
      } else {
        // No pending segment - create a new one at playhead position
        const currentTime = getCurrentTime();
        const defaultDuration = 10;

        const newSeg = {
          id: `scene${sceneIdCounter++}`,
          start: currentTime,
          end: currentTime + defaultDuration,
          sceneIndex: sceneIdx,
          sceneFile: scenePath,
          sceneOverrides: {},
          originalSource: null,
          name: sceneName,
          opacity: 1.0,
          blendMode: 'normal',
          transparentBackground: false,
          color: `hsl(${210 + sceneIdx * 18}, 70%, 60%)`
        };

        // Add to first scene track (or create one if none exists)
        sceneTrack = getTracksByType('scene')[0];
        if (!sceneTrack) {
          sceneTrack = addTrack('scene');
        }
        const placement = newClipPlacement(sceneTrack, currentTime, defaultDuration);
        newSeg.start = placement.start;
        newSeg.end = placement.end;
        sceneTrack.segments.push(newSeg);
        extendSilentTimelineTo(newSeg.end);
        segIndex = sceneTrack.segments.length - 1;
      }
      
      showNewSceneStatus('Scene created!', 'success');
      
      // Close modals and render
      setTimeout(() => {
        closeNewSceneModal();
        closeAssetBrowser(false); // Close without triggering cancel callback
        
        renderTracks();
        selectSegmentInTrack(sceneTrack.id, segIndex);
        scheduleTimelineAutoSave();
        scheduleDemoSave();
        
        console.log(`[NewScene] Created new empty scene: ${sceneName} (working copy: ${scenePath})`);
      }, 300);
      
    } catch (err) {
      console.error('Failed to create scene:', err);
      showNewSceneStatus('Failed to create scene: ' + err.message, 'error');
    }
  }
  
  // New Scene modal event listeners
  assetNewSceneBtn.addEventListener('click', openNewSceneModal);
  document.getElementById('newSceneClose').addEventListener('click', closeNewSceneModal);
  document.getElementById('newSceneCancel').addEventListener('click', closeNewSceneModal);
  document.getElementById('newSceneCreate').addEventListener('click', createNewEmptyScene);
  newSceneModal.addEventListener('click', (e) => {
    if (e.target === newSceneModal) closeNewSceneModal();
  });
  newSceneNameInput.addEventListener('keydown', (e) => {
    if (e.key === 'Enter') {
      e.preventDefault();
      createNewEmptyScene();
    }
  });
  
  // =========================
  // ADD SCENE OBJECT MODAL
  // =========================
  const addObjectModal = document.getElementById('addObjectModal');
  const btnAddSceneObject = document.getElementById('btnAddSceneObject');
  let addObjectSceneIdx = -1;
  
  function openAddObjectModal(sceneIdx) {
    addObjectSceneIdx = sceneIdx;
    addObjectModal.classList.add('show');
  }
  
  function closeAddObjectModal() {
    addObjectModal.classList.remove('show');
    addObjectSceneIdx = -1;
  }
  
  btnAddSceneObject.addEventListener('click', () => {
    if (selectedSegment?.track === 'scenes') {
      const seg = getCurrentSegment();
      if (seg && seg.sceneIndex >= 0) {
        openAddObjectModal(seg.sceneIndex);
      }
    }
  });
  
  // =========================
  // SCENE HELPERS (Light helpers, bounding boxes)
  // =========================
  let helpersEnabled = false;
  const sceneHelpers = new Map(); // sceneIdx -> { lightHelpers: [], boxHelpers: [] }
  const btnToggleHelpers = document.getElementById('btnToggleHelpers');
  
  function createSceneHelpers(sceneIdx) {
    const sceneObj = getSceneByIdx(sceneIdx);
    if (!sceneObj || !sceneObj.threeScene) return;
    
    // Remove existing helpers first
    removeSceneHelpers(sceneIdx);
    
    const helpers = { lightHelpers: [], boxHelpers: [] };
    
    // Create light helpers
    sceneObj.threeScene.traverse(obj => {
      if (obj.isLight) {
        let helper = null;
        
        if (obj.isDirectionalLight) {
          helper = new THREE.DirectionalLightHelper(obj, 2, 0xffff00);
        } else if (obj.isPointLight) {
          helper = new THREE.PointLightHelper(obj, 1, 0xffff00);
        } else if (obj.isSpotLight) {
          helper = new THREE.SpotLightHelper(obj, 0xffff00);
        } else if (obj.isHemisphereLight) {
          helper = new THREE.HemisphereLightHelper(obj, 2);
        } else if (obj.isRectAreaLight) {
          // Custom RectAreaLight helper - create a plane to show the light surface
          const width = obj.width || 10;
          const height = obj.height || 10;
          const planeGeo = new THREE.PlaneGeometry(width, height);
          const planeMat = new THREE.MeshBasicMaterial({
            color: obj.color || 0xffffff,
            side: THREE.DoubleSide,
            transparent: true,
            opacity: 0.3,
            wireframe: false
          });
          helper = new THREE.Mesh(planeGeo, planeMat);
          helper.position.copy(obj.position);
          helper.quaternion.copy(obj.quaternion);
          
          // Add wireframe outline
          const wireGeo = new THREE.EdgesGeometry(planeGeo);
          const wireMat = new THREE.LineBasicMaterial({ color: 0xffff00 });
          const wireframe = new THREE.LineSegments(wireGeo, wireMat);
          helper.add(wireframe);
          
          helper.isRectAreaHelper = true;
          helper.targetLight = obj;
        }
        
        if (helper) {
          helper.visible = helpersEnabled;
          sceneObj.threeScene.add(helper);
          helpers.lightHelpers.push(helper);
        }
      }
    });
    
    // Create bounding box helpers for meshes
    sceneObj.threeScene.traverse(obj => {
      if (obj.isMesh && !obj.isGrass && !obj.isVoxel && !obj.isRectAreaHelper) {
        // Skip helper meshes
        if (obj.parent?.isRectAreaHelper) return;
        
        const boxHelper = new THREE.BoxHelper(obj, 0x00ff00);
        boxHelper.visible = helpersEnabled;
        sceneObj.threeScene.add(boxHelper);
        helpers.boxHelpers.push({ helper: boxHelper, mesh: obj });
      }
    });
    
    sceneHelpers.set(sceneIdx, helpers);
    console.log(`[Helpers] Created ${helpers.lightHelpers.length} light helpers, ${helpers.boxHelpers.length} box helpers for scene ${sceneIdx}`);
  }
  
  function removeSceneHelpers(sceneIdx) {
    const helpers = sceneHelpers.get(sceneIdx);
    if (!helpers) return;
    
    const sceneObj = getSceneByIdx(sceneIdx);
    if (sceneObj?.threeScene) {
      helpers.lightHelpers.forEach(h => {
        sceneObj.threeScene.remove(h);
        h.dispose?.();
        if (h.material) h.material.dispose?.();
        if (h.geometry) h.geometry.dispose?.();
      });
      helpers.boxHelpers.forEach(({ helper }) => {
        sceneObj.threeScene.remove(helper);
        helper.dispose?.();
      });
    }
    
    sceneHelpers.delete(sceneIdx);
  }
  
  function updateHelpersVisibility(visible) {
    sceneHelpers.forEach((helpers) => {
      helpers.lightHelpers.forEach(h => h.visible = visible);
      helpers.boxHelpers.forEach(({ helper }) => helper.visible = visible);
    });
  }
  
  function updateBoxHelpers() {
    // Update bounding boxes to match current mesh transforms
    sceneHelpers.forEach((helpers) => {
      helpers.boxHelpers.forEach(({ helper, mesh }) => {
        if (mesh && helper.visible) {
          helper.update();
        }
      });
      // Update RectAreaLight helpers position/rotation
      helpers.lightHelpers.forEach(h => {
        if (h.isRectAreaHelper && h.targetLight) {
          h.position.copy(h.targetLight.position);
          h.quaternion.copy(h.targetLight.quaternion);
        } else if (h.update) {
          h.update();
        }
      });
    });
  }
  
  function toggleHelpers() {
    if (!selectedSegment || selectedSegment.track !== 'scenes') return;
    
    const seg = getCurrentSegment();
    if (!seg || seg.sceneIndex < 0) return;
    
    helpersEnabled = !helpersEnabled;
    
    // Update button appearance
    btnToggleHelpers.textContent = helpersEnabled ? '👁 Helpers On' : '👁 Helpers';
    btnToggleHelpers.style.background = helpersEnabled ? 'rgba(100, 200, 100, 0.3)' : '';
    
    // Create helpers if they don't exist for this scene
    if (helpersEnabled && !sceneHelpers.has(seg.sceneIndex)) {
      createSceneHelpers(seg.sceneIndex);
    }
    
    updateHelpersVisibility(helpersEnabled);
    console.log(`[Helpers] ${helpersEnabled ? 'Enabled' : 'Disabled'}`);
  }
  
  btnToggleHelpers.addEventListener('click', toggleHelpers);
  
  document.getElementById('addObjectClose').addEventListener('click', closeAddObjectModal);
  document.getElementById('addObjectCancel').addEventListener('click', closeAddObjectModal);
  addObjectModal.addEventListener('click', (e) => {
    if (e.target === addObjectModal) closeAddObjectModal();
  });
  
  // Handle add object button clicks
  document.querySelectorAll('.add-object-btn').forEach(btn => {
    btn.addEventListener('click', () => {
      const type = btn.dataset.type;
      if (type === 'primitive') {
        const geometry = btn.dataset.geometry;
        addPrimitiveToScene(addObjectSceneIdx, geometry);
        closeAddObjectModal();
      } else if (type === 'light') {
        const lightType = btn.dataset.lightType;
        addLightToScene(addObjectSceneIdx, lightType);
      closeAddObjectModal();
      } else if (type === '3d') {
        // Capture scene index BEFORE closing modal (which resets it to -1)
        const sceneIdx = addObjectSceneIdx;
        closeAddObjectModal();
        open3DModelBrowser(sceneIdx);
      }
    });
  });
  
  // Open 3D model browser
  function open3DModelBrowser(sceneIdx) {
    console.log('[3D] Opening 3D model browser for scene:', sceneIdx);
    if (sceneIdx < 0) {
      console.error('[3D] Invalid scene index for 3D model browser');
      return;
    }
    openAssetBrowser('3d', async (asset) => {
      console.log('[3D] Asset browser callback received:', asset);
      if (!asset) {
        console.log('[3D] No asset selected (cancelled)');
        return;
      }
      await add3DModelToScene(sceneIdx, asset.path, asset.name);
    });
  }
  
  // Add primitive mesh to scene
  function addPrimitiveToScene(sceneIdx, geometryType) {
    const sceneObj = getSceneByIdx(sceneIdx);
    if (!sceneObj) return;
    
    saveUndoState();
    
    // Create geometry based on type
    let geometry;
    const name = geometryType.charAt(0).toUpperCase() + geometryType.slice(1);
    
    switch(geometryType) {
      case 'plane':
        geometry = new THREE.PlaneGeometry(10, 10, 32, 32);
        break;
      case 'sphere':
        geometry = new THREE.SphereGeometry(1, 32, 32);
        break;
      case 'box':
        geometry = new THREE.BoxGeometry(2, 2, 2);
        break;
      case 'capsule':
        geometry = new THREE.CapsuleGeometry(0.5, 1, 16, 32);
        break;
      case 'cylinder':
        geometry = new THREE.CylinderGeometry(1, 1, 2, 32);
        break;
      case 'torus':
        geometry = new THREE.TorusGeometry(1, 0.4, 16, 100);
        break;
      default:
        geometry = new THREE.BoxGeometry(2, 2, 2);
    }
    
    // Create material
    const material = new THREE.MeshStandardMaterial({
      color: 0xcccccc,
      metalness: 0.3,
      roughness: 0.7,
    });
    
    // Add to scene data first to get the correct index
    if (!sceneObj.data.objects) sceneObj.data.objects = [];
    const newObjIdx = sceneObj.data.objects.length;
    
    const objData = {
      name: name,
      geometry: geometryType,
      position: { x: 0, y: 0, z: 0 },
      rotation: { x: 0, y: 0, z: 0 },
      scale: { x: 1, y: 1, z: 1 },
      material: {
        type: 'standard',
        color: '#cccccc',
        metalness: 0.3,
        roughness: 0.7,
        opacity: 1,
        transparent: false,
      },
      castShadow: true,
      receiveShadow: true,
      visible: true,
    };
    sceneObj.data.objects.push(objData);
    
    // Create mesh
    const mesh = new THREE.Mesh(geometry, material);
    mesh.castShadow = true;
    mesh.receiveShadow = true;
    mesh.name = name;
    
    // IMPORTANT: Set userData with objIndex so parameter handlers can find this mesh
    mesh.userData = { ...objData, objIndex: newObjIdx };
    
    // Add to Three.js scene
    sceneObj.threeScene.add(mesh);
    
    // Refresh object list
    if (selectedSegment?.track === 'scenes') {
      populateObjectList('scenes', getCurrentSegment());
    }
    
    // Auto-select the new object (newObjIdx already defined above)
    selectSceneObject(sceneIdx, 'object', newObjIdx);
    
    // Highlight in list
    setTimeout(() => {
      const item = document.querySelector(`.obj-item[data-type="object"][data-id="${newObjIdx}"]`);
      if (item) {
        document.querySelectorAll('.obj-item').forEach(i => i.classList.remove('selected'));
        item.classList.add('selected');
      }
    }, 50);
    
    // Recreate helpers if enabled to include the new object
    if (helpersEnabled) {
      createSceneHelpers(sceneIdx);
      updateHelpersVisibility(true);
    }
    
    scheduleAutoSave(sceneIdx);
    console.log(`Added ${geometryType} primitive to scene ${sceneIdx}`);
  }
  
  // Add light to scene
  function addLightToScene(sceneIdx, lightType) {
    const sceneObj = getSceneByIdx(sceneIdx);
    if (!sceneObj) return;
    
    saveUndoState();
    
    // First, add to scene data to get the correct index
    if (!sceneObj.data.lights) sceneObj.data.lights = [];
    const newLightIdx = sceneObj.data.lights.length;
    
    // Create Three.js light
    let light;
    const name = lightType.charAt(0).toUpperCase() + lightType.slice(1) + ' Light';
    
    switch(lightType) {
      case 'ambient':
        light = new THREE.AmbientLight(0xffffff, 0.5);
        break;
      case 'directional':
        light = new THREE.DirectionalLight(0xffffff, 1);
        light.position.set(5, 10, 5);
        light.castShadow = true;
        light.shadow.mapSize.width = 2048;
        light.shadow.mapSize.height = 2048;
        light.shadow.camera.near = 0.5;
        light.shadow.camera.far = 50;
        light.shadow.camera.left = -20;
        light.shadow.camera.right = 20;
        light.shadow.camera.top = 20;
        light.shadow.camera.bottom = -20;
        break;
      case 'rectarea':
        // RectAreaLight - needs to be visible and properly positioned
        light = new THREE.RectAreaLight(0xffffff, 5, 10, 10);  // Higher intensity for rectarea
        light.position.set(0, 5, 5);
        light.lookAt(0, 0, 0);
        break;
      case 'hemisphere':
        light = new THREE.HemisphereLight(0x87CEEB, 0x3a5a2a, 0.6);
        break;
      case 'point':
        light = new THREE.PointLight(0xffffff, 1, 50);
        light.position.set(0, 5, 0);
        light.castShadow = true;
        break;
      case 'spot':
        light = new THREE.SpotLight(0xffffff, 1);
        light.position.set(0, 10, 0);
        light.angle = Math.PI / 6;
        light.penumbra = 0.2;
        light.decay = 2;
        light.distance = 50;
        light.castShadow = true;
        break;
      default:
        light = new THREE.PointLight(0xffffff, 1);
    }
    
    light.name = name;
    
    // IMPORTANT: Store the light index in userData for reliable retrieval
    light.userData = { lightIndex: newLightIdx };
    
    // Add to Three.js scene
    sceneObj.threeScene.add(light);
    
    // Add to scene data
    const lightData = {
      name: name,
      type: lightType,
      color: '#ffffff',
      intensity: light.intensity,
      visible: true
    };
    
    // Add position for lights that have it
    if (lightType !== 'ambient' && lightType !== 'hemisphere') {
      lightData.position = { x: light.position.x, y: light.position.y, z: light.position.z };
    }
    
    // Add type-specific properties
    if (lightType === 'hemisphere') {
      lightData.skyColor = '#87CEEB';
      lightData.groundColor = '#3a5a2a';
    }
    if (lightType === 'point' || lightType === 'spot') {
      lightData.distance = light.distance || 50;
      lightData.decay = light.decay || 2;
    }
    if (lightType === 'spot') {
      lightData.angle = light.angle;
      lightData.penumbra = light.penumbra;
    }
    if (lightType === 'rectarea') {
      lightData.width = 10;
      lightData.height = 10;
    }
    if (lightType === 'directional' || lightType === 'point' || lightType === 'spot') {
      lightData.castShadow = light.castShadow;
    }
    
    sceneObj.data.lights.push(lightData);
    
    // Refresh object list
    if (selectedSegment?.track === 'scenes') {
      populateObjectList('scenes', getCurrentSegment());
    }
    
    // Auto-select the new light (newLightIdx was set at the beginning)
    selectSceneObject(sceneIdx, 'light', newLightIdx);
    
    // Highlight in list
    setTimeout(() => {
      const item = document.querySelector(`.obj-item[data-type="light"][data-id="${newLightIdx}"]`);
      if (item) {
        document.querySelectorAll('.obj-item').forEach(i => i.classList.remove('selected'));
        item.classList.add('selected');
      }
    }, 50);
    
    // Recreate helpers if enabled to include the new light
    if (helpersEnabled) {
      createSceneHelpers(sceneIdx);
      updateHelpersVisibility(true);
    }
    
    scheduleAutoSave(sceneIdx);
    console.log(`Added ${lightType} light to scene ${sceneIdx}`);
  }
  
  // Add 3D model to scene
  async function add3DModelToScene(sceneIdx, filePath, fileName) {
    console.log('[3D] Adding model to scene:', { sceneIdx, filePath, fileName });
    
    const sceneObj = getSceneByIdx(sceneIdx);
    if (!sceneObj) {
      console.error('[3D] Scene not found:', sceneIdx);
      return;
    }
    
    console.log('[3D] Scene object found:', sceneObj);
    saveUndoState();
    
    // Determine loader based on file extension
    const ext = filePath.split('.').pop().toLowerCase();
    let model = null;
    
    try {
      if (ext === 'glb' || ext === 'gltf') {
        // GLTF/GLB Loader
        const gltf = await new Promise((resolve, reject) => {
          const loader = new GLTFLoader();
          loader.load(
            '/' + filePath,
            (gltf) => resolve(gltf),
            undefined,
            (err) => reject(err)
          );
        });
        model = gltf.scene;
        model.userData.isGLTF = true;
        
        // Handle animations if present
        if (gltf.animations && gltf.animations.length > 0) {
          const mixer = new THREE.AnimationMixer(model);
          // Play all animations
          gltf.animations.forEach(clip => {
            const action = mixer.clipAction(clip);
            action.play();
          });
          // Start paused if timeline is not playing
          const isPlaying = silentPlaying;
          if (!isPlaying) {
            mixer.timeScale = 0;
          }
          animationMixers.set(model, mixer);
          model.userData.hasAnimations = true;
          model.userData.animationClips = gltf.animations;
          console.log(`[3D] Loaded ${gltf.animations.length} animation(s) for model`);
        }
        
      } else if (ext === 'obj') {
        // OBJ Loader
        model = await new Promise((resolve, reject) => {
          const loader = new OBJLoader();
          loader.load(
            '/' + filePath,
            (obj) => resolve(obj),
            undefined,
            (err) => reject(err)
          );
        });
        model.userData.isOBJ = true;
        
        // Convert OBJ materials to MeshStandardMaterial for PBR support
        model.traverse((child) => {
          if (child.isMesh) {
            const oldMat = child.material;
            const newMat = new THREE.MeshStandardMaterial({
              color: oldMat.color ? oldMat.color.clone() : new THREE.Color(0xcccccc),
              map: oldMat.map || null,
              metalness: 0.3,
              roughness: 0.7,
            });
            child.material = newMat;
            oldMat.dispose?.();
          }
        });
        
      } else if (ext === 'fbx') {
        // FBX Loader
        const fbx = await new Promise((resolve, reject) => {
          const loader = new FBXLoader();
          loader.load(
            '/' + filePath,
            (fbx) => resolve(fbx),
            undefined,
            (err) => reject(err)
          );
        });
        model = fbx;
        model.userData.isFBX = true;
        
        // Handle animations if present (FBX stores animations directly on the object)
        if (fbx.animations && fbx.animations.length > 0) {
          const mixer = new THREE.AnimationMixer(model);
          // Play all animations
          fbx.animations.forEach(clip => {
            const action = mixer.clipAction(clip);
            action.play();
          });
          // Start paused if timeline is not playing
          const isPlaying = silentPlaying;
          if (!isPlaying) {
            mixer.timeScale = 0;
          }
          animationMixers.set(model, mixer);
          model.userData.hasAnimations = true;
          model.userData.animationClips = fbx.animations;
          console.log(`[3D] Loaded ${fbx.animations.length} animation(s) for FBX model`);
        }
        
        // Convert FBX materials to MeshStandardMaterial for PBR support
        model.traverse((child) => {
          if (child.isMesh) {
            const oldMat = child.material;
            // Handle array of materials
            if (Array.isArray(oldMat)) {
              child.material = oldMat.map(m => {
                const newMat = new THREE.MeshStandardMaterial({
                  color: m.color ? m.color.clone() : new THREE.Color(0xcccccc),
                  map: m.map || null,
                  metalness: 0.3,
                  roughness: 0.7,
                });
                m.dispose?.();
                return newMat;
              });
            } else {
              const newMat = new THREE.MeshStandardMaterial({
                color: oldMat.color ? oldMat.color.clone() : new THREE.Color(0xcccccc),
                map: oldMat.map || null,
                metalness: 0.3,
                roughness: 0.7,
              });
              child.material = newMat;
              oldMat.dispose?.();
            }
          }
        });
        
      } else if (ext === 'stl') {
        // STL Loader - returns geometry, need to create mesh
        const geometry = await new Promise((resolve, reject) => {
          const loader = new STLLoader();
          loader.load(
            '/' + filePath,
            (geom) => resolve(geom),
            undefined,
            (err) => reject(err)
          );
        });
        const material = new THREE.MeshStandardMaterial({
          color: 0xcccccc,
          metalness: 0.3,
          roughness: 0.7
        });
        model = new THREE.Mesh(geometry, material);
        model.userData.isSTL = true;
        
      } else {
        console.error('Unsupported 3D format:', ext);
        alert(`Unsupported 3D format: ${ext}`);
        return;
      }
    } catch (err) {
      console.error('[3D] Failed to load 3D model:', err);
      alert(`Failed to load 3D model: ${err.message}`);
      return;
    }
    
    console.log('[3D] Model loaded successfully:', model);
    
    // Compute bounding box and normalize model if needed
    const box = new THREE.Box3().setFromObject(model);
    const size = box.getSize(new THREE.Vector3());
    const center = box.getCenter(new THREE.Vector3());
    
    console.log('[3D] Model size:', size, 'center:', center);
    
    // Auto-scale if model is very large or very small
    const maxDim = Math.max(size.x, size.y, size.z);
    if (maxDim > 100) {
      // Scale down large models to fit ~10 units
      const scale = 10 / maxDim;
      model.scale.multiplyScalar(scale);
      console.log('[3D] Scaled down model by:', scale);
    } else if (maxDim < 0.01 && maxDim > 0) {
      // Scale up very small models
      const scale = 1 / maxDim;
      model.scale.multiplyScalar(scale);
      console.log('[3D] Scaled up model by:', scale);
    }
    
    // Re-compute bounding box after scaling
    const scaledBox = new THREE.Box3().setFromObject(model);
    const scaledCenter = scaledBox.getCenter(new THREE.Vector3());
    
    // Center the model at origin
    model.position.sub(scaledCenter);
    
    // Get model name from filename (without extension)
    const modelName = fileName.replace(/\.[^.]+$/, '');
    
    // Add to scene data first to get the correct index
    if (!sceneObj.data.objects) sceneObj.data.objects = [];
    const newObjIdx = sceneObj.data.objects.length;
    
    const objData = {
      name: modelName,
      geometry: 'gltf', // Use 'gltf' for all 3D models (it's our generic 3D model type)
      file: '/' + filePath,
      position: { x: model.position.x, y: model.position.y, z: model.position.z },
      rotation: { x: 0, y: 0, z: 0 },
      scale: { x: model.scale.x, y: model.scale.y, z: model.scale.z },
      castShadow: true,
      receiveShadow: true,
      visible: true,
      preserveOriginalMaterial: true, // Don't override embedded textures/materials from GLB/GLTF files
    };
    sceneObj.data.objects.push(objData);
    
    // Configure the model
    model.name = modelName;
    model.userData.objIndex = newObjIdx;
    model.userData.objName = modelName;
    
    // Enable shadows for all meshes in the model
    model.traverse((child) => {
      if (child.isMesh) {
        child.castShadow = true;
        child.receiveShadow = true;
      }
    });
    
    // Add to Three.js scene
    sceneObj.threeScene.add(model);
    console.log('[3D] Added model to Three.js scene, children count:', sceneObj.threeScene.children.length);
    
    // Find the segment that uses this scene
    const allSceneSegs = getSceneSegments();
    const segmentWithScene = allSceneSegs.find(seg => seg.sceneIndex === sceneIdx);
    console.log('[3D] Found segment with scene:', segmentWithScene);
    
    // Refresh object list - find the segment for this scene
    if (segmentWithScene) {
      const allSceneSegs = getSceneSegments();
      const segIndex = allSceneSegs.indexOf(segmentWithScene);
      populateObjectList('scenes', segmentWithScene);
      
      // Select this segment if not already selected
      const currentSeg = getCurrentSegment();
      if (!selectedSegment || selectedSegment.track !== 'scenes' || currentSeg?.sceneIndex !== sceneIdx) {
        selectSegment('scenes', segIndex);
      }
    }
    
    // Auto-select the new object
    selectSceneObject(sceneIdx, 'object', newObjIdx);
    
    // Highlight in list
    setTimeout(() => {
      const item = document.querySelector(`.obj-item[data-type="object"][data-id="${newObjIdx}"]`);
      if (item) {
        document.querySelectorAll('.obj-item').forEach(i => i.classList.remove('selected'));
        item.classList.add('selected');
      }
    }, 50);
    
    // Recreate helpers if enabled to include the new object
    if (helpersEnabled) {
      createSceneHelpers(sceneIdx);
      updateHelpersVisibility(true);
    }
    
    scheduleAutoSave(sceneIdx);
    console.log(`[3D] Added 3D model "${modelName}" to scene ${sceneIdx} from ${filePath}`);
  }
  
  // Helper to find a Three.js light by its data index
  function findThreeLightByIndex(sceneObj, lightIdx) {
    // First try to find by userData.lightIndex (for manually added lights)
    let light = null;
    sceneObj.threeScene.traverse(obj => {
      if (obj.isLight && obj.userData?.lightIndex === lightIdx) {
        light = obj;
      }
    });
    
    // Fallback: try to match by position in the lights array
    if (!light) {
      const lights = sceneObj.threeScene.children.filter(c => c.isLight);
      if (lights[lightIdx]) {
        light = lights[lightIdx];
      }
    }
    
    return light;
  }
  
  // Helper to find a Three.js object (mesh or 3D model group) by its data index
  function findThreeMeshByIndex(sceneObj, objIdx) {
    let result = null;
    
    // First check direct children that have userData.objIndex (includes Groups from GLTF/OBJ/FBX)
    for (const child of sceneObj.threeScene.children) {
      if (child.userData?.objIndex === objIdx) {
        return child;
      }
    }
    
    // Then traverse for nested meshes with objIndex (for manually added primitives)
    sceneObj.threeScene.traverse(obj => {
      if (!result && obj.isMesh && !obj.isGrass && !obj.isVoxel && obj.userData?.objIndex === objIdx) {
        result = obj;
      }
    });
    
    // Fallback: try to match by position in the objects array (meshes + groups with objIndex)
    if (!result) {
      const objects = sceneObj.threeScene.children.filter(c => 
        (c.isMesh && !c.isGrass && !c.isVoxel) || 
        c.userData?.objIndex !== undefined ||
        c.userData?.isGLTF || c.userData?.isOBJ || c.userData?.isFBX || c.userData?.isSTL
      );
      if (objects[objIdx]) {
        result = objects[objIdx];
      }
    }
    
    return result;
  }
  
  // Toggle object/light visibility
  function toggleObjectVisibility(sceneIdx, type, id, btnEl) {
    const sceneObj = getSceneByIdx(sceneIdx);
    if (!sceneObj) return;
    
    const idx = parseInt(id);
    
    if (type === 'object') {
      const mesh = findThreeMeshByIndex(sceneObj, idx);
      if (mesh) {
        mesh.visible = !mesh.visible;
        const objData = sceneObj.data.objects?.[idx];
        if (objData) objData.visible = mesh.visible;
        
        // Update button appearance
        btnEl.classList.toggle('hidden', !mesh.visible);
        btnEl.textContent = mesh.visible ? '👁' : '👁‍🗨';
        btnEl.title = mesh.visible ? 'Hide object' : 'Show object';
        
        // Update params panel if this object is selected
        const visCheckbox = document.getElementById('obj_visible');
        if (visCheckbox && selectedSceneObject?.type === 'object' && selectedSceneObject?.id === idx) {
          visCheckbox.checked = mesh.visible;
        }
        
        scheduleAutoSave(sceneIdx);
      } else {
        console.warn(`[Visibility] Could not find mesh for object index ${idx}`);
      }
    } else if (type === 'light') {
      const light = findThreeLightByIndex(sceneObj, idx);
      if (light) {
        light.visible = !light.visible;
        const lightData = sceneObj.data.lights?.[idx];
        if (lightData) lightData.visible = light.visible;
        
        // Update button appearance
        btnEl.classList.toggle('hidden', !light.visible);
        btnEl.textContent = light.visible ? '👁' : '👁‍🗨';
        btnEl.title = light.visible ? 'Hide light' : 'Show light';
        
        scheduleAutoSave(sceneIdx);
      } else {
        console.warn(`[Visibility] Could not find light for index ${idx}`);
      }
    } else if (type === 'group') {
      const groupId = id; // id is already the group ID string
      const threeGroup = sceneObj.threeGroups?.[groupId];
      if (threeGroup) {
        threeGroup.visible = !threeGroup.visible;
        const groupData = sceneObj.data.groups?.find(g => g.id === groupId);
        if (groupData) groupData.visible = threeGroup.visible;
        
        // Update button appearance
        btnEl.classList.toggle('hidden', !threeGroup.visible);
        btnEl.textContent = threeGroup.visible ? '👁' : '👁‍🗨';
        btnEl.title = threeGroup.visible ? 'Hide group' : 'Show group';
        
        scheduleAutoSave(sceneIdx);
      } else {
        console.warn(`[Visibility] Could not find group ${groupId}`);
      }
    }
  }
  
  // Remove object from scene
  function removeObjectFromScene(sceneIdx, type, id) {
    const sceneObj = getSceneByIdx(sceneIdx);
    if (!sceneObj) return;
    
    console.log(`[Remove] Removing ${type} ${id} from scene ${sceneIdx}, saving undo state...`);
    saveUndoState();
    
    if (type === 'object') {
      const objIdx = parseInt(id);
      const objData = sceneObj.data.objects?.[objIdx];
      if (!objData) return;
      
      // Find and remove from Three.js scene using the reliable finder
      const threeObj = findThreeMeshByIndex(sceneObj, objIdx);
      if (threeObj) {
        sceneObj.threeScene.remove(threeObj);
        
        // Clean up animation mixer if this object has one
        if (animationMixers.release(threeObj)) {
          console.log(`[Remove] Cleaned up animation mixer for object`);
        }
        
        // Dispose of all geometries and materials in the object (handles both meshes and groups)
        threeObj.traverse?.((child) => {
          if (child.geometry) {
            child.geometry.dispose();
          }
          if (child.material) {
            if (Array.isArray(child.material)) {
              child.material.forEach(m => m.dispose?.());
            } else {
              child.material.dispose?.();
            }
          }
        });
        
        // Also dispose direct geometry/material if it's a simple mesh
        threeObj.geometry?.dispose();
        threeObj.material?.dispose?.();
        
        console.log(`[Remove] Removed Three.js object:`, threeObj.name || threeObj.uuid);
      } else {
        console.warn(`[Remove] Could not find Three.js object for index ${objIdx}`);
      }
      
      // Remove from data
      sceneObj.data.objects.splice(objIdx, 1);
      
      // Update objIndex userData for remaining objects to maintain consistency
      // Check both direct children and traversed meshes
      for (const child of sceneObj.threeScene.children) {
        if (child.userData?.objIndex !== undefined && child.userData.objIndex > objIdx) {
          child.userData.objIndex--;
        }
      }
      sceneObj.threeScene.traverse(obj => {
        if (obj.isMesh && !obj.isGrass && !obj.isVoxel && obj.userData?.objIndex !== undefined) {
          if (obj.userData.objIndex > objIdx) {
            obj.userData.objIndex--;
          }
        }
      });
      
      console.log(`Removed object ${objIdx} from scene ${sceneIdx}`);
    } else if (type === 'light') {
      const lightIdx = parseInt(id);
      const lightData = sceneObj.data.lights?.[lightIdx];
      if (!lightData) return;
      
      // Find and remove from Three.js scene using the reliable finder
      const threeLight = findThreeLightByIndex(sceneObj, lightIdx);
      if (threeLight) {
        sceneObj.threeScene.remove(threeLight);
        threeLight.dispose?.();
      }
      
      // Remove from data
      sceneObj.data.lights.splice(lightIdx, 1);
      
      // Update lightIndex userData for remaining lights to maintain consistency
      sceneObj.threeScene.traverse(obj => {
        if (obj.isLight && obj.userData?.lightIndex !== undefined) {
          if (obj.userData.lightIndex > lightIdx) {
            obj.userData.lightIndex--;
          }
        }
      });
      
      console.log(`Removed light ${lightIdx} from scene ${sceneIdx}`);
    } else if (type === 'group') {
      // For groups, delete = ungroup
      ungroupObjects(sceneIdx, id);
      return; // ungroupObjects handles refresh
    }
    
    // Clear selection
    selectedSceneObject = null;
    paramContent.innerHTML = '<div class="panel-empty">Select an object to edit</div>';
    
    // Refresh object list
    if (selectedSegment?.track === 'scenes') {
      populateObjectList('scenes', getCurrentSegment());
    }
    
    // Recreate helpers if enabled (removed object/light helpers need cleanup)
    if (helpersEnabled) {
      createSceneHelpers(sceneIdx);
      updateHelpersVisibility(true);
    }
    
    scheduleAutoSave(sceneIdx);
  }
  
  // Provider selection change handler
  document.getElementById('aiProviderSelect')?.addEventListener('change', (e) => {
    updateProviderKeySection(e.target.value);
  });
  
  saveApiKeyBtn.addEventListener('click', () => {
    const providerSelect = document.getElementById('aiProviderSelect');
    const provider = providerSelect?.value || 'gemini';
    
    let key;
    if (provider === 'claude') {
      key = document.getElementById('claudeApiKey')?.value.trim();
      if (!key) {
        alert('Please enter a valid Claude API key');
        return;
      }
      setClaudeApiKey(key);
    } else {
      key = geminiApiKeyInput.value.trim();
      if (!key) {
        alert('Please enter a valid Gemini API key');
        return;
      }
      setGeminiApiKey(key);
    }
    
    setAiProvider(provider);
    aiApiKeySection.style.display = 'none';
    aiPromptSection.style.display = 'block';
    changeApiKeyBtn.style.display = 'block';
  });
  
  changeApiKeyBtn.addEventListener('click', () => {
    const provider = getAiProvider();
    const providerSelect = document.getElementById('aiProviderSelect');
    if (providerSelect) providerSelect.value = provider;
    updateProviderKeySection(provider);
    
    geminiApiKeyInput.value = getGeminiApiKey();
    const claudeKeyInput = document.getElementById('claudeApiKey');
    if (claudeKeyInput) claudeKeyInput.value = getClaudeApiKey();
    
    aiApiKeySection.style.display = 'block';
    aiPromptSection.style.display = 'none';
    changeApiKeyBtn.style.display = 'none';
  });
  
  generateShaderBtn.addEventListener('click', generateShaderWithAI);
  
  // Enter key to trigger generation from prompt textarea
  shaderPromptInput.addEventListener('keydown', (e) => {
    // Enter without shift triggers generation (shift+enter for newline)
    if (e.key === 'Enter' && !e.shiftKey) {
      e.preventDefault();
      if (!generateShaderBtn.disabled) {
        generateShaderWithAI();
      }
    }
  });
  
  // Also handle Enter in the shader name input
  document.getElementById('aiShaderName')?.addEventListener('keydown', (e) => {
    if (e.key === 'Enter') {
      e.preventDefault();
      if (!generateShaderBtn.disabled) {
        generateShaderWithAI();
      }
    }
  });
  
  // =========================
  // AUDIO MANAGEMENT
  // =========================
  const btnSelectAudio = document.getElementById('btnSelectAudio');
  const btnDeleteAudio = document.getElementById('btnDeleteAudio');
  
  let currentAudioFile = null; // No default audio - projects start empty
  let currentAudioDuration = 60; // Default duration for empty project
  const audioWaveformCache = new Map();
  let detailedWaveformBatch = null;
  let audioClipPlayback = null;
  
  // Silent playback mode (when no audio is loaded)
  let silentPlaying = false;
  let silentTime = 0;
  
  // Loop mode
  let loopEnabled = false;
  let loopStart = 0;
  let loopEnd = 10;
  
  function updateAudioTrackUI() {
    if (currentAudioFile) {
      btnDeleteAudio.style.display = 'inline-block';
    } else {
      btnDeleteAudio.style.display = 'none';
    }
  }

  function refreshPrimaryAudioState() {
    const first = audioClipRecords(timelineTracks, { includeHidden: false })
      .sort((left, right) => left.segment.start - right.segment.start)[0]?.segment;
    currentAudioFile = first?.audioFile || null;
    currentAudioDuration = Number(first?.sourceDuration || 60);
    AUDIO_URL = currentAudioFile;
    updateAudioTrackUI();
  }

  async function loadAudioClipWaveform(audioPath) {
    if (!audioPath) return null;
    const cached = audioWaveformCache.get(audioPath);
    if (cached?.peaks) return cached;
    if (cached?.promise) return cached.promise;

    const promise = api.audio.waveform(audioPath)
      .then(async response => {
        if (!response.ok) throw new Error(`Waveform request failed: ${response.status}`);
        const normalized = normalizeWaveformResponse(await response.json());
        if (!normalized) throw new Error('Waveform response was empty');
        const value = { peaks: normalized.peaks, bpm: normalized.bpm };
        audioWaveformCache.set(audioPath, value);
        renderSegments();
        return value;
      })
      .catch(error => {
        console.warn('[Audio clips] Could not load waveform:', audioPath, error);
        audioWaveformCache.set(audioPath, { peaks: null, error });
        renderSegments();
        return null;
      });
    audioWaveformCache.set(audioPath, { promise });
    return promise;
  }

  function refreshSelectedAudioClipInspector(segment = null) {
    if (selectedSegment?.track !== 'audios') return;
    const selectedAudioSegment = getCurrentSegment();
    if (!selectedAudioSegment || (segment && selectedAudioSegment !== segment)) return;
    populateObjectList('audios', selectedAudioSegment);
  }

  async function renderDetailedAudioSourceWaveform(segment) {
    const audioPath = segment?.audioFile;
    if (!audioPath) return null;

    let cached = audioWaveformCache.get(audioPath);
    if (cached?.detailed && cached.peaks) return cached;
    if (cached?.detailPromise) return cached.detailPromise;
    if (cached?.promise) {
      await cached.promise;
      cached = audioWaveformCache.get(audioPath);
    } else if (!cached?.peaks) {
      await loadAudioClipWaveform(audioPath);
      cached = audioWaveformCache.get(audioPath);
    }

    const sampleCount = detailedWaveformSampleCount(segment.sourceDuration);
    const detailPromise = (async () => {
      try {
        const response = await api.audio.waveform(audioPath, {
          detailed: true,
          samples: sampleCount,
        });
        if (!response.ok) {
          throw new Error(`Detailed waveform request failed: ${response.status}`);
        }
        const normalized = normalizeWaveformResponse(await response.json());
        if (!normalized) throw new Error('Detailed waveform response was empty');
        if (!detailedWaveformResponseMeetsRequest(normalized, sampleCount)) {
          throw new Error(
            `The editor server returned only ${normalized.sampleCount.toLocaleString()} `
            + `of ${sampleCount.toLocaleString()} requested points. Restart the editor server and retry.`,
          );
        }

        const latest = audioWaveformCache.get(audioPath) || {};
        const value = {
          ...latest,
          peaks: normalized.peaks,
          detailed: true,
          detailPromise: null,
          detailError: null,
          sampleCount: normalized.sampleCount,
        };
        delete value.promise;
        audioWaveformCache.set(audioPath, value);
        renderSegments();
        refreshSelectedAudioClipInspector(segment);
        return value;
      } catch (error) {
        console.warn('[Audio clips] Could not render detailed waveform:', audioPath, error);
        const latest = audioWaveformCache.get(audioPath) || {};
        audioWaveformCache.set(audioPath, {
          ...latest,
          detailPromise: null,
          detailError: error,
        });
        refreshSelectedAudioClipInspector(segment);
        return null;
      }
    })();

    audioWaveformCache.set(audioPath, {
      ...(cached || {}),
      detailPromise,
      detailError: null,
    });
    refreshSelectedAudioClipInspector(segment);
    return detailPromise;
  }

  function renderDetailedAudioWaveforms() {
    if (detailedWaveformBatch?.promise) return detailedWaveformBatch.promise;

    const sources = detailedWaveformSources(timelineTracks);
    const readyCount = sources.filter(source => {
      const waveform = audioWaveformCache.get(source.audioFile);
      return waveform?.detailed && waveform?.peaks;
    }).length;
    const pendingSources = sources.filter(source => {
      const waveform = audioWaveformCache.get(source.audioFile);
      return !(waveform?.detailed && waveform?.peaks);
    });
    if (!pendingSources.length) return Promise.resolve([]);

    const batch = {
      total: sources.length,
      completed: readyCount,
      current: 0,
      promise: null,
    };
    detailedWaveformBatch = batch;
    batch.promise = (async () => {
      const results = [];
      for (const source of pendingSources) {
        batch.current += 1;
        refreshSelectedAudioClipInspector();
        const result = await renderDetailedAudioSourceWaveform(source.segment);
        if (result) batch.completed += 1;
        results.push(result);
      }
      return results;
    })().finally(() => {
      if (detailedWaveformBatch === batch) detailedWaveformBatch = null;
      renderSegments();
      refreshSelectedAudioClipInspector();
    });
    refreshSelectedAudioClipInspector();
    return batch.promise;
  }
  
  // Audio analysis modal elements
  const audioAnalysisModal = document.getElementById('audioAnalysisModal');
  const audioAnalysisStatus = document.getElementById('audioAnalysisStatus');
  const audioAnalysisProgressBar = document.getElementById('audioAnalysisProgressBar');
  const audioAnalysisBpm = document.getElementById('audioAnalysisBpm');
  
  function showAudioAnalysisModal(status = 'Loading audio file...', progress = 0) {
    audioAnalysisStatus.textContent = status;
    audioAnalysisProgressBar.style.width = progress + '%';
    audioAnalysisBpm.style.display = 'none';
    audioAnalysisBpm.textContent = '';
    audioAnalysisModal.classList.add('show');
  }
  
  function updateAudioAnalysisProgress(status, progress, bpm = null) {
    audioAnalysisStatus.textContent = status;
    audioAnalysisProgressBar.style.width = progress + '%';
    if (bpm) {
      audioAnalysisBpm.textContent = `♪ ${bpm} BPM detected`;
      audioAnalysisBpm.style.display = 'block';
    }
  }
  
  function hideAudioAnalysisModal() {
    audioAnalysisModal.classList.remove('show');
    audioAnalysisProgressBar.style.width = '0%';
    audioAnalysisBpm.style.display = 'none';
  }

  function loadAudioPath(audioPath) {
    return loadProjectAudio(audioPath, {
      getCurrentDuration: () => currentAudioDuration,
      saveUndo: saveUndoState,
      showProgress: showAudioAnalysisModal,
      updateProgress: updateAudioAnalysisProgress,
      hideProgress: hideAudioAnalysisModal,
      setAudioState: (path, duration) => {
        currentAudioFile = path;
        currentAudioDuration = duration;
        AUDIO_URL = path;
        silentPlaying = false;
        durationGuess = Math.max(durationGuess, duration);
        audioFullyLoaded = false;
      },
      loadPlayback: loadAudioAsBlob,
      waitUntilReady: () => waitForAudioReady(audioEl),
      scaleTimeline: scaleTimelineToNewDuration,
      clearWaveform: () => {
        wavePeaks = null;
        waveRawSamples = null;
        waveStatus.textContent = 'Waveform: generating...';
        drawWave();
      },
      analyze: generateWaveformOnServer,
      setBpm: bpm => {
        bpmInput.value = bpm;
        console.log(`BPM automatically set to ${bpm}`);
      },
      refresh: () => {
        updateAudioTrackUI();
        layoutTimeline();
        drawWaveAndRuler();
        renderTracks();
      },
      save: saveDemoToServerImmediate,
    });
  }

  async function selectAudioFile() {
    openAssetBrowser('audio', async (asset) => {
      // User cancelled
      if (!asset) return;

      try {
        const result = await loadAudioPath(asset.path);
        console.log(`Audio loaded and saved: ${asset.name}, duration: ${formatDuration(result.duration)}`);
      } catch (err) {
        console.error('Failed to load audio:', err);
        alert('Failed to load audio file: ' + asset.name);
      }
    });
  }
  
  function deleteAudioFile() {
    if (!confirm('Remove audio from this demo?')) return;
    
    // Save state for undo before removing audio
    saveUndoState();
    
    currentAudioFile = null;
    currentAudioDuration = 60; // Reset to default
    durationGuess = 60;
    AUDIO_URL = null;
    
    if (audioEl) {
      audioEl.pause();
      audioEl.src = '';
      audioEl.load();
    }
    
    // Clear waveform
    wavePeaks = null;
    waveRawSamples = null;
    waveStatus.textContent = "Waveform: none";
    // Redraw empty waveform (will show "pending" message)
    drawWave();
    
    updateAudioTrackUI();
    scheduleDemoSave();
  }
  
  function scaleTimelineToNewDuration(oldDuration, newDuration) {
    const hasContent = timelineTracks.some(track => track.segments.some(segment => segment.end > 0));
    if (!hasContent) {
      console.log('No existing content to scale');
      return;
    }

    saveUndoState();
    const result = scaleTimelineSegments(timelineTracks, newDuration);
    console.log(`Scaling timeline: content ends at ${formatDuration(result.contentEnd)}, new duration ${formatDuration(result.targetDuration)}, scale factor ${result.scale.toFixed(3)}`);
    renderTracks();
    console.log(`Timeline scaled to fit ${formatDuration(result.targetDuration)}`);
  }
  
  async function generateWaveformOnServer(audioPath) {
    if (!audioPath) {
      console.warn('No audio path provided for waveform generation');
      waveStatus.textContent = "Waveform: no audio";
      return { waveform: null, bpm: null };
    }
    
    console.log('Generating waveform for:', audioPath);
    
    try {
      const res = await api.audio.waveform(audioPath);
      
      if (res.ok) {
        const data = await res.json();
        const normalized = normalizeWaveformResponse(data);
        if (normalized) {
          // Convert waveform array to Float32Array (wavePeaks format)
          wavePeaks = normalized.peaks;
          waveStatus.textContent = "Waveform: ready";
          console.log('Waveform generated successfully:', data.waveform.length, 'samples');
          
          // Also decode audio locally for high-res waveform
          try {
            const audioUrl = '/' + audioPath;
            const audioRes = await fetch(audioUrl);
            if (audioRes.ok) {
              const buf = await audioRes.arrayBuffer();
              const audioBuf = await audioCtx.decodeAudioData(buf);
              waveRawSamples = new Float32Array(audioBuf.getChannelData(0));
              waveSampleRate = audioBuf.sampleRate;
              console.log('Raw audio samples loaded for high-res waveform');
            }
          } catch (e) {
            console.warn('Could not load raw audio for high-res waveform:', e);
          }
          
          drawWave();
          
          // Return BPM if detected
          if (normalized.bpm) {
            console.log('BPM detected:', normalized.bpm);
            return { waveform: normalized.waveform, bpm: normalized.bpm };
          }
          return { waveform: normalized.waveform, bpm: null };
        } else {
          console.warn('Waveform generation returned empty data');
          waveStatus.textContent = "Waveform: failed";
          return { waveform: null, bpm: null };
        }
      } else {
        const errorData = await res.json().catch(() => ({}));
        console.warn('Waveform generation failed:', res.status, errorData.error || 'Unknown error');
        console.warn('Audio path was:', audioPath);
        waveStatus.textContent = "Waveform: trying local...";
        // Fallback: try to generate locally
        return await generateWaveformLocally(audioPath);
      }
    } catch (e) {
      console.warn('Could not generate waveform on server:', e);
      waveStatus.textContent = "Waveform: trying local...";
      // Fallback: try to generate locally
      return await generateWaveformLocally(audioPath);
    }
  }
  
  async function generateWaveformLocally(audioPath) {
    try {
      console.log('Attempting local waveform generation for:', audioPath);
      const audioUrl = '/' + audioPath;
      const res = await fetch(audioUrl);
      if (!res.ok) {
        throw new Error(`Failed to fetch audio: ${res.status}`);
      }
      const buf = await res.arrayBuffer();
      const { peaks, duration } = await buildWaveformPeaks(buf);
      
      if (peaks && peaks.length > 0) {
        wavePeaks = peaks;
        waveStatus.textContent = "Waveform: ready";
        drawWave();
        console.log('Local waveform generated successfully:', peaks.length, 'samples');
        return { waveform: Array.from(peaks), bpm: null };
      }
    } catch (e) {
      console.warn('Local waveform generation also failed:', e);
    }
    
    waveStatus.textContent = "Waveform: failed";
    return { waveform: null, bpm: null };
  }
  
  // Audio track button handlers
  btnSelectAudio.addEventListener('click', selectAudioFile);
  btnDeleteAudio.addEventListener('click', deleteAudioFile);
  
  // Double-click on audio track waveform area opens audio selector (when no audio loaded)
  document.querySelector('.audio-track-waveform')?.addEventListener('dblclick', (e) => {
    if (!currentAudioFile) {
      e.preventDefault();
      e.stopPropagation();
      selectAudioFile();
    }
  });
  
  // Initialize audio track UI with default
  updateAudioTrackUI();
  
  // =========================
  // CONFIG
  // =========================
  let AUDIO_URL = null; // No default audio - projects start empty

  // 10 scene JSON files
  const SCENE_FILES = [
    "scenes/scene00.json",
    "scenes/scene01.json",
    "scenes/scene02.json",
    "scenes/scene03.json",
    "scenes/scene04.json",
    "scenes/scene05.json",
    "scenes/scene06.json",
    "scenes/scene07.json",
    "scenes/scene08.json",
    "scenes/scene09.json",
  ];

  // Post-effect shaders (global)
  const POSTFX_OPTIONS = [
    { name: "None", id: "none" },
    { name: "Bloom", id: "bloom" },
    { name: "Film Grain", id: "grain" },
    { name: "Chromatic Aberration", id: "chromatic" },
    { name: "Vignette", id: "vignette" },
    { name: "Color Grade (Warm)", id: "warm" },
    { name: "Color Grade (Cool)", id: "cool" },
  ];

  // =========================
  // DOM ELEMENTS
  // =========================
  const threeCanvas = document.getElementById("threeCanvas");
  const imageOverlayCanvas = document.getElementById("imageOverlayCanvas");
  const imageOverlayCtx = imageOverlayCanvas.getContext("2d");
  const viewportWrap = document.getElementById("viewportWrap");
  const canvasContainer = document.getElementById("canvasContainer");
  const editor = document.getElementById("editor");
  const objectListContent = document.getElementById("objectListContent");
  const paramContent = document.getElementById("paramContent");
  const objectListPanel = document.getElementById("objectListPanel");
  const paramPanel = document.getElementById("paramPanel");
  
  // Viewport orientation and aspect ratio
  let viewportOrientation = 'horizontal'; // 'horizontal' (16:9), 'vertical' (9:16), or 'square' (1:1)
  
  function getTargetAspectRatio() {
    return viewportAspectRatio(viewportOrientation);
  }
  
  // Currently selected object in the object list (single selection - for params panel)
  let selectedSceneObject = null;
  
  // Multi-selection state for batch operations
  let selectedSceneObjects = []; // Array of {type, id, sceneIdx}
  let lastClickedObjectItem = null; // For shift-click range selection
  
  // Track which groups are expanded in the object list (folder view)
  let expandedGroups = {}; // { "sceneIdx_groupId": true/false }

  // Viewport text picking state
  let viewportSelectedText = null;  // { trackId, segIndex, seg }
  let viewportTextDrag = null;      // { startPctX, startPctY, origAreaX, origAreaY }

  // Viewport text editing state (for inline editing)
  let textEditMode = false;              // Are we in text editing mode?
  let textEditFocused = false;           // Does text editor have keyboard focus? (false = selection visible but input goes to params)
  let textEditCursor = 0;                // Cursor position in text (character index)
  let textEditSelection = null;          // { start, end } for selected range, null if no selection
  let textEditBlinkVisible = true;       // Cursor blink state
  let textEditBlinkInterval = null;      // Blink timer interval ID
  let textEditLastClickTime = 0;         // For double-click detection
  let textEditCharPositions = [];        // Cached character positions for cursor placement

  // Custom rotation cursor (circular arrow icon)
  const ROTATE_CURSOR = `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23ffffff' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8'/%3E%3Cpath d='M21 3v5h-5'/%3E%3C/svg%3E") 12 12, pointer`;

  // Metadata for exposed params (stores which object index a param belongs to)
  // e.g., { "scenes_track_1_0": { "light_intensity": { lightIndex: 0 }, "obj_pos_x": { objectIndex: 2 } } }
  let exposedParamsMeta = {};
  const hud = document.getElementById("hud");

  const btnPlayPause = document.getElementById("btnPlayPause");
  const btnSkipToStart = document.getElementById("btnSkipToStart");
  const btnStart = document.getElementById("btnStart");

  const timePill = document.getElementById("timePill");
  const scenePill = document.getElementById("scenePill");
  const fpsPill = document.getElementById("fpsPill");

  const btnExport = document.getElementById("btnExport");
  const btnImport = document.getElementById("btnImport");
  const btnReloadScenes = document.getElementById("btnReloadScenes");
  const btnUndo = document.getElementById("btnUndo");
  const btnRedo = document.getElementById("btnRedo");

  const zoomSlider = document.getElementById("zoom");
  const zoomThumb = document.getElementById("zoomThumb");
  const followChk = document.getElementById("follow");
  const snapChk = document.getElementById("snap");
  const bpmInput = document.getElementById("bpm");
  const gridSel = document.getElementById("grid");
  const inspector = document.getElementById("inspector");
  let debugMode = false;

  const timelineViewport = document.getElementById("timelineViewport");
  const timelineContent = document.getElementById("timelineContent");
  const rulerCanvas = document.getElementById("rulerCanvas");
  const waveCanvas = document.getElementById("waveCanvas");
  const wavePlayhead = document.getElementById("wavePlayhead");
  const tracks = document.getElementById("tracks");
  const playhead = document.getElementById("playhead");

  const selName = document.getElementById("selName");
  const selStart = document.getElementById("selStart");
  const selEnd = document.getElementById("selEnd");
  const selScene = document.getElementById("selScene");
  const statusText = document.getElementById("statusText");
  const waveStatus = document.getElementById("waveStatus");

  const startOverlay = document.getElementById("startOverlay");
  const splitter = document.getElementById("splitter");
  const progressFill = document.getElementById("progressFill");
  const progressText = document.getElementById("progressText");
  const btnAddPostFx = document.getElementById("btnAddPostFx");
  const postfxHint = document.getElementById("postfxHint");
  const imagesHint = document.getElementById("imagesHint");
  const btnAddImage = document.getElementById("btnAddImage");
  const btnAddShader = document.getElementById("btnAddShader");
  const shadersHint = document.getElementById("shadersHint");
  const btnAddScene = document.getElementById("btnAddScene");
  const scenesHint = document.getElementById("scenesHint");
  const btnFullscreen = document.getElementById("btnFullscreen");
  const fullscreenHint = document.getElementById("fullscreenHint");

  // =========================
  // FULLSCREEN MODE
  // =========================
  
  // Resume all videos that should be playing based on current timeline state
  function resumeActiveVideos() {
    const isPlaying = silentPlaying;
    if (!isPlaying) return; // Only resume if we're in playback mode
    
    const t = getCurrentTime();
    const allMediaSegments = getImageSegments();
    
    for (const seg of allMediaSegments) {
      if (t >= seg.start && t < seg.end && seg.imageFile) {
        const media = loadedImages[seg.imageFile];
        if (media && media.tagName === 'VIDEO' && media.paused) {
          media.play().catch(err => console.warn('Video resume failed:', err));
        }
        // Also check video buffers for double-buffered videos
        const buffer = videoBuffers[seg.imageFile];
        if (buffer) {
          if (buffer.videoA.paused) buffer.videoA.play().catch(() => {});
          if (buffer.videoB.paused) buffer.videoB.play().catch(() => {});
        }
      }
    }
  }

  const fullscreenController = createFullscreenController({
    documentTarget: document,
    body: document.body,
    viewport: viewportWrap,
    hint: fullscreenHint,
    button: btnFullscreen,
    getHelpersEnabled: () => helpersEnabled,
    setHelpersVisible: updateHelpersVisibility,
    resize: resizeRenderer,
    restoreLayout: () => {
      layoutTimeline();
      renderTracks();
      drawRuler();
      drawWave();
    },
    resumeVideos: resumeActiveVideos,
  });
  // =========================
  // VIEWPORT TEXT PICKING
  // =========================

  // Helper: rotate a point around a center
  function rotatePoint(x, y, centerX, centerY, angleDeg) {
    const angleRad = angleDeg * Math.PI / 180;
    const cos = Math.cos(angleRad);
    const sin = Math.sin(angleRad);
    const dx = x - centerX;
    const dy = y - centerY;
    return {
      x: centerX + dx * cos - dy * sin,
      y: centerY + dx * sin + dy * cos
    };
  }

  // Check if click is on a resize handle corner
  function getResizeHandle(pctX, pctY, seg) {
    const dpr = Math.min(2, window.devicePixelRatio);
    const canvasW = imageOverlayCanvas.width / dpr;
    const canvasH = imageOverlayCanvas.height / dpr;

    const clickX = (pctX / 100) * canvasW;
    const clickY = (pctY / 100) * canvasH;
    const handleRadius = 12; // Hit area for handles

    // Get rotation and center - use PARAGRAPH bounds
    const rotation = seg.rotation || 0;
    const centerX = (seg.areaX / 100) * canvasW;
    const centerY = (seg.areaY / 100) * canvasH;
    const halfW = ((seg.areaWidth || 30) / 100) * canvasW / 2;
    const halfH = ((seg.areaHeight || 20) / 100) * canvasH / 2;
    const paraLeft = centerX - halfW;
    const paraRight = centerX + halfW;
    const paraTop = centerY - halfH;
    const paraBottom = centerY + halfH;

    // Define corners in unrotated space, then rotate them
    const cornersUnrotated = [
      { name: 'nw', x: paraLeft, y: paraTop },
      { name: 'ne', x: paraRight, y: paraTop },
      { name: 'sw', x: paraLeft, y: paraBottom },
      { name: 'se', x: paraRight, y: paraBottom },
    ];

    // Rotate corners to match visual position
    const corners = cornersUnrotated.map(c => {
      const rotated = rotatePoint(c.x, c.y, centerX, centerY, rotation);
      return { name: c.name, x: rotated.x, y: rotated.y };
    });

    for (const corner of corners) {
      const dist = Math.sqrt((clickX - corner.x) ** 2 + (clickY - corner.y) ** 2);
      if (dist <= handleRadius) {
        return corner.name;
      }
    }
    return null;
  }

  // Check if click is in the rotation zone (just outside corners)
  function getRotateHandle(pctX, pctY, seg) {
    const dpr = Math.min(2, window.devicePixelRatio);
    const canvasW = imageOverlayCanvas.width / dpr;
    const canvasH = imageOverlayCanvas.height / dpr;

    const clickX = (pctX / 100) * canvasW;
    const clickY = (pctY / 100) * canvasH;

    // Rotation zone is between 15-35 pixels from corner (outside the resize handle)
    const rotateInnerRadius = 15;
    const rotateOuterRadius = 35;

    // Get rotation and center - use PARAGRAPH bounds
    const rotation = seg.rotation || 0;
    const centerX = (seg.areaX / 100) * canvasW;
    const centerY = (seg.areaY / 100) * canvasH;
    const halfW = ((seg.areaWidth || 30) / 100) * canvasW / 2;
    const halfH = ((seg.areaHeight || 20) / 100) * canvasH / 2;
    const paraLeft = centerX - halfW;
    const paraRight = centerX + halfW;
    const paraTop = centerY - halfH;
    const paraBottom = centerY + halfH;

    // Define corners in unrotated space, then rotate them
    const cornersUnrotated = [
      { name: 'nw', x: paraLeft, y: paraTop },
      { name: 'ne', x: paraRight, y: paraTop },
      { name: 'sw', x: paraLeft, y: paraBottom },
      { name: 'se', x: paraRight, y: paraBottom },
    ];

    // Rotate corners to match visual position
    const corners = cornersUnrotated.map(c => {
      const rotated = rotatePoint(c.x, c.y, centerX, centerY, rotation);
      return { name: c.name, x: rotated.x, y: rotated.y };
    });

    // To check if click is "outside" the rotated box, we transform click to unrotated space
    const clickUnrotated = rotatePoint(clickX, clickY, centerX, centerY, -rotation);

    for (const corner of corners) {
      const dist = Math.sqrt((clickX - corner.x) ** 2 + (clickY - corner.y) ** 2);
      if (dist > rotateInnerRadius && dist <= rotateOuterRadius) {
        // Check that we're outside the bounding box (in unrotated space)
        const isOutsideX = clickUnrotated.x < paraLeft - 5 || clickUnrotated.x > paraRight + 5;
        const isOutsideY = clickUnrotated.y < paraTop - 5 || clickUnrotated.y > paraBottom + 5;
        if (isOutsideX || isOutsideY) {
          return corner.name;
        }
      }
    }
    return null;
  }

  // Check if click is on an edge handle (for resizing paragraph bounds)
  function getEdgeHandle(pctX, pctY, seg) {
    const dpr = Math.min(2, window.devicePixelRatio);
    const canvasW = imageOverlayCanvas.width / dpr;
    const canvasH = imageOverlayCanvas.height / dpr;

    const clickX = (pctX / 100) * canvasW;
    const clickY = (pctY / 100) * canvasH;

    const handleLength = 20; // Length of edge handle
    const handleWidth = 10;  // Width (thickness) of edge handle hit area

    // Get rotation and center - use PARAGRAPH bounds, not text bounds
    const rotation = seg.rotation || 0;
    const centerX = (seg.areaX / 100) * canvasW;
    const centerY = (seg.areaY / 100) * canvasH;
    const halfW = ((seg.areaWidth || 30) / 100) * canvasW / 2;
    const halfH = ((seg.areaHeight || 20) / 100) * canvasH / 2;

    // Calculate paragraph bounds edges (in unrotated space)
    const left = centerX - halfW;
    const right = centerX + halfW;
    const top = centerY - halfH;
    const bottom = centerY + halfH;

    // Define edge handle positions (center of each edge) in unrotated space
    const edgesUnrotated = [
      { name: 'n', x: centerX, y: top },
      { name: 's', x: centerX, y: bottom },
      { name: 'w', x: left, y: centerY },
      { name: 'e', x: right, y: centerY },
    ];

    // Rotate edge positions to match visual position
    const edges = edgesUnrotated.map(e => {
      const rotated = rotatePoint(e.x, e.y, centerX, centerY, rotation);
      return { name: e.name, x: rotated.x, y: rotated.y };
    });

    // Check distance to each edge handle
    for (const edge of edges) {
      const dist = Math.sqrt((clickX - edge.x) ** 2 + (clickY - edge.y) ** 2);
      // Edge handles have a slightly larger hit area
      if (dist <= handleLength / 2 + handleWidth) {
        return edge.name;
      }
    }
    return null;
  }

  function onViewportPointerDown(e) {
    const { pctX, pctY } = canvasEventToPercent(e);
    const t = getCurrentTime();

    // Check if clicking on a rotation zone of already selected text
    if (viewportSelectedText && viewportSelectedText.seg && !textEditMode) {
      const rotateHandle = getRotateHandle(pctX, pctY, viewportSelectedText.seg);
      if (rotateHandle) {
        e.preventDefault();
        e.stopPropagation();

        const seg = viewportSelectedText.seg;
        const dpr = Math.min(2, window.devicePixelRatio);
        const canvasW = imageOverlayCanvas.width / dpr;
        const canvasH = imageOverlayCanvas.height / dpr;
        const bounds = getTextActualBounds(seg, canvasW, canvasH);

        // Calculate center of text box
        const centerX = (bounds.left + bounds.right) / 2;
        const centerY = (bounds.top + bounds.bottom) / 2;

        // Calculate initial angle from center to click position
        const clickX = (pctX / 100) * canvasW;
        const clickY = (pctY / 100) * canvasH;
        const startAngle = Math.atan2(clickY - centerY, clickX - centerX);

        saveUndoState();
        viewportTextDrag = {
          type: 'rotate',
          startAngle: startAngle,
          origRotation: seg.rotation || 0,
          centerX: centerX,
          centerY: centerY,
          canvasW: canvasW,
          canvasH: canvasH
        };

        canvasContainer.style.cursor = ROTATE_CURSOR;

        window.addEventListener('pointermove', onViewportPointerMove);
        window.addEventListener('pointerup', onViewportPointerUp);
        return;
      }
    }

    // Check if clicking on an edge handle (for paragraph bounds resize)
    if (viewportSelectedText && viewportSelectedText.seg && !textEditMode) {
      const edgeHandle = getEdgeHandle(pctX, pctY, viewportSelectedText.seg);
      if (edgeHandle) {
        e.preventDefault();
        e.stopPropagation();

        const seg = viewportSelectedText.seg;
        const dpr = Math.min(2, window.devicePixelRatio);
        const canvasW = imageOverlayCanvas.width / dpr;
        const canvasH = imageOverlayCanvas.height / dpr;

        saveUndoState();
        viewportTextDrag = {
          type: 'edge',
          edge: edgeHandle,
          startPctX: pctX,
          startPctY: pctY,
          origAreaX: seg.areaX,
          origAreaY: seg.areaY,
          origAreaWidth: seg.areaWidth,
          origAreaHeight: seg.areaHeight,
          canvasW: canvasW,
          canvasH: canvasH,
          rotation: seg.rotation || 0
        };

        // Set appropriate cursor based on edge
        if (edgeHandle === 'n' || edgeHandle === 's') {
          canvasContainer.style.cursor = 'ns-resize';
        } else {
          canvasContainer.style.cursor = 'ew-resize';
        }

        window.addEventListener('pointermove', onViewportPointerMove);
        window.addEventListener('pointerup', onViewportPointerUp);
        return;
      }
    }

    // Check if clicking on a resize handle of already selected text
    if (viewportSelectedText && viewportSelectedText.seg && !textEditMode) {
      const handle = getResizeHandle(pctX, pctY, viewportSelectedText.seg);
      if (handle) {
        e.preventDefault();
        e.stopPropagation();

        const seg = viewportSelectedText.seg;
        const dpr = Math.min(2, window.devicePixelRatio);
        const canvasW = imageOverlayCanvas.width / dpr;
        const canvasH = imageOverlayCanvas.height / dpr;
        const bounds = getTextActualBounds(seg, canvasW, canvasH);

        saveUndoState();
        viewportTextDrag = {
          type: 'resize',
          handle: handle,
          startPctX: pctX,
          startPctY: pctY,
          origTextScale: seg.textScale ?? 1.0,
          origBoundsWidth: bounds.width,
          origBoundsHeight: bounds.height,
          origAreaX: seg.areaX,
          origAreaY: seg.areaY
        };

        canvasContainer.style.cursor = handle.includes('n') && handle.includes('w') || handle.includes('s') && handle.includes('e') ? 'nwse-resize' : 'nesw-resize';

        window.addEventListener('pointermove', onViewportPointerMove);
        window.addEventListener('pointerup', onViewportPointerUp);
        return;
      }
    }

    const hit = findTextSegmentAtPoint(pctX, pctY, t);
    const now = Date.now();
    const isDoubleClick = (now - textEditLastClickTime) < 350;
    textEditLastClickTime = now;

    // If in text edit mode
    if (textEditMode && viewportSelectedText) {
      if (hit && hit.seg === viewportSelectedText.seg) {
        // Click inside the text being edited - reposition cursor
        e.preventDefault();
        e.stopPropagation();

        if (isDoubleClick) {
          // Double-click in edit mode - select word
          selectWordAtCursor();
        } else if (e.shiftKey && textEditSelection === null) {
          // Shift+click - extend selection from cursor
          const clickIndex = getCursorIndexFromClick(pctX, pctY, viewportSelectedText.seg);
          textEditSelection = {
            start: Math.min(textEditCursor, clickIndex),
            end: Math.max(textEditCursor, clickIndex)
          };
          textEditCursor = clickIndex;
        } else if (e.shiftKey && textEditSelection !== null) {
          // Shift+click with existing selection - extend
          const clickIndex = getCursorIndexFromClick(pctX, pctY, viewportSelectedText.seg);
          textEditSelection.end = clickIndex;
          textEditCursor = clickIndex;
        } else {
          // Regular click - move cursor, clear selection, and start drag-select
          const clickIndex = getCursorIndexFromClick(pctX, pctY, viewportSelectedText.seg);
          textEditCursor = clickIndex;
          textEditSelection = null;

          // Set up drag selection
          viewportTextDrag = {
            type: 'textSelect',
            startIndex: clickIndex
          };

          window.addEventListener('pointermove', onViewportPointerMove);
          window.addEventListener('pointerup', onViewportPointerUp);
        }
        // Refocus text editor (in case it was unfocused by clicking params)
        refocusTextEdit();
        return;
      } else {
        // Click outside the text - exit edit mode
        exitTextEditMode();
        // Fall through to normal handling
      }
    }

    if (hit) {
      e.preventDefault();
      e.stopPropagation();

      // Check for double-click to enter edit mode
      if (isDoubleClick && viewportSelectedText && hit.seg === viewportSelectedText.seg) {
        enterTextEditMode(hit.seg, pctX, pctY);
        return;
      }

      // Select in timeline
      selectSegmentInTrack(hit.trackId, hit.segIndex);

      // Set viewport selection
      viewportSelectedText = hit;

      // Start move drag (only if not entering edit mode)
      saveUndoState();
      viewportTextDrag = {
        type: 'move',
        startPctX: pctX,
        startPctY: pctY,
        origAreaX: hit.seg.areaX,
        origAreaY: hit.seg.areaY
      };

      // Change cursor
      canvasContainer.style.cursor = 'grabbing';

      // Attach move/up listeners
      window.addEventListener('pointermove', onViewportPointerMove);
      window.addEventListener('pointerup', onViewportPointerUp);
    } else {
      // Click on empty area - exit edit mode if active, clear selection
      if (textEditMode) {
        exitTextEditMode();
      }
      viewportSelectedText = null;
    }
  }

  function onViewportPointerMove(e) {
    if (!viewportTextDrag || !viewportSelectedText) return;
    e.preventDefault();

    const { pctX, pctY } = canvasEventToPercent(e);
    const seg = viewportSelectedText.seg;

    if (viewportTextDrag.type === 'textSelect') {
      // Drag selection in text edit mode
      const currentIndex = getCursorIndexFromClick(pctX, pctY, seg);
      const startIndex = viewportTextDrag.startIndex;

      if (currentIndex !== startIndex) {
        textEditSelection = {
          start: Math.min(startIndex, currentIndex),
          end: Math.max(startIndex, currentIndex)
        };
      } else {
        textEditSelection = null;
      }
      textEditCursor = currentIndex;
      resetCursorBlink();
      return;
    }

    if (viewportTextDrag.type === 'resize') {
      // Resize with aspect ratio lock
      const dpr = Math.min(2, window.devicePixelRatio);
      const canvasW = imageOverlayCanvas.width / dpr;
      const canvasH = imageOverlayCanvas.height / dpr;

      // Calculate distance from center for scaling
      const centerX = (seg.areaX / 100) * canvasW;
      const centerY = (seg.areaY / 100) * canvasH;

      const startX = (viewportTextDrag.startPctX / 100) * canvasW;
      const startY = (viewportTextDrag.startPctY / 100) * canvasH;
      const currX = (pctX / 100) * canvasW;
      const currY = (pctY / 100) * canvasH;

      // Calculate scale based on distance from center
      const startDist = Math.sqrt((startX - centerX) ** 2 + (startY - centerY) ** 2);
      const currDist = Math.sqrt((currX - centerX) ** 2 + (currY - centerY) ** 2);

      if (startDist > 0) {
        const scale = currDist / startDist;
        // Clamp scale to reasonable range
        const clampedScale = Math.max(0.25, Math.min(4, scale));
        // Update textScale (not fontSize) when resizing the bounding box
        const origTextScale = viewportTextDrag.origTextScale ?? 1.0;
        const newTextScale = Math.max(0.25, Math.min(4.0, origTextScale * clampedScale));
        seg.textScale = newTextScale;

        // Update text scale slider if visible
        const textScaleSlider = document.getElementById('text_scale');
        if (textScaleSlider) {
          textScaleSlider.value = Math.round(seg.textScale * 100);
          updateSliderThumb(textScaleSlider);
        }

        // Recalculate cursor positions if in edit mode
        if (textEditMode) {
          calculateCharPositions(seg);
        }
      }
    } else if (viewportTextDrag.type === 'rotate') {
      // Rotate text
      const clickX = (pctX / 100) * viewportTextDrag.canvasW;
      const clickY = (pctY / 100) * viewportTextDrag.canvasH;

      // Calculate current angle from center to mouse
      const currentAngle = Math.atan2(
        clickY - viewportTextDrag.centerY,
        clickX - viewportTextDrag.centerX
      );

      // Calculate rotation delta
      let deltaAngle = currentAngle - viewportTextDrag.startAngle;

      // Convert to degrees
      let newRotation = viewportTextDrag.origRotation + (deltaAngle * 180 / Math.PI);

      // Snap to 0, 45, 90, etc. when holding Shift
      if (e.shiftKey) {
        newRotation = Math.round(newRotation / 45) * 45;
      }

      // Normalize to -180 to 180 range
      while (newRotation > 180) newRotation -= 360;
      while (newRotation < -180) newRotation += 360;

      seg.rotation = newRotation;

      // Update rotation slider if visible
      const rotationSlider = document.getElementById('text_rotation');
      if (rotationSlider) {
        rotationSlider.value = seg.rotation;
        updateSliderThumb(rotationSlider);
        const rotationNum = document.getElementById('text_rotation_num');
        if (rotationNum) rotationNum.value = Math.round(seg.rotation);
      }
    } else if (viewportTextDrag.type === 'edge') {
      // Resize paragraph bounds (not scaling text)
      const edge = viewportTextDrag.edge;
      const rotation = viewportTextDrag.rotation;
      const canvasW = viewportTextDrag.canvasW;
      const canvasH = viewportTextDrag.canvasH;

      // Calculate delta in pixels
      const deltaXPx = ((pctX - viewportTextDrag.startPctX) / 100) * canvasW;
      const deltaYPx = ((pctY - viewportTextDrag.startPctY) / 100) * canvasH;

      // Transform delta to account for rotation (unrotate the delta)
      const angleRad = -rotation * Math.PI / 180;
      const cos = Math.cos(angleRad);
      const sin = Math.sin(angleRad);
      const localDeltaX = deltaXPx * cos - deltaYPx * sin;
      const localDeltaY = deltaXPx * sin + deltaYPx * cos;

      // Convert local deltas to percentage
      const localDeltaXPct = (localDeltaX / canvasW) * 100;
      const localDeltaYPct = (localDeltaY / canvasH) * 100;

      // Apply changes based on which edge is being dragged
      // Note: areaWidth/areaHeight are FULL dimensions, areaX/areaY are CENTER positions
      // When dragging an edge, the opposite edge must stay fixed
      if (edge === 'n') {
        // North edge: dragging up (negative delta) increases height
        const heightChange = -localDeltaYPct;
        seg.areaHeight = Math.max(5, viewportTextDrag.origAreaHeight + heightChange);
        // Move center up by half the height change to keep bottom edge fixed
        seg.areaY = viewportTextDrag.origAreaY + localDeltaYPct / 2;
      } else if (edge === 's') {
        // South edge: dragging down (positive delta) increases height
        const heightChange = localDeltaYPct;
        seg.areaHeight = Math.max(5, viewportTextDrag.origAreaHeight + heightChange);
        // Move center down by half the height change to keep top edge fixed
        seg.areaY = viewportTextDrag.origAreaY + localDeltaYPct / 2;
      } else if (edge === 'w') {
        // West edge: dragging left (negative delta) increases width
        const widthChange = -localDeltaXPct;
        seg.areaWidth = Math.max(10, viewportTextDrag.origAreaWidth + widthChange);
        // Move center left by half the width change to keep right edge fixed
        seg.areaX = viewportTextDrag.origAreaX + localDeltaXPct / 2;
      } else if (edge === 'e') {
        // East edge: dragging right (positive delta) increases width
        const widthChange = localDeltaXPct;
        seg.areaWidth = Math.max(10, viewportTextDrag.origAreaWidth + widthChange);
        // Move center right by half the width change to keep left edge fixed
        seg.areaX = viewportTextDrag.origAreaX + localDeltaXPct / 2;
      }

      // Update UI sliders if visible
      const widthSlider = document.getElementById('text_area_width');
      const heightSlider = document.getElementById('text_area_height');
      const xSlider = document.getElementById('text_area_x');
      const ySlider = document.getElementById('text_area_y');
      if (widthSlider) {
        widthSlider.value = seg.areaWidth;
        updateSliderThumb(widthSlider);
        const widthNum = document.getElementById('text_area_width_num');
        if (widthNum) widthNum.value = Math.round(seg.areaWidth);
      }
      if (heightSlider) {
        heightSlider.value = seg.areaHeight;
        updateSliderThumb(heightSlider);
        const heightNum = document.getElementById('text_area_height_num');
        if (heightNum) heightNum.value = Math.round(seg.areaHeight);
      }
      if (xSlider) {
        xSlider.value = seg.areaX;
        updateSliderThumb(xSlider);
        const xNum = document.getElementById('text_area_x_num');
        if (xNum) xNum.value = Math.round(seg.areaX);
      }
      if (ySlider) {
        ySlider.value = seg.areaY;
        updateSliderThumb(ySlider);
        const yNum = document.getElementById('text_area_y_num');
        if (yNum) yNum.value = Math.round(seg.areaY);
      }

      // Recalculate cursor positions if in edit mode
      if (textEditMode) {
        calculateCharPositions(seg);
      }
    } else {
      // Move
      const deltaX = pctX - viewportTextDrag.startPctX;
      const deltaY = pctY - viewportTextDrag.startPctY;

      seg.areaX = Math.max(0, Math.min(100, viewportTextDrag.origAreaX + deltaX));
      seg.areaY = Math.max(0, Math.min(100, viewportTextDrag.origAreaY + deltaY));

      // Update UI sliders if the param panel is showing this text
      const xSlider = document.getElementById('text_area_x');
      const ySlider = document.getElementById('text_area_y');
      if (xSlider) {
        xSlider.value = seg.areaX;
        const xNum = document.getElementById('text_area_x_num');
        if (xNum) xNum.value = Math.round(seg.areaX);
      }
      if (ySlider) {
        ySlider.value = seg.areaY;
        const yNum = document.getElementById('text_area_y_num');
        if (yNum) yNum.value = Math.round(seg.areaY);
      }

      // Recalculate cursor positions if in edit mode
      if (textEditMode) {
        calculateCharPositions(seg);
      }
    }
  }

  function onViewportPointerUp(e) {
    if (viewportTextDrag) {
      scheduleTimelineAutoSave();
    }

    viewportTextDrag = null;
    canvasContainer.style.cursor = '';

    window.removeEventListener('pointermove', onViewportPointerMove);
    window.removeEventListener('pointerup', onViewportPointerUp);
  }

  // Cursor feedback on hover
  function onViewportPointerMoveHover(e) {
    // Skip if dragging
    if (viewportTextDrag) return;

    const { pctX, pctY } = canvasEventToPercent(e);
    const t = getCurrentTime();

    // Check handles if text is selected (not in edit mode)
    if (viewportSelectedText && viewportSelectedText.seg && !textEditMode) {
      // Check rotation zone first (further from corner)
      const rotateHandle = getRotateHandle(pctX, pctY, viewportSelectedText.seg);
      if (rotateHandle) {
        canvasContainer.style.cursor = ROTATE_CURSOR;
        return;
      }

      // Check resize handles (closer to corner)
      const handle = getResizeHandle(pctX, pctY, viewportSelectedText.seg);
      if (handle) {
        canvasContainer.style.cursor = (handle === 'nw' || handle === 'se') ? 'nwse-resize' : 'nesw-resize';
        return;
      }

      // Check edge handles (for paragraph bounds resize)
      const edgeHandle = getEdgeHandle(pctX, pctY, viewportSelectedText.seg);
      if (edgeHandle) {
        if (edgeHandle === 'n' || edgeHandle === 's') {
          canvasContainer.style.cursor = 'ns-resize';
        } else {
          canvasContainer.style.cursor = 'ew-resize';
        }
        return;
      }
    }

    const hit = findTextSegmentAtPoint(pctX, pctY, t);
    if (textEditMode) {
      canvasContainer.style.cursor = hit ? 'text' : '';
    } else {
      canvasContainer.style.cursor = hit ? 'grab' : '';
    }
  }

  // =========================
  // VIEWPORT TEXT EDITING
  // =========================

  // Enter text editing mode for a segment
  function enterTextEditMode(seg, pctX, pctY) {
    textEditMode = true;
    textEditFocused = true;
    textEditSelection = null;

    // Calculate character positions for cursor placement
    calculateCharPositions(seg);

    // Position cursor at click location
    if (pctX !== undefined && pctY !== undefined) {
      textEditCursor = getCursorIndexFromClick(pctX, pctY, seg);
    } else {
      textEditCursor = seg.text ? seg.text.length : 0;
    }

    // Start cursor blink
    resetCursorBlink();

    // Change cursor style
    canvasContainer.style.cursor = 'text';

    console.log('[TextEdit] Entered edit mode, cursor at:', textEditCursor);
  }

  // Exit text editing mode
  function exitTextEditMode() {
    if (!textEditMode) return;

    textEditMode = false;
    textEditFocused = false;
    textEditSelection = null;
    textEditCursor = 0;
    textEditCharPositions = [];

    // Stop cursor blink
    if (textEditBlinkInterval) {
      clearInterval(textEditBlinkInterval);
      textEditBlinkInterval = null;
    }

    // Reset cursor style
    canvasContainer.style.cursor = '';

    // Save changes
    scheduleTimelineAutoSave();

    console.log('[TextEdit] Exited edit mode');
  }

  // Unfocus text editor (keep selection visible, but release keyboard focus)
  // Used when clicking on params panel to edit selected text's properties
  function unfocusTextEdit() {
    if (!textEditMode) return;

    textEditFocused = false;

    // Stop cursor blink (but keep cursor position)
    if (textEditBlinkInterval) {
      clearInterval(textEditBlinkInterval);
      textEditBlinkInterval = null;
    }
    textEditBlinkVisible = true; // Keep cursor visible (not blinking)

    console.log('[TextEdit] Unfocused (selection preserved)');
  }

  // Refocus text editor (when clicking back in text area)
  function refocusTextEdit() {
    if (!textEditMode) return;

    textEditFocused = true;
    resetCursorBlink();

    console.log('[TextEdit] Refocused');
  }

  // Reset cursor blink (call after any cursor movement)
  function resetCursorBlink() {
    textEditBlinkVisible = true;
    if (textEditBlinkInterval) {
      clearInterval(textEditBlinkInterval);
    }
    textEditBlinkInterval = setInterval(() => {
      textEditBlinkVisible = !textEditBlinkVisible;
    }, 530);

    // Update font inputs to reflect cursor position
    updateFontSizeInputFromCursor();
    updateFontDropdownFromCursor();
    updateLetterSpacingFromCursor();
  }

  // Calculate pixel positions for each character in the text
  function calculateCharPositions(seg) {
    if (!seg || !seg.text) {
      textEditCharPositions = [];
      return;
    }

    const dpr = Math.min(2, window.devicePixelRatio);
    const canvasW = imageOverlayCanvas.width / dpr;
    const canvasH = imageOverlayCanvas.height / dpr;

    // Get text rendering parameters (matching renderTextOverlays logic EXACTLY)
    const scaleFactor = canvasH / 1080;
    const textScale = seg.textScale ?? 1.0;
    const fontSize = Math.round((seg.fontSize || 48) * scaleFactor * textScale);
    const letterSpacing = (seg.letterSpacing || 0) * scaleFactor * textScale;
    const padding = Math.round((seg.padding || 20) * scaleFactor);

    // Area dimensions
    const areaX = (seg.areaX / 100) * canvasW;
    const areaY = (seg.areaY / 100) * canvasH;
    const areaW = (seg.areaWidth / 100) * canvasW;

    // Calculate text area bounds (matching renderTextOverlays)
    const textAreaLeft = areaX - areaW / 2 + padding;
    const textAreaWidth = areaW - padding * 2;

    // Set up canvas context for measuring (default font)
    const fontInfo = AVAILABLE_FONTS.find(f => f.name === seg.fontFamily);
    const fontFamily = fontInfo ? fontInfo.family : 'Inter, sans-serif';
    const fontStyle = seg.fontStyle === 'italic' ? 'italic' : 'normal';
    const fontWeight = seg.fontWeight || 400;
    const defaultFont = `${fontStyle} ${fontWeight} ${fontSize}px ${fontFamily}`;

    imageOverlayCtx.font = defaultFont;
    imageOverlayCtx.textAlign = seg.textAlign || 'center';

    // Check if we have rich text formatting
    const hasRichText = seg.formatRanges && seg.formatRanges.length > 0;

    // Word wrap text into lines
    const lines = wrapText(imageOverlayCtx, seg.text, textAreaWidth);

    // Calculate line height and total text height (matching renderTextOverlays)
    const lineHeightMultiplier = seg.lineHeight || 1.3;
    const lineHeight = fontSize * lineHeightMultiplier;
    const totalTextHeight = lines.length * lineHeight;

    // Starting Y - MUST match renderTextOverlays: areaY - totalTextHeight / 2
    // Text is drawn with textBaseline = 'top', so this is the TOP of the first line
    const startY = areaY - totalTextHeight / 2;

    // Build character position array
    textEditCharPositions = [];
    let charIndex = 0;
    let textY = startY;

    for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) {
      const line = lines[lineIdx];
      const lineStartCharIndex = charIndex;

      // Calculate line width (with per-character fonts if needed)
      let lineWidth = 0;
      for (let i = 0; i < line.length; i++) {
        const char = line[i];
        const ci = lineStartCharIndex + i;

        // Set font for this character if rich text
        if (hasRichText) {
          const format = getCharFormat(seg, ci);
          imageOverlayCtx.font = buildFontString(format, fontSize);
        }

        const charWidth = imageOverlayCtx.measureText(char).width;
        lineWidth += charWidth;
        if (i < line.length - 1) {
          lineWidth += letterSpacing;
          if (seg.kerning && seg.kerning[ci]) {
            lineWidth += (seg.kerning[ci] / 1000) * fontSize;
          }
        }
      }

      // Reset to default font for alignment calculation
      imageOverlayCtx.font = defaultFont;

      // Calculate line starting X based on alignment
      let textX;
      if (seg.textAlign === 'left') {
        textX = textAreaLeft;
      } else if (seg.textAlign === 'right') {
        textX = textAreaLeft + textAreaWidth - lineWidth;
      } else {
        textX = textAreaLeft + (textAreaWidth - lineWidth) / 2;
      }

      // Calculate position for each character in this line
      let currentX = textX;
      for (let i = 0; i < line.length; i++) {
        const char = line[i];
        let charFontSizeScale = 1.0;

        // Set font for this character if rich text
        if (hasRichText) {
          const format = getCharFormat(seg, charIndex);
          imageOverlayCtx.font = buildFontString(format, fontSize);
          charFontSizeScale = format.fontSizeScale ?? 1.0;
        }

        const charWidth = imageOverlayCtx.measureText(char).width;
        const kerning = seg.kerning && seg.kerning[charIndex] ? (seg.kerning[charIndex] / 1000) * fontSize : 0;
        // Scale character height based on its font size scale
        const charHeight = lineHeight * charFontSizeScale;

        textEditCharPositions.push({
          charIndex: charIndex,
          lineIndex: lineIdx,
          x: currentX,
          y: textY, // Top of line (text is drawn with textBaseline='top')
          width: charWidth,
          height: charHeight,
          char: char
        });

        currentX += charWidth + letterSpacing + kerning;
        charIndex++;
      }

      // Reset to default font
      imageOverlayCtx.font = defaultFont;

      // Add newline position (if not last line)
      if (lineIdx < lines.length - 1) {
        // Account for the newline character in original text
        const originalLineEnd = seg.text.indexOf(line) + line.length;
        if (seg.text[originalLineEnd] === '\n') {
          textEditCharPositions.push({
            charIndex: charIndex,
            lineIndex: lineIdx,
            x: currentX,
            y: textY,
            width: 0,
            height: lineHeight,
            char: '\n'
          });
          charIndex++;
        }
      }

      textY += lineHeight;
    }

    // Add final position (for cursor at end of text)
    const lastPos = textEditCharPositions[textEditCharPositions.length - 1];
    if (lastPos) {
      textEditCharPositions.push({
        charIndex: charIndex,
        lineIndex: lastPos.lineIndex,
        x: lastPos.x + lastPos.width + letterSpacing,
        y: lastPos.y,
        width: 0,
        height: lastPos.height,
        char: ''
      });
    } else {
      // Empty text - cursor at beginning
      const textX = seg.textAlign === 'left' ? textAreaLeft :
                    seg.textAlign === 'right' ? textAreaLeft + textAreaWidth :
                    textAreaLeft + textAreaWidth / 2;
      textEditCharPositions.push({
        charIndex: 0,
        lineIndex: 0,
        x: textX,
        y: startY,
        width: 0,
        height: lineHeight,
        char: ''
      });
    }
  }

  // Measure text width including letter spacing

  // ======== Rich Text Helper Functions ========

  // Build a CSS font string from a format object
  // fontSize is the base font size in pixels
  // format.fontSizeScale is the per-character scale multiplier
  function buildFontString(format, fontSize, scaleFactor = 1) {
    const fontInfo = AVAILABLE_FONTS.find(f => f.name === format.fontFamily);
    const fontFamily = fontInfo ? fontInfo.family : 'Inter, sans-serif';
    const fontStyle = format.fontStyle === 'italic' ? 'italic' : 'normal';
    const fontWeight = format.fontWeight || '400';
    // Apply both canvas scale factor and per-character font size scale
    const charScale = format.fontSizeScale ?? 1.0;
    const size = Math.round(fontSize * scaleFactor * charScale);
    return `${fontStyle} ${fontWeight} ${size}px ${fontFamily}`;
  }

  // Apply formatting to a selection range

  // Adjust format ranges when text is inserted or deleted

  // Check if selection has uniform formatting

  // Toggle bold on selection

  // Toggle italic on selection

  // Apply font size scale to selection (scale is relative to base font size, e.g., 1.5 = 150%)

  // Adjust font size scale on selection by delta (e.g., +0.1 to increase by 10%)
  // Get cursor index from click position
  function getCursorIndexFromClick(pctX, pctY, seg) {
    if (!textEditCharPositions.length) {
      calculateCharPositions(seg);
    }
    if (!textEditCharPositions.length) return 0;

    const dpr = Math.min(2, window.devicePixelRatio);
    const canvasW = imageOverlayCanvas.width / dpr;
    const canvasH = imageOverlayCanvas.height / dpr;
    const clickX = (pctX / 100) * canvasW;
    const clickY = (pctY / 100) * canvasH;

    return cursorIndexFromPoint(textEditCharPositions, clickX, clickY, seg.text?.length || 0);
  }

  // Select the word at the current cursor position
  function selectWordAtCursor() {
    if (!viewportSelectedText || !viewportSelectedText.seg) return;
    const text = viewportSelectedText.seg.text || '';
    if (!text) return;

    const range = wordRangeAt(text, textEditCursor);
    if (range) {
      textEditSelection = range;
      textEditCursor = range.end;
      resetCursorBlink();
    }
  }

  // Get the cursor position info for rendering
  function getCursorRenderPosition() {
    if (!textEditCharPositions.length) return null;

    // Find the position for current cursor index
    for (const pos of textEditCharPositions) {
      if (pos.charIndex === textEditCursor) {
        return { x: pos.x, y: pos.y, height: pos.height, lineIndex: pos.lineIndex };
      }
    }

    // Fallback to last position
    const last = textEditCharPositions[textEditCharPositions.length - 1];
    return { x: last.x + last.width, y: last.y, height: last.height, lineIndex: last.lineIndex };
  }

  // =========================
  // THREE.JS SETUP
  // =========================
  console.log('[Editor] Initializing Three.js renderer...');
  let renderer;
  try {
    renderer = new THREE.WebGLRenderer({ canvas: threeCanvas, antialias: true, preserveDrawingBuffer: true });
    renderer.setPixelRatio(Math.min(2, window.devicePixelRatio));
    renderer.setClearColor(0x000000);
    renderer.shadowMap.enabled = true;
    renderer.shadowMap.type = THREE.PCFSoftShadowMap;
    console.log('[Editor] Three.js renderer initialized successfully');
  } catch (e) {
    console.error('[Editor] FATAL: Three.js renderer failed to initialize:', e);
    if (window.self !== window.top) {
      try {
        window.parent.postMessage({
          type: EDITOR_EVENTS.LOADING_ERROR,
          message: `WebGL failed: ${e.message}`
        }, playerMessageOrigin);
      } catch (pe) {}
    }
    throw e;
  }

  // Scene storage
  let loadedScenes = []; // Array of { threeScene, camera, animations, data } - legacy, for initial load
  let currentSceneIndex = -1;
  let currentEditingSceneIdx = -1; // Track which scene is being edited in the param panel
  let running = false;
  
  // =========================
  // SCREEN WAKE LOCK API
  // Prevents screen from dimming/locking during playback
  // https://developer.mozilla.org/en-US/docs/Web/API/Screen_Wake_Lock_API
  // =========================
  let editorWakeLock = null;
  
  async function requestEditorWakeLock() {
    if (!('wakeLock' in navigator)) {
      console.log('[WakeLock] API not supported');
      return;
    }
    
    try {
      editorWakeLock = await navigator.wakeLock.request('screen');
      console.log('[WakeLock] Acquired - screen will stay on');
      
      editorWakeLock.addEventListener('release', () => {
        console.log('[WakeLock] Released by system');
        editorWakeLock = null;
      });
    } catch (err) {
      console.log('[WakeLock] Request failed:', err.message);
    }
  }
  
  async function releaseEditorWakeLock() {
    if (editorWakeLock) {
      try {
        await editorWakeLock.release();
        console.log('[WakeLock] Released');
        editorWakeLock = null;
      } catch (err) {
        console.log('[WakeLock] Release failed:', err.message);
      }
    }
  }
  
  // Re-acquire wake lock when document becomes visible while playing
  document.addEventListener('visibilitychange', async () => {
    if (document.visibilityState === 'visible' && running && !editorWakeLock) {
      console.log('[WakeLock] Re-acquiring after visibility change');
      await requestEditorWakeLock();
    }
  });
  
  // Animation mixers for GLTF/GLB/FBX models with animations
  const animationMixers = createAnimationMixerStore(); // model -> THREE.AnimationMixer
  
  // Pause all animation mixers (when timeline stops)
  function pauseAllAnimations() {
    animationMixers.pauseAll();
  }
  
  // Resume all animation mixers (when timeline plays)
  function resumeAllAnimations() {
    animationMixers.resumeAll();
  }
  
  // =========================
  // PER-SEGMENT SCENE INSTANCES
  // =========================
  // Each scene segment gets its own unique Three.js scene instance
  // This allows the same scene file to be used multiple times with different parameters
  function disposeSegmentSceneInstance(instance) {
    disposeSceneSystems(instance);
    disposeObjectTree(instance.threeScene, { releaseMixer: object => animationMixers.release(object) });
  }

  const segmentSceneInstances = createSegmentSceneInstanceStore({
    loadScene: sceneFile => loadJSON(
      sceneFile.startsWith('/') ? sceneFile : `/${sceneFile}`
    ),
    createScene: createThreeScene,
    mergeSceneData: deepMerge,
    getLegacyScene: index => loadedScenes[index] || null,
    readStoredOverride: key => localStorage.getItem(key),
    removeStoredOverride: key => localStorage.removeItem(key),
    disposeInstance: disposeSegmentSceneInstance,
    logger: console,
  });
  
  /**
   * Get a unique key for a segment (trackId + segmentIndex)
   */
  function getSegmentKey(trackId, segIndex) {
    const track = getTrackById(trackId);
    return segmentSceneKey(trackId, track?.segments[segIndex]?.id ?? segIndex);
  }
  
  /**
   * Get or create a scene instance for a specific segment
   * Each segment gets its own unique scene instance
   */
  async function getOrCreateSegmentSceneInstance(trackId, segIndex, seg) {
    return segmentSceneInstances.getOrCreate(trackId, segIndex, seg);
  }
  
  /**
   * Release a scene instance when segment is deleted or changed
   */
  function releaseSegmentSceneInstance(trackId, segIndex) {
    const track = getTrackById(trackId);
    segmentSceneInstances.release(trackId, segIndex, track?.segments[segIndex]);
  }

  function syncSegmentSceneInstances() {
    const sceneTracks = getTracksByType('scene');
    segmentSceneInstances.retain(sceneTracks);
    for (const track of sceneTracks) {
      track.segments.forEach((segment, index) => {
        ensureSegmentSceneInstance(track.id, index, segment);
      });
    }
  }
  
  /**
   * Get scene object by sceneIdx, preferring segment-specific instance
   * This is a drop-in replacement for loadedScenes[sceneIdx]
   * It first checks if the currently selected segment has a specific instance
   */
  function getSceneByIdx(sceneIdx) {
    // Normalize sceneIdx to number for consistent comparison
    const normalizedIdx = typeof sceneIdx === 'string' ? parseInt(sceneIdx, 10) : sceneIdx;
    
    // Check if this is for the currently selected segment
    if (selectedSegment?.track === 'scenes' && selectedSegment.trackId) {
      const seg = getCurrentSegment();
      if (seg && seg.sceneIndex === normalizedIdx) {
        const key = getSegmentKey(selectedSegment.trackId, selectedSegment.index);
        const instance = segmentSceneInstances.get(key);
        if (instance) return instance;
      }
    }
    
    // Check currentEditingSceneIdx
    if (currentEditingSceneIdx === normalizedIdx) {
      // Try to find the segment instance for the currently editing scene
      const editingSeg = getCurrentSegment();
      if (editingSeg?.sceneIndex === normalizedIdx && selectedSegment?.trackId) {
        const key = getSegmentKey(selectedSegment.trackId, selectedSegment.index);
        const instance = segmentSceneInstances.get(key);
        if (instance) return instance;
      }
    }
    
    // Fallback to loadedScenes
    if (normalizedIdx >= 0 && normalizedIdx < loadedScenes.length) {
      const scene = loadedScenes[normalizedIdx];
      // Handle null entries (scenes that weren't loaded because they weren't initially used)
      if (scene === null) {
        // Trigger lazy load if needed - for now return null, caller should handle
        console.warn(`[SCENE] Scene ${normalizedIdx} was not loaded - triggering lazy load`);
        lazyLoadScene(normalizedIdx); // async, won't block
        return null;
      }
      return scene;
    }
    
    return null;
  }
  
  // Lazy load a scene that wasn't initially loaded
  async function lazyLoadScene(sceneIdx) {
    if (sceneIdx < 0 || sceneIdx >= SCENE_FILES.length) return;
    if (loadedScenes[sceneIdx] !== null) return; // Already loaded
    
    const sceneFile = SCENE_FILES[sceneIdx];
    console.log(`[SCENE] Lazy loading scene ${sceneIdx}...`);
    try {
      let data = await loadJSON(sceneFile);
      data._sourceFile = sceneFile;
      
      // Check for localStorage overrides using the scene file name
      const filename = sceneFile.split('/').pop().replace('.json', '');
      const storageKey = STORAGE_PREFIX + filename;
      try {
        const stored = localStorage.getItem(storageKey);
        if (stored) {
          const wrapped = JSON.parse(stored);
          if (wrapped._version === STORAGE_VERSION) {
            const storedData = wrapped.data || wrapped;
            console.log(`[SCENE] Applying localStorage overrides for ${filename}`);
        data = deepMerge(data, storedData);
          } else {
            console.warn(`[SCENE] Version mismatch for ${filename}, ignoring stored data`);
            localStorage.removeItem(storageKey);
          }
        }
      } catch (err) {
        console.warn(`[SCENE] Failed to parse localStorage for ${filename}:`, err);
      }
      
      const sceneObj = await createThreeScene(data);
      loadedScenes[sceneIdx] = sceneObj;
      console.log(`[SCENE] Lazy loaded scene ${sceneIdx}`);
    } catch (err) {
      console.warn(`[SCENE] Failed to lazy load scene ${sceneIdx}:`, err);
      loadedScenes[sceneIdx] = await createThreeScene({
        background: '#111',
        camera: { position: { x: 0, y: 5, z: 10 } },
        lights: [{ type: 'ambient', intensity: 0.5 }],
        objects: []
      });
    }
  }
  
  /**
   * Ensure a segment has its own scene instance (async, non-blocking)
   */
  function ensureSegmentSceneInstance(trackId, segIndex, seg) {
    segmentSceneInstances.ensure(trackId, segIndex, seg);
  }
  
  /**
   * Pre-load scene instances for all scene segments
   * Called after project load to ensure each segment has its own instance
   */
  async function preloadAllSegmentSceneInstances() {
    await segmentSceneInstances.preload(getTracksByType('scene'));
    console.log(`[SegmentScene] Preloaded ${segmentSceneInstances.size} scene instances`);
  }

  // =========================
  // PARAMETER PERSISTENCE
  // =========================
  const STORAGE_PREFIX = 'demotool_scene_';
  const STORAGE_VERSION = 2; // Increment this when scene structure changes significantly
  
  // Debug helper - call window.debugSceneStorage() in console to see what's saved
  window.debugSceneStorage = function() {
    console.log('=== Scene Storage Debug ===');
    console.log('loadedScenes count:', loadedScenes.length);
    loadedScenes.forEach((scene, idx) => {
      if (!scene) {
        console.log(`Scene ${idx}: (not loaded)`);
        return;
      }
      const key = getSceneStorageKey(idx);
      const stored = key ? localStorage.getItem(key) : null;
      console.log(`Scene ${idx}:`, {
        name: scene.data?.name,
        sourceFile: scene.data?._sourceFile,
        storageKey: key,
        hasStoredData: !!stored,
        storedDataSize: stored?.length || 0
      });
      if (stored) {
        try {
          const parsed = JSON.parse(stored);
          console.log(`  Stored version: ${parsed._version}, objects: ${parsed.data?.objects?.length || 0}`);
          if (parsed.data?.objects?.[0]?.material) {
            console.log(`  First object material:`, parsed.data.objects[0].material);
          }
        } catch (e) {
          console.log(`  Error parsing stored data:`, e);
        }
      }
    });
    console.log('=== All demotool_ keys in localStorage ===');
    for (let i = 0; i < localStorage.length; i++) {
      const key = localStorage.key(i);
      if (key.startsWith('demotool_')) {
        console.log(key, localStorage.getItem(key)?.slice(0, 100) + '...');
      }
    }
  };

  function getEditorResourceStats() {
    return {
      sceneInstances: segmentSceneInstances.size,
      sceneLoadsInFlight: segmentSceneInstances.loading.size,
      sceneRenderTargets: sceneRenderTargetPool.size(),
      animationMixers: animationMixers.size,
      geometries: renderer.info.memory.geometries,
      textures: renderer.info.memory.textures,
      programs: renderer.info.programs?.length || 0,
    };
  }
  window.debugEditorResources = getEditorResourceStats;
  
  // Clear all scene storage - call window.clearSceneStorage() if data is corrupt
  window.clearSceneStorage = function() {
    const keysToRemove = [];
    for (let i = 0; i < localStorage.length; i++) {
      const key = localStorage.key(i);
      if (key.startsWith(STORAGE_PREFIX)) {
        keysToRemove.push(key);
      }
    }
    keysToRemove.forEach(key => localStorage.removeItem(key));
    console.log(`Cleared ${keysToRemove.length} scene storage entries`);
  };
  
  // Get storage key based on scene filename (not index) for stability
  function getSceneStorageKey(sceneIdx) {
    // ALWAYS use the actual loaded scene's _sourceFile first - this is the authoritative source
    // The scene at getSceneByIdx(sceneIdx) might be different from SCENE_FILES[sceneIdx]
    // (e.g., a dynamically loaded scene might be at index 0 instead of scene00.json)
    const sceneObjForKey = getSceneByIdx(sceneIdx);
    let sceneFile = sceneObjForKey?.data?._sourceFile;
    
    // Fallback to SCENE_FILES only if no _sourceFile is set
    if (!sceneFile) {
      sceneFile = SCENE_FILES[sceneIdx];
    }
    
    return sceneStorageKey(sceneFile, STORAGE_PREFIX);
  }
  
  // Serialize current scene state to a JSON-compatible object
  function serializeSceneState(sceneIdx) {
    return serializeRuntimeSceneState(THREE, getSceneByIdx(sceneIdx));
  }

  function captureSceneSaveTarget(sceneIdx) {
    if (selectedSegment?.track !== 'scenes' || !selectedSegment.trackId) return null;
    const track = getTrackById(selectedSegment.trackId);
    const segment = track?.segments?.[selectedSegment.index];
    if (!segment || segment.sceneIndex !== sceneIdx) return null;
    const key = getSegmentKey(track.id, selectedSegment.index);
    const sceneObj = segmentSceneInstances.get(key);
    if (!sceneObj) return null;
    return {
      key,
      trackId: track.id,
      segmentId: segment.id,
      sceneIdx,
      sceneObj,
    };
  }

  function resolveSceneSaveSegment(target) {
    const track = target ? getTrackById(target.trackId) : null;
    if (!track) return null;
    return track.segments.find(segment => segment.id === target.segmentId) || null;
  }

  function sceneSaveTargetIsCurrent(target) {
    return !!resolveSceneSaveSegment(target) &&
      segmentSceneInstances.get(target.key) === target.sceneObj;
  }
  
  // Save scene state to localStorage
  function saveSceneToStorage(sceneIdx, target = captureSceneSaveTarget(sceneIdx)) {
    try {
      const state = target?.sceneObj
        ? serializeRuntimeSceneState(THREE, target.sceneObj)
        : serializeSceneState(sceneIdx);
      if (state) {
        const segment = resolveSceneSaveSegment(target);
        if (target) {
          if (!segment || !sceneSaveTargetIsCurrent(target)) return;
          segment.sceneOverrides = JSON.parse(JSON.stringify(state));
          console.log(`[SaveScene] Updated clip overrides for ${segment.id || target.key}`);
        } else {
          // Keep the filename-based path only for legacy console operations that
          // are not associated with a timeline clip.
          const key = getSceneStorageKey(sceneIdx);
          if (!key) return;
          const wrapped = createSceneOverrideEnvelope(state, { version: STORAGE_VERSION });
          localStorage.setItem(key, JSON.stringify(wrapped));
        }
      }
    } catch (err) {
      console.warn('Failed to save scene to localStorage:', err);
    }
  }
  
  // Load scene overrides from localStorage
  function loadSceneFromStorage(sceneIdx) {
    try {
      const key = getSceneStorageKey(sceneIdx);
      if (!key) return null;
      
      const stored = localStorage.getItem(key);
      if (stored) {
        const parsed = parseSceneOverride(stored, { version: STORAGE_VERSION });
        if (parsed.status === 'version-mismatch') {
          console.warn(`Scene ${sceneIdx} storage version mismatch (stored: ${parsed.storedVersion}, current: ${STORAGE_VERSION}). Ignoring stored data.`);
          localStorage.removeItem(key);
          return null;
        }
        if (parsed.status === 'invalid') throw parsed.error || new Error('Invalid scene override');
        return parsed.data;
      }
    } catch (err) {
      console.warn('Failed to load scene from localStorage:', err);
    }
    return null;
  }
  
  // Clear scene overrides from localStorage
  function clearSceneStorage(sceneIdx) {
    const key = getSceneStorageKey(sceneIdx);
    if (key) {
      localStorage.removeItem(key);
      console.log(`Scene ${sceneIdx} (${key}) storage cleared`);
    }
  }
  
  // Clear all scene storage
  function clearAllSceneStorage() {
    // Clear by known scene files
    for (let i = 0; i < SCENE_FILES.length; i++) {
      const key = getSceneStorageKey(i);
      if (key) localStorage.removeItem(key);
    }
    // Also clear any old index-based keys for migration
    for (let i = 0; i < 20; i++) {
      localStorage.removeItem(STORAGE_PREFIX + i);
    }
    console.log('All scene storage cleared');
  }
  
  // Collect ALL scene overrides from localStorage for saving to server
  // This handles both legacy scene00-scene09 format AND custom scene names
  function collectAllSceneOverrides() {
    const { overrides, errors } = collectStoredSceneOverrides(localStorage, STORAGE_PREFIX);
    for (const { key, error } of errors) {
      console.warn(`Failed to parse scene override for ${key.substring(STORAGE_PREFIX.length)}:`, error);
    }
    return overrides;
  }
  
  // Expose to console for debugging
  window.demotool = window.demotool || {};
  window.demotool.clearAllSceneStorage = clearAllSceneStorage;
  window.demotool.clearSceneStorage = clearSceneStorage;
  window.demotool.exportSceneJSON = exportSceneJSON;
  window.demotool.collectAllSceneOverrides = collectAllSceneOverrides;
  window.demotool.saveAllScenes = () => {
    for (const track of getTracksByType('scene')) {
      track.segments.forEach((segment, index) => {
        const key = getSegmentKey(track.id, index);
        const sceneObj = segmentSceneInstances.get(key);
        if (!sceneObj) return;
        saveSceneToStorage(segment.sceneIndex, {
          key,
          trackId: track.id,
          segmentId: segment.id,
          sceneIdx: segment.sceneIndex,
          sceneObj,
        });
      });
    }
    console.log('All timeline scene clips saved');
  };

  // =========================
  // TIMELINE PERSISTENCE
  // =========================
  const TIMELINE_STORAGE_KEY = 'demotool_timeline';
  const TIMELINE_STORAGE_VERSION = 1;
  
  // Save timeline state to localStorage
  function saveTimelineToStorage() {
    try {
      // Convert exposedParams Sets to arrays for JSON serialization
      const exposedParamsArray = {};
      for (const [segId, paramSet] of Object.entries(exposedParams)) {
        exposedParamsArray[segId] = [...paramSet];
      }
      
      const state = {
        _version: TIMELINE_STORAGE_VERSION,
        _savedAt: Date.now(),
        tracks: timelineTracks,
        trackIdCounter: trackIdCounter,
        sceneSegments: getSceneSegments(),
        shaderSegments: getShaderSegments(),
        imageSegments: getImageSegments(),
        postfxSegments: getPostfxSegments(),
        imageIdCounter: imageIdCounter,
        postfxIdCounter: postfxIdCounter,
        shaderIdCounter: shaderIdCounter,
        sceneIdCounter: sceneIdCounter,
        textIdCounter: textIdCounter,
        keyframeData: keyframeData,
        exposedParams: exposedParamsArray,
        exposedParamsMeta: exposedParamsMeta,
        curveTypes: curveTypes,
      };
      localStorage.setItem(TIMELINE_STORAGE_KEY, JSON.stringify(state));
      console.log('Timeline saved to localStorage');
    } catch (err) {
      console.warn('Failed to save timeline to localStorage:', err);
    }
  }
  
  // Load timeline state from localStorage
  function loadTimelineFromStorage() {
    try {
      const stored = localStorage.getItem(TIMELINE_STORAGE_KEY);
      if (!stored) return null;
      
      const state = JSON.parse(stored);
      
      // Version check
      if (state._version !== TIMELINE_STORAGE_VERSION) {
        console.warn('Timeline storage version mismatch, ignoring stored data');
        localStorage.removeItem(TIMELINE_STORAGE_KEY);
        return null;
      }
      
      // Convert exposedParams arrays back to Sets
      if (state.exposedParams) {
        for (const [segId, paramArray] of Object.entries(state.exposedParams)) {
          exposedParams[segId] = new Set(paramArray);
        }
      }
      
      // Load exposed params metadata
      if (state.exposedParamsMeta) {
        exposedParamsMeta = state.exposedParamsMeta;
      }
      
      // Load keyframe data
      if (state.keyframeData) {
        keyframeData = state.keyframeData;
      }
      
      // Load curve types
      if (state.curveTypes) {
        curveTypes = state.curveTypes;
      }
      
      return state;
    } catch (err) {
      console.warn('Failed to load timeline from localStorage:', err);
      return null;
    }
  }
  
  // Clear timeline storage
  function clearTimelineStorage() {
    localStorage.removeItem(TIMELINE_STORAGE_KEY);
    console.log('Timeline storage cleared');
  }
  
  // Auto-save timeline with debounce
  let timelineAutoSaveTimeout = null;
  function scheduleTimelineAutoSave() {
    if (timelineAutoSaveTimeout) clearTimeout(timelineAutoSaveTimeout);
    timelineAutoSaveTimeout = setTimeout(() => {
      saveTimelineToStorage();
      timelineAutoSaveTimeout = null;
    }, 500);
    
    // Also trigger server save
    if (typeof scheduleDemoSave === 'function') {
      scheduleDemoSave();
    }
  }
  
  // Expose timeline tools to console
  window.demotool.clearTimelineStorage = clearTimelineStorage;
  window.demotool.saveTimeline = saveTimelineToStorage;
  
  // Clean up orphan scene segments (segments with no scene file assigned)
  window.demotool.cleanupOrphanScenes = function() {
    let removed = 0;
    // Clean up empty scene segments across all scene tracks
    for (const track of getTracksByType('scene')) {
      const before = track.segments.length;
      track.segments = track.segments.filter(seg => seg.sceneFile || seg.sceneIndex >= 0);
      removed += before - track.segments.length;
    }
    
    if (removed > 0) {
      renderTracks();
      saveTimelineToStorage();
      console.log(`Removed ${removed} orphan scene segment(s)`);
    } else {
      console.log('No orphan scene segments found');
    }
    return removed;
  };
  
  // =========================
  // SERVER API (for Python server with write access)
  // =========================
  const serverAPI = {
    // Check if server has write access
    async checkServer() {
      try {
        const res = await fetch('/api/list-files', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ directory: 'scenes', extension: '.json' })
        });
        return res.ok;
      } catch {
        return false;
      }
    },
    
    // Save any file
    async saveFile(path, content) {
      const res = await fetch('/api/save-file', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ path, content })
      });
      if (!res.ok) throw new Error('Failed to save file');
      return res.json();
    },
    
    // Save waveform data
    async saveWaveform(filename = 'waveform.json') {
      if (!wavePeaks) {
        throw new Error('No waveform data available');
      }
      const res = await fetch('/api/save-waveform', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          filename,
          peaks: Array.from(wavePeaks),
          duration: durationGuess
        })
      });
      if (!res.ok) throw new Error('Failed to save waveform');
      return res.json();
    },
    
    // Save scene to disk
    async saveScene(sceneIdx, filename = null) {
      const state = serializeSceneState(sceneIdx);
      if (!state) throw new Error('No scene to save');
      
      const name = filename || state.name || `scene${String(sceneIdx).padStart(2, '0')}`;
      const safeName = name.toLowerCase().replace(/\s+/g, '_').replace(/[^a-z0-9_-]/g, '');
      
      const res = await fetch('/api/save-scene', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          filename: `${safeName}.json`,
          data: state
        })
      });
      if (!res.ok) throw new Error('Failed to save scene');
      return res.json();
    },
    
    // Save complete project (all timeline data)
    async saveProject(filename = 'project') {
      // Convert exposedParams Sets to arrays for JSON serialization
      const exposedParamsArray = {};
      for (const [segId, paramSet] of Object.entries(exposedParams)) {
        exposedParamsArray[segId] = [...paramSet];
      }
      
      const projectData = {
        _version: 7,
        _savedAt: new Date().toISOString(),
        tracks: timelineTracks,
        trackIdCounter: trackIdCounter,
        sceneSegments: getSceneSegments(),
        shaderSegments: getShaderSegments(),
        imageSegments: getImageSegments(),
        postfxSegments: getPostfxSegments(),
        imageIdCounter,
        postfxIdCounter,
        shaderIdCounter,
        sceneIdCounter,
        textIdCounter,
        keyframeData,
        exposedParams: exposedParamsArray,
        exposedParamsMeta,
        curveTypes
      };
      
      const safeName = filename.toLowerCase().replace(/\s+/g, '_').replace(/[^a-z0-9_-]/g, '');
      
      const res = await fetch('/api/save-project', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          filename: `${safeName}.json`,
          data: projectData
        })
      });
      if (!res.ok) throw new Error('Failed to save project');
      return res.json();
    },
    
    // List files in a directory
    async listFiles(directory, extension = '') {
      const res = await fetch('/api/list-files', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ directory, extension })
      });
      if (!res.ok) throw new Error('Failed to list files');
      const data = await res.json();
      return data.files || [];
    }
  };
  
  // Expose server API
  window.demotool.server = serverAPI;
  
  // Convenience functions with prompts
  window.demotool.saveSceneToDisk = async (sceneIdx) => {
    const idx = sceneIdx ?? (selectedSegment?.track === 'scenes' ? getCurrentSegment()?.sceneIndex : null);
    if (idx === null || idx === undefined) {
      alert('Please select a scene first');
      return;
    }
    const state = serializeSceneState(idx);
    const defaultName = state?.name || `scene${String(idx).padStart(2, '0')}`;
    const filename = prompt('Save scene as:', defaultName);
    if (!filename) return;
    
    try {
      const result = await serverAPI.saveScene(idx, filename);
      console.log('Scene saved:', result.path);
      alert(`Scene saved to ${result.path}`);
    } catch (err) {
      console.error('Save failed:', err);
      alert('Failed to save scene. Is the Python server running?');
    }
  };
  
  window.demotool.saveProjectToDisk = async () => {
    const filename = prompt('Save project as:', 'my_project');
    if (!filename) return;
    
    try {
      const result = await serverAPI.saveProject(filename);
      console.log('Project saved:', result.path);
      alert(`Project saved to ${result.path}`);
    } catch (err) {
      console.error('Save failed:', err);
      alert('Failed to save project. Is the Python server running?');
    }
  };
  
  window.demotool.saveWaveformToDisk = async () => {
    if (!wavePeaks) {
      alert('No waveform data. Please wait for audio to load.');
      return;
    }
    const filename = prompt('Save waveform as:', 'waveform.json');
    if (!filename) return;
    
    try {
      const result = await serverAPI.saveWaveform(filename);
      console.log('Waveform saved:', result.path);
      alert(`Waveform saved to ${result.path}`);
    } catch (err) {
      console.error('Save failed:', err);
      alert('Failed to save waveform. Is the Python server running?');
    }
  };
  
  // Export scene as downloadable JSON file
  function exportSceneJSON(sceneIdx) {
    const state = serializeSceneState(sceneIdx);
    if (!state) {
      alert('No scene to export');
      return;
    }
    
    const sceneName = state.name || `scene${String(sceneIdx).padStart(2, '0')}`;
    const blob = new Blob([JSON.stringify(state, null, 2)], { type: 'application/json' });
    const a = document.createElement('a');
    a.href = URL.createObjectURL(blob);
    a.download = `${sceneName.toLowerCase().replace(/\s+/g, '_')}.json`;
    a.click();
  }
  
  // Auto-save debounce
  const sceneAutoSaveTimeouts = new Map();
  let autoSaveEnabled = false; // Prevent saves until user actually interacts
  
  // Enable auto-save after a delay (allows scenes to fully load)
  setTimeout(() => {
    autoSaveEnabled = true;
  }, 3000);
  
  function scheduleAutoSave(sceneIdx) {
    if (!autoSaveEnabled) return;
    const target = captureSceneSaveTarget(sceneIdx);
    const saveKey = target?.key || `legacy:${sceneIdx}`;
    const pending = sceneAutoSaveTimeouts.get(saveKey);
    if (pending) clearTimeout(pending);
    const timeout = setTimeout(async () => {
      if (target && !sceneSaveTargetIsCurrent(target)) {
        sceneAutoSaveTimeouts.delete(saveKey);
        return;
      }
      saveSceneToStorage(sceneIdx, target);
      
      // Also save working copy to server
      const sceneObj = target?.sceneObj || getSceneByIdx(sceneIdx);
      if (sceneObj?.data?._workingCopy && sceneObj.data._sourceFile) {
        await saveWorkingCopyToServer(sceneIdx, sceneObj);
      }
      
      // Also trigger a project save to server so scene overrides are persisted
      // This ensures scene parameter changes survive page refresh
      scheduleTimelineAutoSave();
      
      sceneAutoSaveTimeouts.delete(saveKey);
    }, 500); // Save 500ms after last change
    sceneAutoSaveTimeouts.set(saveKey, timeout);
  }
  
  // Save working copy scene to server
  async function saveWorkingCopyToServer(sceneIdx, sceneObj = getSceneByIdx(sceneIdx)) {
    if (!sceneObj?.data?._workingCopy || !sceneObj.data._sourceFile) {
      return;
    }
    
    try {
      // Serialize the current scene state
      const state = serializeRuntimeSceneState(THREE, sceneObj);
      if (!state) return;
      
      // Merge serialized state with working copy metadata
      const saveData = {
        ...state,
        _workingCopy: sceneObj.data._workingCopy,
        _originalSource: sceneObj.data._originalSource,
        _demoId: sceneObj.data._demoId,
        _sourceFile: sceneObj.data._sourceFile,
        _createdAt: sceneObj.data._createdAt,
        _lastModified: Date.now()
      };
      
      // Extract filename from path (e.g., "scenes/wc_demo123_scene.json" -> "wc_demo123_scene.json")
      const filename = sceneObj.data._sourceFile.split('/').pop();
      
      // Save to server
      const res = await api.assets.saveGenerated(
        filename, JSON.stringify(saveData, null, 2), 'scenes'
      );
      
      if (!res.ok) {
        console.warn('[WorkingCopy] Failed to save to server:', await res.text());
      } else {
        console.log(`[WorkingCopy] Saved to server: ${filename}`);
      }
    } catch (err) {
      console.warn('[WorkingCopy] Error saving to server:', err);
    }
  }

  // =========================
  // POST-FX RENDER TARGETS
  // =========================
  let renderTargetA, renderTargetB;
  let exportController = null;
  let ffmpegAdapter = null;
  let postFxScene, postFxCamera, postFxQuad;
  let postFxMaterial;
  // Media overlay compositing (to blend media into WebGL before post-fx)
  let mediaOverlayTexture, mediaOverlayMaterial, mediaOverlayQuad;
  let compositionSurfaceOverride = null;

  function compositionSurfaceMetrics() {
    if (compositionSurfaceOverride) return compositionSurfaceOverride;
    return {
      width: parseInt(canvasContainer.style.width) || threeCanvas.clientWidth || 800,
      height: parseInt(canvasContainer.style.height) || threeCanvas.clientHeight || 450,
      dpr: Math.min(2, window.devicePixelRatio),
    };
  }

  function refreshMediaOverlayTexture() {
    if (!mediaOverlayTexture) return;
    const oldTexture = mediaOverlayTexture;
    mediaOverlayTexture = new THREE.CanvasTexture(imageOverlayCanvas);
    mediaOverlayTexture.minFilter = THREE.LinearFilter;
    mediaOverlayTexture.magFilter = THREE.LinearFilter;
    if (mediaOverlayMaterial) {
      mediaOverlayMaterial.uniforms.uOverlay.value = mediaOverlayTexture;
    }
    oldTexture.dispose();
  }

  function initPostFxPipeline() {
    const { width: w, height: h, dpr } = compositionSurfaceMetrics();
    
    // Create two render targets for ping-pong
    const rtParams = {
      minFilter: THREE.LinearFilter,
      magFilter: THREE.LinearFilter,
      format: THREE.RGBAFormat,
    };
    [renderTargetA, renderTargetB] = ensureRenderTargetPair(
      null,
      w * dpr,
      h * dpr,
      (width, height, options) => new THREE.WebGLRenderTarget(width, height, options),
      rtParams,
    );
    
    // Create fullscreen quad scene for post-processing
    postFxCamera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
    postFxScene = new THREE.Scene();
    
    // Basic pass-through material (will be replaced per effect)
    postFxMaterial = new THREE.ShaderMaterial({
      uniforms: {
        uTexture: { value: null },
        uResolution: { value: new THREE.Vector2(w * dpr, h * dpr) },
        uTime: { value: 0 },
        uIntensity: { value: 1.0 },
      },
      vertexShader: `
        varying vec2 vUv;
        void main() {
          vUv = uv;
          gl_Position = vec4(position, 1.0);
        }
      `,
      fragmentShader: `
        uniform sampler2D uTexture;
        varying vec2 vUv;
        void main() {
          gl_FragColor = texture(uTexture, vUv);
        }
      `,
    });
    
    const geometry = new THREE.PlaneGeometry(2, 2);
    postFxQuad = new THREE.Mesh(geometry, postFxMaterial);
    postFxScene.add(postFxQuad);
    
    // Create media overlay texture and compositing material
    // This allows us to blend the 2D media overlay canvas into the WebGL pipeline
    mediaOverlayTexture = new THREE.CanvasTexture(imageOverlayCanvas);
    mediaOverlayTexture.minFilter = THREE.LinearFilter;
    mediaOverlayTexture.magFilter = THREE.LinearFilter;
    
    // Material to composite overlay onto base texture
    mediaOverlayMaterial = new THREE.ShaderMaterial({
      uniforms: {
        uBase: { value: null },      // The WebGL scene render
        uOverlay: { value: mediaOverlayTexture }, // The media overlay canvas
      },
      vertexShader: `
        varying vec2 vUv;
        void main() {
          vUv = uv;
          gl_Position = vec4(position, 1.0);
        }
      `,
      fragmentShader: `
        uniform sampler2D uBase;
        uniform sampler2D uOverlay;
        varying vec2 vUv;
        void main() {
          vec4 base = texture(uBase, vUv);
          vec4 overlay = texture(uOverlay, vUv);
          // Standard alpha compositing: overlay on top of base
          gl_FragColor = vec4(
            mix(base.rgb, overlay.rgb, overlay.a),
            base.a + overlay.a * (1.0 - base.a)
          );
        }
      `,
    });
    
    mediaOverlayQuad = new THREE.Mesh(geometry.clone(), mediaOverlayMaterial);
  }

  function resizePostFxTargets() {
    if (!renderTargetA) return;
    
    // Get canvas size and apply device pixel ratio
    const { width: w, height: h, dpr } = compositionSurfaceMetrics();
    const targetW = w * dpr;
    const targetH = h * dpr;
    
    // Only resize if dimensions are valid
    if (targetW <= 0 || targetH <= 0) return;
    
    // Dispose and recreate render targets instead of using setSize
    // (setSize can cause WebGL texture copy errors during transitions)
    const rtParams = {
      minFilter: THREE.LinearFilter,
      magFilter: THREE.LinearFilter,
      format: THREE.RGBAFormat,
    };
    [renderTargetA, renderTargetB] = ensureRenderTargetPair(
      [renderTargetA, renderTargetB],
      targetW,
      targetH,
      (width, height, options) => new THREE.WebGLRenderTarget(width, height, options),
      rtParams,
    );
    
    // Also dispose and recreate blendTempRT if it exists
    if (blendTempRT && (blendTempRT.width !== targetW || blendTempRT.height !== targetH)) {
      blendTempRT.dispose();
      blendTempRT = null;
    }
    
    if (postFxMaterial) {
      postFxMaterial.uniforms.uResolution.value.set(targetW, targetH);
    }
  }

  // =========================
  // SCENE RENDER TARGET MANAGER
  // =========================
  // Manages render targets for scene tracks, enabling multi-scene compositing
  // Each scene renders to its own texture, then all layers are composited in order
  
  const sceneRenderTargetPool = createRenderTargetPool(
    (width, height, options) => new THREE.WebGLRenderTarget(width, height, options)
  );
  let sceneCompositeScene, sceneCompositeCamera, sceneCompositeQuad;
  let sceneCompositeMaterial;
  
  function initSceneCompositing() {
    ({
      sceneCompositeCamera,
      sceneCompositeScene,
      sceneCompositeMaterial,
      sceneCompositeQuad,
    } = createSceneCompositor(THREE));
  }
  function getSceneRenderTarget(trackId) {
    const { width: w, height: h, dpr } = compositionSurfaceMetrics();
    
    const rtParams = {
      minFilter: THREE.LinearFilter,
      magFilter: THREE.LinearFilter,
      format: THREE.RGBAFormat,
      // Enable alpha for scene layering
      alpha: true,
    };
    
    return sceneRenderTargetPool.get(trackId, w * dpr, h * dpr, rtParams);
  }
  
  function resizeSceneRenderTargets() {
    const { width: w, height: h, dpr } = compositionSurfaceMetrics();
    const targetW = w * dpr;
    const targetH = h * dpr;
    
    if (targetW <= 0 || targetH <= 0) return;
    
    sceneRenderTargetPool.invalidateSize(targetW, targetH);
  }
  
  // Composite a scene texture onto the current render target (or screen)
  // Temporary render target for reading destination when blending
  let blendTempRT = null;
  
  function getBlendTempRT() {
    const { width: w, height: h, dpr } = compositionSurfaceMetrics();
    const targetW = w * dpr;
    const targetH = h * dpr;
    
    blendTempRT = ensureRenderTarget(
      blendTempRT,
      targetW,
      targetH,
      (width, height, options) => new THREE.WebGLRenderTarget(width, height, options),
      {
        minFilter: THREE.LinearFilter,
        magFilter: THREE.LinearFilter,
        format: THREE.RGBAFormat,
      },
    );
    return blendTempRT;
  }
  
  // Reusable objects for copying textures
  let copyMat = null;
  let copyMesh = null;
  let copyScene = null;
  
  function ensureCopyObjects() {
    if (!copyMat) {
      copyMat = new THREE.MeshBasicMaterial({ transparent: false });
      copyMesh = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), copyMat);
      copyScene = new THREE.Scene();
      copyScene.add(copyMesh);
    }
  }
  
  function compositeSceneTexture(sceneTexture, opacity = 1.0, blendMode = 'normal', targetRT = null) {
    if (!sceneCompositeMaterial || !sceneTexture) return;
    
    const blendModeIdx = blendModeIndex(blendMode);
    
    sceneCompositeMaterial.uniforms.uTexture.value = sceneTexture;
    sceneCompositeMaterial.uniforms.uOpacity.value = opacity;
    sceneCompositeMaterial.uniforms.uBlendMode.value = blendModeIdx;
    
    // We always need to copy the current target to read from it for proper blending
    // (The shader does all blending manually since we need to read and write to same target)
    const tempRT = getBlendTempRT();
    
    // Copy current target to temp
    ensureCopyObjects();
    renderer.setRenderTarget(tempRT);
    
    if (targetRT) {
      // Copy from targetRT to temp
      copyMat.map = targetRT.texture;
      renderer.render(copyScene, sceneCompositeCamera);
    } else {
      // For screen target, we can't read from it, so start with black
      // This means the first layer should render correctly, subsequent layers blend on top
      renderer.setClearColor(0x000000, 1);
      renderer.clear();
    }
    
    sceneCompositeMaterial.uniforms.uDestTexture.value = tempRT.texture;
    
    const autoClear = renderer.autoClear;
    renderer.autoClear = false;
    renderer.setRenderTarget(targetRT);
    renderer.render(sceneCompositeScene, sceneCompositeCamera);
    renderer.autoClear = autoClear;
  }

  // =========================
  // TIMELINE MODEL - Multi-Track System
  // =========================
  
  // Track system: dynamic tracks that can be added, removed, reordered
  // Each track has: { id, type, name, order, segments }
  // type: 'scene' | 'shader' | 'media' | 'postfx'
  // order: lower = higher on screen, rendered first (tracks below render on top)
  let timelineTracks = [];
  let trackIdCounter = 0;
  
  // Generate unique track ID
  function generateTrackId() {
    const identity = nextTrackIdentity(trackIdCounter);
    trackIdCounter = identity.trackIdCounter;
    return identity.id;
  }
  
  // Create default tracks (one of each type)
  function createDefaultTracks() {
    const result = createDefaultTimelineTracks(trackIdCounter);
    timelineTracks = result.tracks;
    trackIdCounter = result.trackIdCounter;
  }
  
  // Add a new track
  function addTrack(type) {
    const result = appendTimelineTrack(timelineTracks, type, trackIdCounter);
    timelineTracks = result.tracks;
    trackIdCounter = result.trackIdCounter;
    renderTracks();
    scheduleTimelineAutoSave();
    return result.track;
  }
  
  // Remove a track by ID
  function removeTrack(trackId) {
    const idx = timelineTracks.findIndex(t => t.id === trackId);
    if (idx === -1) return false;
    
    // Save undo state before deletion
    saveUndoState();
    
    // Clear selection if selected segment was in this track
    if (selectedSegment && selectedSegment.trackId === trackId) {
      selectedSegment = null;
    }

    // Clear track selection if this track was selected
    if (selectedTimelineTrack === trackId) {
      selectedTimelineTrack = null;
    }

    // Clear minimized state if this track was minimized
    if (minimizedTracks.has(trackId)) {
      minimizedTracks.delete(trackId);
      saveMinimizedTracks();
    }

    // Clean up keyframe data for segments in this track
    const track = timelineTracks[idx];
    if (track.type === 'scene') {
      segmentSceneInstances.releaseTrack(trackId);
      sceneRenderTargetPool.release(trackId);
    }
    removeTrackSegmentState(
      [keyframeData, exposedParams, exposedParamsMeta, curveTypes, lfoData],
      legacyTrackName(track.type),
      trackId,
    );
    
    timelineTracks = removeTimelineTrack(timelineTracks, trackId);
    refreshPrimaryAudioState();
    audioClipPlayback?.sync(timelineTracks, getCurrentTime(), silentPlaying);
    
    renderTracks();
    scheduleTimelineAutoSave();
    return true;
  }
  
  // Reorder tracks (move trackId to newOrder)
  function reorderTrack(trackId, newOrder) {
    const track = timelineTracks.find(t => t.id === trackId);
    if (!track) return;
    if (track.order === newOrder) return;
    timelineTracks = reorderTimelineTracks(timelineTracks, trackId, newOrder);
    renderTracks();
    scheduleTimelineAutoSave();
  }
  
  // Get track by ID
  function getTrackById(trackId) {
    return findTrack(timelineTracks, trackId);
  }
  
  // Get all tracks of a specific type
  function getTracksByType(type) {
    return tracksOfType(timelineTracks, type);
  }
  
  // Get sorted tracks for rendering (by order)
  function getSortedTracks() {
    return sortedTracks(timelineTracks);
  }

  function extendSilentTimelineTo(end) {
    if (end <= durationGuess) return;
    durationGuess = end;
    layoutTimeline();
    drawWaveAndRuler();
  }
  
  // Legacy compatibility: Get all segments of a type across all tracks
  function getAllSegmentsOfType(type) {
    return timelineTracks
      .filter(t => t.type === type)
      .sort((a, b) => a.order - b.order)
      .flatMap(t => t.segments.map((seg, i) => ({
        ...seg,
        _trackId: t.id,
        _segIndex: i
      })));
  }
  
  // Legacy segment accessors - these now aggregate across all tracks of each type
  // Note: These return new arrays, so mutations won't persist. Use track.segments directly for mutations.
  function getSceneSegments() { return getAllSegmentsOfType('scene'); }
  function getShaderSegments() { return getAllSegmentsOfType('shader'); }
  function getImageSegments() { return getAllSegmentsOfType('media'); }
  function getPostfxSegments() { return getAllSegmentsOfType('postfx'); }
  function getTextSegments() { return getAllSegmentsOfType('text'); }
  function getLutSegments() { return getAllSegmentsOfType('lut'); }
  function getAudioSegments() { return getAllSegmentsOfType('audio'); }

  let selectedSegment = null; // { trackId, segmentIndex } or legacy { track, index }
  let selectedTimelineTrack = null; // trackId string or null
  let minimizedTracks = new Set(); // Set of trackId strings that are minimized

  // Restore minimized tracks from localStorage immediately
  (function() {
    try {
      const stored = localStorage.getItem('demotool_minimized_tracks');
      if (stored) {
        minimizedTracks = new Set(JSON.parse(stored));
      }
    } catch (e) {}
  })();

  let clipboardSegment = null; // { track, segment (deep copy), keyframes, curveTypes, lfoData, exposedParams, exposedParamsMeta }
  let durationGuess = 60; // Default for empty projects
  let projectFrameRate = 30;
  let projectLoadedFromServer = false; // Track if we've loaded saved project data
  
  // =========================
  // KEYFRAME SYSTEM
  // =========================
  let editorMode = 'timeline'; // 'timeline' | 'keyframe'
  
  // Keyframe storage: { segmentId: { paramId: [{ time, value, easing }] } }
  let keyframeData = {};
  
  // Track which parameters are exposed for keyframing per segment
  // { segmentId: Set<paramId> }
  let exposedParams = {};
  
  // LFO data per parameter: { segmentId: { paramId: { enabled, shape, frequency, amplitude, phase } } }
  let lfoData = {};
  
  // Curve types per parameter: { segmentId: { paramId: 'linear' | 'bezier' | 'step' } }
  
  // =========================
  // PROJECT DATA HELPERS (for server save/load)
  // =========================

  function migrateSceneClipOverrideOwnership(tracks = timelineTracks) {
    const migrated = ensureSceneSegmentOverrideOwnership(tracks, {
      readStoredOverride: key => localStorage.getItem(key),
      storagePrefix: STORAGE_PREFIX,
      storageVersion: STORAGE_VERSION,
      logger: console,
    });
    if (migrated > 0) {
      console.log(`[Scene Clips] Migrated ${migrated} clip(s) to independent overrides`);
    }
    return migrated;
  }

  function syncAllSceneClipOverrides() {
    for (const track of getTracksByType('scene')) {
      track.segments.forEach((segment, index) => {
        const sceneObj = segmentSceneInstances.get(getSegmentKey(track.id, index));
        if (!sceneObj) return;
        const state = serializeRuntimeSceneState(THREE, sceneObj);
        if (state) segment.sceneOverrides = state;
      });
    }
  }
  
  // Get current project state for saving
  function getProjectData() {
    syncAllSceneClipOverrides();
    refreshPrimaryAudioState();
    return buildSerializedProject({
      audioFile: currentAudioFile,
      audioDuration: currentAudioDuration,
      projectDuration: durationGuess,
      frameRate: projectFrameRate,
      viewportOrientation,
      tracks: timelineTracks,
      trackIdCounter,
      imageIdCounter,
      postfxIdCounter,
      shaderIdCounter,
      sceneIdCounter,
      textIdCounter,
      lutIdCounter,
      audioIdCounter,
      keyframeData,
      exposedParams,
      exposedParamsMeta,
      curveTypes,
      lfoData
    });
  }
  
  // Load project data from server
  function loadProjectFromServer(project) {
    if (!project) {
      console.warn('[LOAD] No project data to load');
      return;
    }

    // Project replacement invalidates pending clip saves and every per-segment
    // scene/GPU resource from the previous project.
    for (const timeout of sceneAutoSaveTimeouts.values()) clearTimeout(timeout);
    sceneAutoSaveTimeouts.clear();
    segmentSceneInstances.dispose();
    sceneRenderTargetPool.dispose();
    
    // Mark that we're loading from server - prevents auto-extension of scene lengths
    projectLoadedFromServer = true;
    
    console.log('[LOAD] Loading project:', {
      version: project._version,
      audioFile: project.audioFile,
      scenes: project.sceneSegments?.length || 0,
      shaders: project.shaderSegments?.length || 0,
      images: project.imageSegments?.length || 0,
      postfx: project.postfxSegments?.length || 0
    });
    
    // Load audio data
    if (project.audioFile) {
      currentAudioFile = project.audioFile;
      currentAudioDuration = project.audioDuration || 60;
      durationGuess = currentAudioDuration;
      AUDIO_URL = project.audioFile;
      updateAudioTrackUI();
      // Clear old waveform and set loading state
      wavePeaks = null;
      waveRawSamples = null;
      waveStatus.textContent = "Waveform: loading...";
      // Generate waveform for the loaded audio (async, non-blocking)
      generateWaveformOnServer(project.audioFile);
    } else {
      // No audio in project
      currentAudioFile = null;
      currentAudioDuration = 60;
      AUDIO_URL = null;
      wavePeaks = null;
      waveRawSamples = null;
      waveStatus.textContent = "Waveform: none";
      updateAudioTrackUI();
    }
    
    // Load viewport orientation (v8+), default to horizontal for older projects
    if (project.viewportOrientation) {
      viewportOrientation = normalizeViewportOrientation(project.viewportOrientation);
      console.log('[LOAD] Viewport orientation:', viewportOrientation);
    } else {
      viewportOrientation = 'horizontal'; // Default for legacy projects
    }
    // Update the settings UI to match
    if (viewportOrientationSelect) {
      viewportOrientationSelect.value = viewportOrientation;
    }
    
    // Load timeline data - handle both old format (v6-) and new format (v7+).
    const loadedTrackState = projectTracksForLoad(project, trackIdCounter);
    timelineTracks = loadedTrackState.tracks;
    trackIdCounter = loadedTrackState.trackIdCounter;
    migrateSceneClipOverrideOwnership();
    durationGuess = normalizeProjectDuration(
      project.projectDuration,
      timelineTracks,
      project.audioDuration || 60,
    );
    projectFrameRate = normalizeTimelineFrameRate(project.frameRate);
    for (const selectId of ['exportQuickFps', 'exportHQFps']) {
      const select = document.getElementById(selectId);
      if (select && [...select.options].some(option => Number(option.value) === projectFrameRate)) {
        select.value = String(projectFrameRate);
      }
    }
    refreshPrimaryAudioState();
    for (const { segment } of audioClipRecords(timelineTracks)) {
      loadAudioClipWaveform(segment.audioFile);
    }
    if (!loadedTrackState.migrated) {
      console.log('[LOAD] Loading multi-track format (v7+)');
    } else {
      console.log('[LOAD] Migrating legacy format to multi-track');
    }
    
    if (project.imageIdCounter !== undefined) imageIdCounter = project.imageIdCounter;
    if (project.postfxIdCounter !== undefined) postfxIdCounter = project.postfxIdCounter;
    if (project.shaderIdCounter !== undefined) shaderIdCounter = project.shaderIdCounter;
    if (project.sceneIdCounter !== undefined) sceneIdCounter = project.sceneIdCounter;
    if (project.textIdCounter !== undefined) textIdCounter = project.textIdCounter;
    if (project.lutIdCounter !== undefined) lutIdCounter = project.lutIdCounter;
    if (project.audioIdCounter !== undefined) {
      audioIdCounter = project.audioIdCounter;
    } else {
      audioIdCounter = audioClipRecords(timelineTracks).reduce((maximum, { segment }) => {
        const match = String(segment.id || '').match(/^audio(\d+)$/);
        return Math.max(maximum, match ? Number(match[1]) : 0);
      }, 0);
    }
    if (project.keyframeData) Object.assign(keyframeData, project.keyframeData);
    if (project.curveTypes) Object.assign(curveTypes, project.curveTypes);
    if (project.lfoData) Object.assign(lfoData, project.lfoData);
    if (project.exposedParamsMeta) Object.assign(exposedParamsMeta, project.exposedParamsMeta);
    
    const allSceneSegs = getSceneSegments();
    const allShaderSegs = getShaderSegments();
    const allImageSegs = getImageSegments();
    const allPostfxSegs = getPostfxSegments();
    const allLutSegs = getLutSegments();

    console.log('[LOAD] After loading:', {
      currentAudioFile,
      AUDIO_URL,
      tracks: timelineTracks.length,
      sceneSegments: allSceneSegs.length,
      shaderSegments: allShaderSegs.length,
      imageSegments: allImageSegs.length,
      postfxSegments: allPostfxSegs.length,
      lutSegments: allLutSegs.length
    });
    
    // Convert exposedParams arrays back to Sets
    if (project.exposedParams) {
      Object.assign(exposedParams, deserializeExposedParams(project.exposedParams));
    }
    const animationMigration = migrateIndexedSegmentState(
      [keyframeData, exposedParams, exposedParamsMeta, curveTypes, lfoData],
      timelineTracks,
    );
    if (animationMigration.migratedEntries || animationMigration.assignedIds) {
      console.log(
        `[Keyframes] Migrated ${animationMigration.migratedEntries} animation entries `
        + `to stable clip IDs; assigned ${animationMigration.assignedIds} missing clip IDs`,
      );
    }
    
    // Preload any media (images/videos) referenced in the timeline
    for (const seg of allImageSegs) {
      if (seg.imageFile) {
        seg.isVideo = isVideoFile(seg.imageFile);
        loadMedia(seg.imageFile).catch(err => console.warn('Failed to preload media:', seg.imageFile, err));
      }
    }
    
    // Preload any scene files referenced in the timeline (async, non-blocking)
    (async () => {
      for (const seg of allSceneSegs) {
        if (seg.sceneFile && seg.sceneIndex >= 0) {
          try {
            // Scene might already be loaded from the default loadAllScenes
            const existingIdx = loadedScenes.findIndex(s => s && s.data?._sourceFile === seg.sceneFile);
            if (existingIdx < 0) {
              const newIdx = await loadSceneFromFile(seg.sceneFile);
              seg.sceneIndex = newIdx;
            }
          } catch (err) {
            console.warn('Failed to preload scene:', seg.sceneFile, err);
          }
        }
      }
    })();
    
    // Preload any post-fx shader files referenced in the timeline
    const postfxShaderFilesToLoad = new Set();
    for (const seg of allPostfxSegs) {
      if (seg.shaderFile) {
        postfxShaderFilesToLoad.add(seg.shaderFile);
      }
    }
    
    for (const filename of postfxShaderFilesToLoad) {
      // Try assets/post-fx/ first, then post-fx/
      const paths = [`assets/post-fx/${filename}`, `post-fx/${filename}`];
      
      (async () => {
        for (const path of paths) {
          try {
            const response = await fetch(path);
            if (response.ok) {
              const shaderSource = await response.text();
              const { effectName, params, textureParams } = parseShaderParams(shaderSource);
              loadedPostFxShaders[filename] = {
                source: shaderSource,
                threeMaterial: null,
                effectName: effectName || filename.replace(/\.(glsl|frag|fs)$/i, ''),
                params: params,
                textureParams: textureParams || [],
              };
              console.log(`Loaded post-fx shader: ${filename}`);
              return;
            }
          } catch (e) { /* try next path */ }
        }
        console.warn('Failed to load post-fx shader:', filename);
      })();
    }
    
    // Preload any shader track files referenced in the timeline
    for (const seg of allShaderSegs) {
      if (seg.shaderFile) {
        loadShaderEffect(seg.shaderFile).catch(err =>
          console.warn('Failed to preload shader:', seg.shaderFile, err)
        );
      }
    }

    // Preload any LUT files referenced in the timeline
    for (const seg of allLutSegs) {
      if (seg.lutFile) {
        loadLUT(seg.lutFile).catch(err =>
          console.warn('Failed to preload LUT:', seg.lutFile, err)
        );
      }
    }

    console.log('Loaded project data from server');
  }
  let curveTypes = {};
  
  function getCurveType(segId, paramId) {
    return curveTypes[segId]?.[paramId] || 'linear';
  }
  
  function setCurveType(segId, paramId, type) {
    if (!curveTypes[segId]) curveTypes[segId] = {};
    curveTypes[segId][paramId] = type;
  }
  
  function getSegmentId(segment) {
    if (!segment) return null;
    if (segment.trackId) {
      const track = getTrackById(segment.trackId);
      const clip = track?.segments?.[segment.index];
      if (track && clip) {
        return stableSegmentStateKey(
          segment.track || legacyTrackName(track.type),
          segment.trackId,
          clip,
          segment.index,
        );
      }
    }
    // Legacy fallback
    return `${segment.track}_${segment.index}`;
  }
  
  // Helper to get segments array for a track
  function getSegmentsForTrack(trackNameOrId) {
    // Support both legacy track names ('scenes', 'shaders', etc.) and track IDs
    if (trackNameOrId.startsWith('track_')) {
      const track = getTrackById(trackNameOrId);
      return track ? track.segments : [];
    }
    // Legacy track names - aggregate from all tracks of that type
    switch(trackNameOrId) {
      case 'scenes': 
      case 'scene': 
        return getSceneSegments();
      case 'shaders': 
      case 'shader': 
        return getShaderSegments();
      case 'images': 
      case 'media': 
        return getImageSegments();
      case 'postfx': 
        return getPostfxSegments();
      case 'texts':
      case 'text':
        return getTextSegments();
      case 'luts':
      case 'lut':
        return getLutSegments();
      case 'audios':
      case 'audio':
        return getAudioSegments();
      default: return [];
    }
  }
  
  function addKeyframe(segId, paramId, time, value, easing = 'linear') {
    if (!keyframeData[segId]) keyframeData[segId] = {};
    keyframeData[segId][paramId] = upsertKeyframe(
      keyframeData[segId][paramId] || [], time, value, easing,
    );
  }
  
  function removeKeyframe(segId, paramId, index) {
    if (!keyframeData[segId]?.[paramId]) return;
    keyframeData[segId][paramId].splice(index, 1);
  }
  
  function interpolateKeyframes(segId, paramId, time) {
    const kfs = keyframeData[segId]?.[paramId];
    return interpolateKeyframeValues(kfs, time, getCurveType(segId, paramId));
  }
  
  // Calculate LFO value at a given time
  function calculateLfoValue(segId, paramId, time, paramRange) {
    const lfo = lfoData[segId]?.[paramId];
    const bpm = parseFloat(bpmInput.value) || 129.2;
    return calculatePureLfoValue(lfo, time, paramRange, bpm);
  }
  
  // Get combined value (keyframes + LFO)
  function getInterpolatedValue(segId, paramId, time, paramRange) {
    const kfValue = interpolateKeyframes(segId, paramId, time);
    const lfo = lfoData[segId]?.[paramId];
    const bpm = parseFloat(bpmInput.value) || 129.2;
    return combineAnimatedValue(kfValue, lfo, time, paramRange, bpm);
  }
  
  function getCurrentSegment() {
    if (!selectedSegment) return null;
    
    // Support both new trackId and legacy track name
    if (selectedSegment.trackId) {
      const track = getTrackById(selectedSegment.trackId);
      if (track) {
        return track.segments[selectedSegment.index];
      }
    }
    
    // Fall back to legacy method if trackId not present
    if (selectedSegment.track) {
    const segs = getSegmentsForTrack(selectedSegment.track);
    return segs[selectedSegment.index];
    }
    
    return null;
  }
  
  function isParamExposed(segId, paramId) {
    // Handle case where exposedParams might be an array instead of Set (defensive)
    const exposed = exposedParams[segId];
    if (!exposed) return false;
    const hasExposed = exposed instanceof Set ? exposed.has(paramId) : Array.isArray(exposed) ? exposed.includes(paramId) : false;
    if (!hasExposed) return false;
    
    // For object parameters, check if the keyframe belongs to the currently selected object
    if (paramId.startsWith('obj_') && selectedSceneObject?.type === 'object') {
      const meta = exposedParamsMeta[segId]?.[paramId];
      const keyframedObjectId = meta?.objectId;
      const currentObjectId = selectedSceneObject.id;
      
      // Only return true if it's for the same object
      if (keyframedObjectId !== undefined && String(keyframedObjectId) !== String(currentObjectId)) {
        return false;
      }
    }
    
    // For light parameters, check if the keyframe belongs to the currently selected light
    if (paramId.startsWith('light_') && selectedSceneObject?.type === 'light') {
      const meta = exposedParamsMeta[segId]?.[paramId];
      const keyframedLightIndex = meta?.lightIndex;
      const currentLightIndex = parseInt(selectedSceneObject.id);
      
      // Only return true if it's for the same light
      if (keyframedLightIndex !== undefined && keyframedLightIndex !== currentLightIndex) {
        return false;
      }
    }
    
    return true;
  }
  
  // Apply keyframe values during playback
  function applyKeyframesToScene(currentTime) {
    // Find which segment we're currently in based on time
    // Check all segments for keyframes
    for (const track of KEYFRAME_TRACK_NAMES) {
      const segs = getSegmentsForTrack(track);
      
      for (let i = 0; i < segs.length; i++) {
        const seg = segs[i];
        if (currentTime < seg.start || currentTime > seg.end) continue;
        
        // Use trackId for unique identification when available (from getAllSegmentsOfType)
        const segId = seg._trackId
          ? stableSegmentStateKey(track, seg._trackId, seg, seg._segIndex)
          : `${track}_${i}`;
        const exposed = exposedParams[segId];
        // Handle case where exposedParams might be an array instead of Set (defensive)
        if (!exposed) continue;
        const exposedSet = exposed instanceof Set ? exposed : new Set(exposed);
        if (exposedSet.size === 0) continue;
        
        const localTime = currentTime - seg.start;
        
        for (const paramId of exposedSet) {
          try {
          const kfs = keyframeData[segId]?.[paramId];
            const hasLfo = lfoData[segId]?.[paramId]?.enabled;
            if ((!kfs || kfs.length === 0) && !hasLfo) continue;
          
            const paramRange = getParamRange(paramId, segId);
            const value = getInterpolatedValue(segId, paramId, localTime, paramRange);
          if (value === null) continue;
          
          // Apply directly to the data (works even when sliders don't exist)
          applyKeyframeValueDirect(track, seg, paramId, value, i);
          
          // Also update slider if it exists (for visual feedback)
          // BUT only if this keyframe is for the currently selected segment/object/light
          const slider = document.getElementById(paramId);
          if (slider && !parameterEditSession?.isEditing()) {
            // Check if this keyframe belongs to the currently selected segment
            let shouldUpdateSlider = true;
            
            // For clip-level tracks, only update the slider for the selected segment
            // This prevents keyframes from one clip affecting the slider of another clip
            if (
              track === 'images'
              || track === 'postfx'
              || track === 'luts'
              || track === 'shaders'
              || track === 'texts'
              || track === 'audios'
            ) {
              const selectedSegId = selectedSegment ? getSegmentId(selectedSegment) : null;
              if (segId !== selectedSegId) {
                shouldUpdateSlider = false;
              }
            }
            // For scene tracks, check object/light ownership
            else if (paramId.startsWith('obj_') && selectedSceneObject?.type === 'object') {
              const meta = exposedParamsMeta[segId]?.[paramId];
              const keyframedObjectId = meta?.objectId;
              const currentObjectId = selectedSceneObject.id;
              if (keyframedObjectId !== undefined && String(keyframedObjectId) !== String(currentObjectId)) {
                shouldUpdateSlider = false;
              }
            } else if (paramId.startsWith('light_') && selectedSceneObject?.type === 'light') {
              const meta = exposedParamsMeta[segId]?.[paramId];
              const keyframedLightIndex = meta?.lightIndex;
              const currentLightIndex = parseInt(selectedSceneObject.id);
              if (keyframedLightIndex !== undefined && keyframedLightIndex !== currentLightIndex) {
                shouldUpdateSlider = false;
              }
            }
            
            if (shouldUpdateSlider) {
              const oldValue = parseFloat(slider.value);
              if (Math.abs(oldValue - value) > 0.0001) {
                slider.value = value;
                // Don't dispatch event - we already applied directly

                // Also update the number input if it exists
                const numInput = document.getElementById(paramId + '_num');
                if (numInput) {
                  numInput.value = value.toFixed(3);
                }
                updateSliderThumb(slider);
              }
            }
          }
          } catch (err) {
            // Log but don't break the animation loop
            console.warn(`Keyframe error for ${paramId}:`, err);
          }
        }
      }
    }
    
    // Update keyframe playhead if in keyframe mode
    if (editorMode === 'keyframe') {
      updateKeyframePlayhead();
    }
  }
  
  // Apply a keyframe value directly to the data (bypassing sliders)
  function applyKeyframeValueDirect(track, seg, paramId, value, segIndex) {
    if (track === 'audios') {
      let originalSeg = seg;
      if (seg._trackId) {
        const originalTrack = getTrackById(seg._trackId);
        if (originalTrack && originalTrack.segments[seg._segIndex]) {
          originalSeg = originalTrack.segments[seg._segIndex];
        }
      }
      applyAudioClipParameter(originalSeg, paramId, value);
    } else if (track === 'images') {
      // Image overlay parameters
      // NOTE: seg might be a copy from getAllSegmentsOfType, so we need to find the original
      let originalSeg = seg;
      if (seg._trackId) {
        const originalTrack = getTrackById(seg._trackId);
        if (originalTrack && originalTrack.segments[seg._segIndex]) {
          originalSeg = originalTrack.segments[seg._segIndex];
        }
      }
      switch (paramId) {
        case 'img_x': originalSeg.x = value; break;
        case 'img_y': originalSeg.y = value; break;
        case 'img_scale': originalSeg.scale = value; break;
        case 'img_rotation': originalSeg.rotation = value; break;
        case 'img_opacity': originalSeg.opacity = value; break;
        case 'img_volume': originalSeg.volume = value; break;
      }
    } else if (track === 'postfx') {
      // Post-FX uniform parameters (UI uses pfx_ prefix)
      // NOTE: seg might be a copy from getAllSegmentsOfType, so we need to find the original
      let originalSeg = seg;
      if (seg._trackId) {
        const originalTrack = getTrackById(seg._trackId);
        if (originalTrack && originalTrack.segments[seg._segIndex]) {
          originalSeg = originalTrack.segments[seg._segIndex];
        }
      }
      if (originalSeg.uniforms && paramId.startsWith('pfx_')) {
        const uniformName = paramId.replace('pfx_', '');
        originalSeg.uniforms[uniformName] = value;
        
        // Also update the actual shader uniform if loaded
        const shaderInfo = loadedPostFxShaders[originalSeg.shaderFile];
        if (shaderInfo?.threeMaterial?.uniforms?.[uniformName]) {
          shaderInfo.threeMaterial.uniforms[uniformName].value = value;
        }
      }
    } else if (track === 'luts') {
      let originalSeg = seg;
      if (seg._trackId) {
        const originalTrack = getTrackById(seg._trackId);
        if (originalTrack && originalTrack.segments[seg._segIndex]) {
          originalSeg = originalTrack.segments[seg._segIndex];
        }
      }
      applyLutClipParameter(originalSeg, paramId, value);
    } else if (track === 'shaders') {
      // Shader effect uniform parameters
      // NOTE: seg might be a copy from getAllSegmentsOfType, so we need to find the original
      let originalSeg = seg;
      if (seg._trackId) {
        const originalTrack = getTrackById(seg._trackId);
        if (originalTrack && originalTrack.segments[seg._segIndex]) {
          originalSeg = originalTrack.segments[seg._segIndex];
        }
      }
      if (paramId === 'shader_opacity') {
        originalSeg.opacity = value;
      } else if (paramId.startsWith('shader_')) {
        const uniformName = paramId.replace('shader_', '');
        if (!originalSeg.uniforms) originalSeg.uniforms = {};
        originalSeg.uniforms[uniformName] = value;
      }
    } else if (track === 'scenes') {
      // Scene parameters - need to apply to the loaded scene
      // Use segment-specific instance if available
      let sceneObj = null;
      if (seg._trackId) {
        const key = getSegmentKey(seg._trackId, seg._segIndex);
        sceneObj = segmentSceneInstances.get(key);
      }
      // Fallback to legacy loadedScenes
      if (!sceneObj) {
        sceneObj = loadedScenes[seg.sceneIndex];
      }
      
      // Scene-level parameters that affect the segment (not the 3D scene)
      // These need to update the original segment, similar to images
      if (paramId === 'scene_opacity') {
        // Get the original segment from the track
        let originalSeg = seg;
        if (seg._trackId) {
          const originalTrack = getTrackById(seg._trackId);
          if (originalTrack && originalTrack.segments[seg._segIndex]) {
            originalSeg = originalTrack.segments[seg._segIndex];
          }
        }
        originalSeg.opacity = value;
        return; // Don't need sceneObj for this
      }
      
      if (!sceneObj) return;
      
      // Camera parameters
      if (paramId === 'cam_fov' && sceneObj.camera?.isPerspectiveCamera) {
        sceneObj.camera.fov = value;
        sceneObj.camera.updateProjectionMatrix();
        if (sceneObj.data?.camera) sceneObj.data.camera.fov = value;
      } else if (paramId === 'cam_pos_x' && sceneObj.camera) {
        sceneObj.camera.position.x = value;
        if (sceneObj.data?.camera?.position) sceneObj.data.camera.position.x = value;
      } else if (paramId === 'cam_pos_y' && sceneObj.camera) {
        sceneObj.camera.position.y = value;
        if (sceneObj.data?.camera?.position) sceneObj.data.camera.position.y = value;
      } else if (paramId === 'cam_pos_z' && sceneObj.camera) {
        sceneObj.camera.position.z = value;
        if (sceneObj.data?.camera?.position) sceneObj.data.camera.position.z = value;
      } else if (paramId === 'cam_rot_x' && sceneObj.camera) {
        sceneObj.camera.rotation.x = THREE.MathUtils.degToRad(value);
        if (sceneObj.data?.camera) {
          sceneObj.data.camera.rotation = sceneObj.data.camera.rotation || {};
          sceneObj.data.camera.rotation.x = value;
        }
      } else if (paramId === 'cam_rot_y' && sceneObj.camera) {
        sceneObj.camera.rotation.y = THREE.MathUtils.degToRad(value);
        if (sceneObj.data?.camera) {
          sceneObj.data.camera.rotation = sceneObj.data.camera.rotation || {};
          sceneObj.data.camera.rotation.y = value;
        }
      } else if (paramId === 'cam_rot_z' && sceneObj.camera) {
        sceneObj.camera.rotation.z = THREE.MathUtils.degToRad(value);
        if (sceneObj.data?.camera) {
          sceneObj.data.camera.rotation = sceneObj.data.camera.rotation || {};
          sceneObj.data.camera.rotation.z = value;
        }
      }
      // Camera orbit animation parameters
      else if (paramId === 'cam_orbit_speed' && sceneObj.data?.camera?.animation?.orbit) {
        sceneObj.data.camera.animation.orbit.speed = value;
      } else if (paramId === 'cam_orbit_radius' && sceneObj.data?.camera?.animation?.orbit) {
        sceneObj.data.camera.animation.orbit.radius = value;
      } else if (paramId === 'cam_orbit_height' && sceneObj.data?.camera?.animation?.orbit) {
        sceneObj.data.camera.animation.orbit.height = value;
      } else if (paramId === 'cam_orbit_target_x' && sceneObj.data?.camera?.animation?.orbit) {
        sceneObj.data.camera.animation.orbit.target = sceneObj.data.camera.animation.orbit.target || {};
        sceneObj.data.camera.animation.orbit.target.x = value;
      } else if (paramId === 'cam_orbit_target_y' && sceneObj.data?.camera?.animation?.orbit) {
        sceneObj.data.camera.animation.orbit.target = sceneObj.data.camera.animation.orbit.target || {};
        sceneObj.data.camera.animation.orbit.target.y = value;
      } else if (paramId === 'cam_orbit_target_z' && sceneObj.data?.camera?.animation?.orbit) {
        sceneObj.data.camera.animation.orbit.target = sceneObj.data.camera.animation.orbit.target || {};
        sceneObj.data.camera.animation.orbit.target.z = value;
      } else if (paramId === 'cam_orbit_vosc_amp' && sceneObj.data?.camera?.animation?.orbit?.verticalOscillation) {
        sceneObj.data.camera.animation.orbit.verticalOscillation.amplitude = value;
      } else if (paramId === 'cam_orbit_vosc_spd' && sceneObj.data?.camera?.animation?.orbit?.verticalOscillation) {
        sceneObj.data.camera.animation.orbit.verticalOscillation.speed = value;
      }
      // Camera flyover animation parameters
      else if (paramId === 'cam_fly_speed' && sceneObj.data?.camera?.animation?.flyover) {
        sceneObj.data.camera.animation.flyover.speed = value;
      } else if (paramId === 'cam_fly_height' && sceneObj.data?.camera?.animation?.flyover) {
        sceneObj.data.camera.animation.flyover.height = value;
      } else if (paramId === 'cam_fly_sway' && sceneObj.data?.camera?.animation?.flyover) {
        sceneObj.data.camera.animation.flyover.sway = value;
      }
      // Camera tunnel animation parameters
      else if (paramId === 'cam_tunnel_speed' && sceneObj.data?.camera?.animation?.tunnel) {
        sceneObj.data.camera.animation.tunnel.speed = value;
      } else if (paramId === 'cam_tunnel_wobble' && sceneObj.data?.camera?.animation?.tunnel) {
        sceneObj.data.camera.animation.tunnel.wobble = value;
      } else if (paramId === 'cam_tunnel_wobble_speed' && sceneObj.data?.camera?.animation?.tunnel) {
        sceneObj.data.camera.animation.tunnel.wobbleSpeed = value;
      }
      // Light parameters
      else if (paramId.startsWith('light_')) {
        // Find all lights using traverse (in case they're nested in groups)
        const lights = [];
        sceneObj.threeScene?.traverse(obj => {
          if (obj.isLight) lights.push(obj);
        });
        
        // Get light index from metadata, fallback to 0
        // Use trackId-based segId when available
        const segId = seg._trackId
          ? stableSegmentStateKey('scenes', seg._trackId, seg, seg._segIndex)
          : `scenes_${segIndex}`;
        const meta = exposedParamsMeta[segId]?.[paramId];
        const lightIdx = meta?.lightIndex ?? 0;
        const light = lights[lightIdx];
        
        if (light) {
          if (paramId === 'light_intensity') {
            light.intensity = value;
            // Sync ambient light to grass shader (since grass uses custom shader)
            if (light.isAmbientLight && sceneObj.grassMesh?.material?.uniforms?.uAmbient) {
              sceneObj.grassMesh.material.uniforms.uAmbient.value = Math.min(1.0, value * 0.5);
            }
          } else if (paramId === 'light_pos_x' && light.position) {
            light.position.x = value;
            // Sync directional/point lights to grass shader light direction
            if ((light.isDirectionalLight || light.isPointLight) && sceneObj.grassMesh?.material?.uniforms?.uLightDir) {
              sceneObj.grassMesh.material.uniforms.uLightDir.value.set(light.position.x, light.position.y, light.position.z).normalize();
            }
          } else if (paramId === 'light_pos_y' && light.position) {
            light.position.y = value;
            if ((light.isDirectionalLight || light.isPointLight) && sceneObj.grassMesh?.material?.uniforms?.uLightDir) {
              sceneObj.grassMesh.material.uniforms.uLightDir.value.set(light.position.x, light.position.y, light.position.z).normalize();
            }
          } else if (paramId === 'light_pos_z' && light.position) {
            light.position.z = value;
            if ((light.isDirectionalLight || light.isPointLight) && sceneObj.grassMesh?.material?.uniforms?.uLightDir) {
              sceneObj.grassMesh.material.uniforms.uLightDir.value.set(light.position.x, light.position.y, light.position.z).normalize();
            }
          }
        }
      }
      // Object parameters
      else if (paramId.startsWith('obj_')) {
        // Get object ID from metadata, fallback to first object
        // Use trackId-based segId when available
        const segId = seg._trackId
          ? stableSegmentStateKey('scenes', seg._trackId, seg, seg._segIndex)
          : `scenes_${segIndex}`;
        const meta = exposedParamsMeta[segId]?.[paramId];
        const targetObjectId = meta?.objectId;
        
        // Find the object by the object ID from metadata (supports meshes and GLTF groups)
        let targetObj = null;
        sceneObj.threeScene?.traverse((obj) => {
          if ((obj.isMesh || obj.userData?.isGLTF) && obj.userData?.objIndex !== undefined) {
            // If we have metadata, only match the target object
            if (targetObjectId !== undefined && String(obj.userData.objIndex) !== String(targetObjectId)) {
              return;
            }
            // Prefer GLTF root over child meshes
            if (!targetObj || obj.userData?.isGLTF) {
              targetObj = obj;
            }
          }
        });
        
        if (targetObj) {
          // Transform parameters - apply to root object
          if (paramId === 'obj_pos_x') targetObj.position.x = value;
          else if (paramId === 'obj_pos_y') targetObj.position.y = value;
          else if (paramId === 'obj_pos_z') targetObj.position.z = value;
          else if (paramId === 'obj_rot_x') targetObj.rotation.x = value * Math.PI / 180;
          else if (paramId === 'obj_rot_y') targetObj.rotation.y = value * Math.PI / 180;
          else if (paramId === 'obj_rot_z') targetObj.rotation.z = value * Math.PI / 180;
          else if (paramId === 'obj_scale_x') targetObj.scale.x = value;
          else if (paramId === 'obj_scale_y') targetObj.scale.y = value;
          else if (paramId === 'obj_scale_z') targetObj.scale.z = value;
          // Material parameters - for 3D models (GLTF, OBJ, FBX), apply to all child meshes
          else if (paramId === 'obj_opacity' || paramId === 'obj_metalness' || paramId === 'obj_roughness' || paramId === 'obj_envmap') {
            const applyToMaterial = (mat) => {
              if (!mat) return;
              if (paramId === 'obj_opacity') mat.opacity = value;
              else if (paramId === 'obj_metalness' && mat.metalness !== undefined) mat.metalness = value;
              else if (paramId === 'obj_roughness' && mat.roughness !== undefined) mat.roughness = value;
              else if (paramId === 'obj_envmap' && mat.envMapIntensity !== undefined) mat.envMapIntensity = value;
            };
            
            // For any 3D model group (GLTF, OBJ, FBX, or any Group), traverse all children
            if (targetObj.userData?.isGLTF || targetObj.userData?.isOBJ || targetObj.userData?.isFBX || targetObj.isGroup || targetObj.type === 'Group') {
              targetObj.traverse((child) => {
                if (child.isMesh) {
                  if (Array.isArray(child.material)) {
                    child.material.forEach(m => applyToMaterial(m));
                  } else {
                    applyToMaterial(child.material);
                  }
                }
              });
            } else {
              applyToMaterial(targetObj.material);
            }
          }
        }
      }
      // Grass parameters
      else if (paramId.startsWith('grass_') && sceneObj.grassMesh?.material?.uniforms) {
        const u = sceneObj.grassMesh.material.uniforms;
        if (paramId === 'grass_wind_speed' && u.uWindSpeed) u.uWindSpeed.value = value;
        else if (paramId === 'grass_wind_str' && u.uWindStrength) u.uWindStrength.value = value;
        else if (paramId === 'grass_ambient' && u.uAmbient) u.uAmbient.value = value;
        else if (paramId === 'grass_ao' && u.uAOStrength) u.uAOStrength.value = value;
      }
      // Voxel parameters
      else if (paramId.startsWith('voxel_') && sceneObj.voxelMesh?.material?.uniforms) {
        const u = sceneObj.voxelMesh.material.uniforms;
        if (paramId === 'voxel_disp_str' && u.uDispStrength) u.uDispStrength.value = value;
        else if (paramId === 'voxel_disp_freq' && u.uDispFrequency) u.uDispFrequency.value = value;
        else if (paramId === 'voxel_disp_speed' && u.uDispSpeed) u.uDispSpeed.value = value;
        else if (paramId === 'voxel_glow_str' && u.uGlowStrength) u.uGlowStrength.value = value;
      }
      // Sphere cloner parameters
      else if (paramId.startsWith('cloner_') && sceneObj.sphereClonerMesh) {
        const u = sceneObj.sphereClonerMesh.material?.uniforms || {};
        const clonerData = sceneObj.data?.sphereCloner;
        if (paramId === 'cloner_offset_x') {
          sceneObj.sphereClonerMesh.position.x = value;
          if (clonerData) clonerData.offsetX = value;
        }
        else if (paramId === 'cloner_offset_y') {
          sceneObj.sphereClonerMesh.position.y = value;
          if (clonerData) clonerData.offsetY = value;
        }
        else if (paramId === 'cloner_offset_z') {
          sceneObj.sphereClonerMesh.position.z = value;
          if (clonerData) clonerData.offsetZ = value;
        }
        else if (paramId === 'cloner_rotation_x') {
          sceneObj.sphereClonerMesh.rotation.x = value * Math.PI / 180;
          if (clonerData) clonerData.rotationX = value;
        }
        else if (paramId === 'cloner_rotation_y') {
          sceneObj.sphereClonerMesh.rotation.y = value * Math.PI / 180;
          if (clonerData) clonerData.rotationY = value;
        }
        else if (paramId === 'cloner_rotation_z') {
          sceneObj.sphereClonerMesh.rotation.z = value * Math.PI / 180;
          if (clonerData) clonerData.rotationZ = value;
        }
        else if (paramId === 'cloner_base_radius' && u.uBaseRadius) u.uBaseRadius.value = value;
        else if (paramId === 'cloner_offset_amt' && u.uOffsetAmount) u.uOffsetAmount.value = value;
        else if (paramId === 'cloner_offset_spd' && u.uOffsetSpeed) u.uOffsetSpeed.value = value;
        else if (paramId === 'cloner_rot_spd' && u.uRotationSpeed) u.uRotationSpeed.value = value;
        else if (paramId === 'cloner_orbit_spd' && u.uOrbitSpeed) u.uOrbitSpeed.value = value;
        else if (paramId === 'cloner_pulse_amt' && u.uPulseAmount) u.uPulseAmount.value = value;
        else if (paramId === 'cloner_pulse_spd' && u.uPulseSpeed) u.uPulseSpeed.value = value;
        else if (paramId === 'cloner_breath_amt' && u.uBreathAmount) u.uBreathAmount.value = value;
        else if (paramId === 'cloner_breath_spd' && u.uBreathSpeed) u.uBreathSpeed.value = value;
        else if (paramId === 'cloner_em_int' && u.uEmissiveIntensity) u.uEmissiveIntensity.value = value;
        else if (paramId === 'cloner_metalness' && u.uMetalness) u.uMetalness.value = value;
        else if (paramId === 'cloner_roughness' && u.uRoughness) u.uRoughness.value = value;
        else if (paramId === 'cloner_env_int' && u.uEnvMapIntensity) u.uEnvMapIntensity.value = value;
        else if (paramId === 'cloner_fresnel' && u.uFresnelPower) u.uFresnelPower.value = value;
        // Light parameters
        else if (paramId === 'cloner_light_int' && u.uLightIntensity) u.uLightIntensity.value = value;
        else if (paramId === 'cloner_ambient_int' && u.uAmbientIntensity) u.uAmbientIntensity.value = value;
      }
      // Particle system parameters
      else if (paramId.startsWith('particle_')) {
        applyParticleKeyframeValue(sceneObj, paramId, value);
      }
      // Newer procedural scene systems share a declarative keyframe dispatcher.
      // This keeps K-button parameters connected to both their config and runtime target.
      else if (applyProceduralSceneKeyframeValue(THREE, sceneObj, paramId, value)) {
        // Applied by the shared procedural binding registry.
      }
      // Crystal Lattice parameters
      else if (paramId.startsWith('crystal_') && sceneObj.crystalLatticeMesh) {
        const mesh = sceneObj.crystalLatticeMesh;
        const u = mesh.userData?.crystalMesh?.material?.uniforms;
        const connMesh = mesh.userData?.connMesh;
        // Transform parameters
        if (paramId === 'crystal_pos_x') mesh.position.x = value;
        else if (paramId === 'crystal_pos_y') mesh.position.y = value;
        else if (paramId === 'crystal_pos_z') mesh.position.z = value;
        else if (paramId === 'crystal_rot_x') mesh.rotation.x = value * Math.PI / 180;
        else if (paramId === 'crystal_rot_y') mesh.rotation.y = value * Math.PI / 180;
        else if (paramId === 'crystal_rot_z') mesh.rotation.z = value * Math.PI / 180;
        else if (paramId === 'crystal_scale') {
          mesh.scale.set(value, value, value);
        }
        // Animation parameters
        else if (paramId === 'crystal_formation' && u?.uFormation) u.uFormation.value = value;
        else if (paramId === 'crystal_rot_speed' && u?.uRotationSpeed) u.uRotationSpeed.value = value;
        else if (paramId === 'crystal_shimmer' && u?.uShimmerSpeed) u.uShimmerSpeed.value = value;
        else if (paramId === 'crystal_pulse' && u?.uPulseSpeed) u.uPulseSpeed.value = value;
        // Color parameters (value is hex string like "#ff0000")
        else if (paramId === 'crystal_crystal_color' && u?.uCrystalColor) u.uCrystalColor.value.set(value);
        else if (paramId === 'crystal_core_color' && u?.uCoreColor) u.uCoreColor.value.set(value);
        else if (paramId === 'crystal_emissive_color' && u?.uEmissiveColor) u.uEmissiveColor.value.set(value);
        else if (paramId === 'crystal_conn_color' && connMesh?.material) connMesh.material.color.set(value);
        else if (paramId === 'crystal_light_color' && u?.uLightColor) u.uLightColor.value.set(value);
        else if (paramId === 'crystal_ambient_color' && u?.uAmbientColor) u.uAmbientColor.value.set(value);
        // Material parameters
        else if (paramId === 'crystal_metalness' && u?.uMetalness) u.uMetalness.value = value;
        else if (paramId === 'crystal_roughness' && u?.uRoughness) u.uRoughness.value = value;
        else if (paramId === 'crystal_envmap' && u?.uEnvMapIntensity) u.uEnvMapIntensity.value = value;
        else if (paramId === 'crystal_emissive_int' && u?.uEmissiveIntensity) u.uEmissiveIntensity.value = value;
        else if (paramId === 'crystal_opacity' && u?.uOpacity) u.uOpacity.value = value;
        else if (paramId === 'crystal_fresnel' && u?.uFresnelPower) u.uFresnelPower.value = value;
        else if (paramId === 'crystal_fresnel_bias' && u?.uFresnelBias) u.uFresnelBias.value = value;
        // Light intensity parameters
        else if (paramId === 'crystal_light_int' && u?.uLightIntensity) u.uLightIntensity.value = value;
        else if (paramId === 'crystal_ambient_int' && u?.uAmbientIntensity) u.uAmbientIntensity.value = value;
        else if (paramId === 'crystal_specular_int' && u?.uSpecularIntensity) u.uSpecularIntensity.value = value;
      }
    } else if (track === 'texts') {
      // Text overlay parameters
      let originalSeg = seg;
      if (seg._trackId) {
        const originalTrack = getTrackById(seg._trackId);
        if (originalTrack && originalTrack.segments[seg._segIndex]) {
          originalSeg = originalTrack.segments[seg._segIndex];
        }
      }
      switch (paramId) {
        case 'text_scale': originalSeg.textScale = value / 100; break;
        case 'text_line_height': originalSeg.lineHeight = value; break;
        case 'text_letter_spacing': originalSeg.letterSpacing = value; break;
        case 'text_area_x': originalSeg.areaX = value; break;
        case 'text_area_y': originalSeg.areaY = value; break;
        case 'text_area_width': originalSeg.areaWidth = value; break;
        case 'text_area_height': originalSeg.areaHeight = value; break;
        case 'text_padding': originalSeg.padding = value; break;
        case 'text_opacity': originalSeg.opacity = value; break;
      }
    }
  }

  // Initialize tracks as empty - project data will be loaded from server
  // (tracks are initialized via createDefaultTracks() or loadProjectFromServer())

  // Image overlay storage
  const loadedImages = {}; // { filename: HTMLImageElement }
  const mediaLoadPromises = new Map();
  let imageIdCounter = 0;
  
  // Double-buffered video system for seamless looping
  // { filename: { videoA, videoB, readyA, readyB, activeBuffer, lastLoopCycle } }
  const videoBuffers = {};

  // Post-FX shader storage
  let loadedPostFxShaders = {}; // { filename: { program, uniforms } }
  let postfxIdCounter = 0;
  
  // Shader (visual effect) storage
  const loadedShaders = {}; // Cache: { filename: { source, material, effectName, params } }
  let shaderIdCounter = 0;
  
  // Scene counter
  let sceneIdCounter = 10; // Start at 10 to avoid conflicts with default scenes
  
  // Text overlay storage and counter
  let textIdCounter = 0;

  // LUT counter
  let lutIdCounter = 0;

  // Audio clip counter
  let audioIdCounter = 0;

  // LUT storage: { lutPath: { texture3D, size, name } }
  const loadedLUTs = {};

  /**
   * Parse .cube LUT file format
   * Standard format:
   *   TITLE "Name"
   *   LUT_3D_SIZE 17  (or 33, 65)
   *   0.0 0.0 0.0
   *   0.0 0.0 0.06
   *   ...
   */
  function parseCubeLUT(cubeText) {
    return parseCubeLUTData(cubeText);
  }

  /**
   * Create a THREE.Data3DTexture from parsed LUT data
   */
  function createLUTTexture(lutData) {
    const { size, data } = lutData;

    const textureData = lutToRgba8({ size, data });

    const texture = new THREE.Data3DTexture(textureData, size, size, size);
    texture.format = THREE.RGBAFormat;
    texture.type = THREE.UnsignedByteType;
    texture.minFilter = THREE.LinearFilter;
    texture.magFilter = THREE.LinearFilter;
    texture.wrapS = THREE.ClampToEdgeWrapping;
    texture.wrapT = THREE.ClampToEdgeWrapping;
    texture.wrapR = THREE.ClampToEdgeWrapping;
    texture.needsUpdate = true;

    return texture;
  }

  /**
   * Load a .cube LUT file and create texture
   */
  async function loadLUT(lutPath) {
    if (loadedLUTs[lutPath]) {
      return loadedLUTs[lutPath];
    }

    try {
      const response = await fetch('/' + lutPath);
      if (!response.ok) throw new Error(`Failed to fetch ${lutPath}`);

      const cubeText = await response.text();
      const parsed = parseCubeLUT(cubeText);
      const texture = createLUTTexture(parsed);

      loadedLUTs[lutPath] = {
        texture3D: texture,
        size: parsed.size,
        name: parsed.title,
      };

      console.log(`[LUT] Loaded: ${parsed.title} (${parsed.size}x${parsed.size}x${parsed.size})`);
      return loadedLUTs[lutPath];
    } catch (err) {
      console.error('[LUT] Failed to load:', lutPath, err);
      return null;
    }
  }

  // Available fonts for text overlays
  const AVAILABLE_FONTS = [
    // Sans-serif
    { name: 'Inter', family: 'Inter, sans-serif', weights: [400, 500, 600, 700] },
    { name: 'Space Grotesk', family: '"Space Grotesk", sans-serif', weights: [400, 500, 600, 700] },
    { name: 'Montserrat', family: 'Montserrat, sans-serif', weights: [400, 500, 600, 700, 800, 900] },
    { name: 'Poppins', family: 'Poppins, sans-serif', weights: [400, 500, 600, 700, 800, 900] },
    { name: 'Raleway', family: 'Raleway, sans-serif', weights: [400, 500, 600, 700, 800, 900] },
    { name: 'Oswald', family: 'Oswald, sans-serif', weights: [400, 500, 600, 700] },
    // Display
    { name: 'Bebas Neue', family: '"Bebas Neue", sans-serif', weights: [400] },
    { name: 'Archivo Black', family: '"Archivo Black", sans-serif', weights: [400] },
    { name: 'Cinzel', family: 'Cinzel, serif', weights: [400, 500, 600, 700] },
    { name: 'DM Serif Display', family: '"DM Serif Display", serif', weights: [400] },
    // Serif
    { name: 'Playfair Display', family: '"Playfair Display", serif', weights: [400, 500, 600, 700] },
    { name: 'Lora', family: 'Lora, serif', weights: [400, 500, 600, 700] },
    { name: 'Cormorant Garamond', family: '"Cormorant Garamond", serif', weights: [400, 500, 600, 700] },
    { name: 'Libre Baskerville', family: '"Libre Baskerville", serif', weights: [400, 700] },
    { name: 'Source Serif 4', family: '"Source Serif 4", serif', weights: [400, 500, 600, 700] },
    { name: 'Bodoni Moda', family: '"Bodoni Moda", serif', weights: [400, 500, 600, 700] },
    // Monospace
    { name: 'JetBrains Mono', family: '"JetBrains Mono", monospace', weights: [400, 500, 600, 700] },
    // System
    { name: 'System UI', family: 'system-ui, sans-serif', weights: [400, 500, 600, 700] },
  ];
  
  // Try to restore timeline from localStorage (only if not loading from server)
  (function restoreTimelineFromStorage() {
    // Skip localStorage if we have a demo ID in URL (will load from server instead)
    const urlParams = new URLSearchParams(window.location.search);
    if (urlParams.get('project')) {
      console.log('Project ID in URL - skipping localStorage, will load from server');
      return;
    }

    const stored = loadTimelineFromStorage();
    if (stored) {
      console.log('Restoring timeline from localStorage');
      projectLoadedFromServer = true; // Also prevent auto-extension when loading from localStorage
      
      // Restore tracks if available (new format)
      if (stored.tracks) {
        timelineTracks = stored.tracks;
        if (stored.trackIdCounter !== undefined) trackIdCounter = stored.trackIdCounter;
      } else {
        // Migrate from old format
        timelineTracks = [];
        trackIdCounter = 0;
        if (stored.sceneSegments?.length > 0) {
          timelineTracks.push({ id: generateTrackId(), type: 'scene', name: 'Scene 1', order: 0, segments: stored.sceneSegments });
        } else {
          timelineTracks.push({ id: generateTrackId(), type: 'scene', name: 'Scene 1', order: 0, segments: [] });
        }
        if (stored.shaderSegments?.length > 0) {
          timelineTracks.push({ id: generateTrackId(), type: 'shader', name: 'Shader 1', order: 1, segments: stored.shaderSegments });
        } else {
          timelineTracks.push({ id: generateTrackId(), type: 'shader', name: 'Shader 1', order: 1, segments: [] });
        }
        if (stored.imageSegments?.length > 0) {
          timelineTracks.push({ id: generateTrackId(), type: 'media', name: 'Media 1', order: 2, segments: stored.imageSegments });
        } else {
          timelineTracks.push({ id: generateTrackId(), type: 'media', name: 'Media 1', order: 2, segments: [] });
        }
        if (stored.postfxSegments?.length > 0) {
          timelineTracks.push({ id: generateTrackId(), type: 'postfx', name: 'Post FX 1', order: 3, segments: stored.postfxSegments });
        } else {
          timelineTracks.push({ id: generateTrackId(), type: 'postfx', name: 'Post FX 1', order: 3, segments: [] });
        }
      }
      migrateSceneClipOverrideOwnership();
      migrateIndexedSegmentState(
        [keyframeData, exposedParams, exposedParamsMeta, curveTypes, lfoData],
        timelineTracks,
      );
      
      if (stored.imageIdCounter) imageIdCounter = stored.imageIdCounter;
      if (stored.postfxIdCounter) postfxIdCounter = stored.postfxIdCounter;
      if (stored.shaderIdCounter) shaderIdCounter = stored.shaderIdCounter;
      if (stored.textIdCounter) textIdCounter = stored.textIdCounter;
      
      // Preload any media (images/videos) referenced in the timeline
      for (const seg of getImageSegments()) {
        if (seg.imageFile) {
          // Ensure isVideo flag is set correctly
          seg.isVideo = isVideoFile(seg.imageFile);
          loadMedia(seg.imageFile).catch(err => console.warn('Failed to preload media:', seg.imageFile));
        }
      }
      
      // Preload any shader files referenced in the timeline from post-fx/ folder
      const shaderFilesToLoad = new Set();
      for (const seg of getPostfxSegments()) {
        if (seg.shaderFile) {
          shaderFilesToLoad.add(seg.shaderFile);
        }
      }
      
      for (const filename of shaderFilesToLoad) {
        fetch(`post-fx/${filename}`)
          .then(response => {
            if (!response.ok) throw new Error(`HTTP ${response.status}`);
            return response.text();
          })
          .then(shaderSource => {
            const { effectName, params } = parseShaderParams(shaderSource);
            loadedPostFxShaders[filename] = {
              source: shaderSource,
              threeMaterial: null,
              effectName: effectName || filename.replace(/\.(glsl|frag|fs)$/i, ''),
              params: params,
            };
            console.log(`Auto-loaded shader: ${filename} (${params.length} params)`);
            
            // Initialize any missing uniforms with defaults for segments using this shader
            for (const seg of getPostfxSegments()) {
              if (seg.shaderFile === filename) {
                // Update segment name to match effect name
                if (effectName && seg.name === filename.replace(/\.(glsl|frag|fs)$/i, '')) {
                  seg.name = effectName;
                }
                // Initialize uniforms if missing
                if (!seg.uniforms) {
                  seg.uniforms = { opacity: 1.0, intensity: 1.0 };
                }
                for (const param of params) {
                  if (seg.uniforms[param.uniform] === undefined) {
                    seg.uniforms[param.uniform] = param.default;
                  }
                }
              }
            }
            
            // Re-render timeline to show updated effect names
            if (typeof renderTracks === 'function') {
              renderTracks();
            }
          })
          .catch(err => console.warn(`Failed to auto-load shader ${filename}:`, err.message));
      }
    }
  })();

  // =========================
  // UNDO/REDO SYSTEM
  // =========================
  const MAX_UNDO_STATES = 50;
  let undoStack = [];
  let redoStack = [];
  let parameterEditSession = null;

  // Capture state of all 3D objects in loaded scenes
  function get3DObjectState() {
    const state = {};
    for (const sceneIdxStr in loadedScenes) {
      // Skip null scenes (not loaded) - don't trigger lazy loading during undo state capture
      if (loadedScenes[sceneIdxStr] === null) continue;
      
      // Convert string key to number for consistent comparison in getSceneByIdx
      const sceneIdx = parseInt(sceneIdxStr, 10);
      const sceneObj = getSceneByIdx(sceneIdx);
      if (!sceneObj || !sceneObj.threeScene) continue;
      
      state[sceneIdx] = {
        // Keep the canonical serializable scene state alongside the legacy
        // runtime snapshots below. The serializer covers camera animation,
        // advanced light/material properties, groups, and every procedural
        // scene system, so undo does not silently omit newer controls.
        sceneData: serializeRuntimeSceneState(THREE, sceneObj),
        camera: {
          fov: sceneObj.camera?.fov,
          position: sceneObj.camera?.position ? { x: sceneObj.camera.position.x, y: sceneObj.camera.position.y, z: sceneObj.camera.position.z } : null,
          rotation: sceneObj.camera?.rotation ? { x: sceneObj.camera.rotation.x, y: sceneObj.camera.rotation.y, z: sceneObj.camera.rotation.z } : null
        },
        lights: [],
        objects: [],
        scene: {
          background: sceneObj.threeScene.background?.getHexString ? sceneObj.threeScene.background.getHexString() : null,
          fog: sceneObj.threeScene.fog ? {
            color: sceneObj.threeScene.fog.color?.getHexString(),
            near: sceneObj.threeScene.fog.near,
            far: sceneObj.threeScene.fog.far
          } : null
        },
        grass: null,
        sphereCloner: null,
        particleSystem: null,
        displacedSphere: null,
        // Deep copy of scene data arrays for undo/redo of add/delete operations
        dataObjects: sceneObj.data?.objects ? JSON.parse(JSON.stringify(sceneObj.data.objects)) : [],
        dataLights: sceneObj.data?.lights ? JSON.parse(JSON.stringify(sceneObj.data.lights)) : []
      };
      
      // Capture grass uniforms
      if (sceneObj.grassMesh?.material?.uniforms) {
        const u = sceneObj.grassMesh.material.uniforms;
        state[sceneIdx].grass = {
          windSpeed: u.uWindSpeed?.value,
          windStrength: u.uWindStrength?.value,
          baseColor: u.uBaseColor?.value?.getHexString(),
          tipColor: u.uTipColor?.value?.getHexString()
        };
      }
      
      // Capture sphere cloner uniforms AND rebuild parameters
      if (sceneObj.sphereClonerMesh) {
        const u = sceneObj.sphereClonerMesh.material?.uniforms || {};
        const clonerData = sceneObj.data?.sphereCloner || {};
        state[sceneIdx].sphereCloner = {
          // Rebuild-required parameters (from data object)
          count: clonerData.count,
          cloneRadius: clonerData.cloneRadius,
          sizeVariation: clonerData.sizeVariation,
          cloneType: clonerData.cloneType,
          cloneDetail: clonerData.cloneDetail,
          distributionType: clonerData.distributionType,
          customCloneModel: clonerData.customCloneModel,
          customDistModel: clonerData.customDistModel,
          torusInnerRadius: clonerData.torusInnerRadius,
          cylinderHeight: clonerData.cylinderHeight,
          helixTurns: clonerData.helixTurns,
          planeWidth: clonerData.planeWidth,
          planeDepth: clonerData.planeDepth,
          lineLength: clonerData.lineLength,
          lineAxis: clonerData.lineAxis,
          spiralTurns: clonerData.spiralTurns,
          // Whole-system placement, independent of the camera.
          offsetX: sceneObj.sphereClonerMesh.position.x,
          offsetY: sceneObj.sphereClonerMesh.position.y,
          offsetZ: sceneObj.sphereClonerMesh.position.z,
          rotationX: sceneObj.sphereClonerMesh.rotation.x * 180 / Math.PI,
          rotationY: sceneObj.sphereClonerMesh.rotation.y * 180 / Math.PI,
          rotationZ: sceneObj.sphereClonerMesh.rotation.z * 180 / Math.PI,
          // Uniform-based parameters
          baseRadius: u.uBaseRadius?.value,
          offsetAmount: u.uOffsetAmount?.value,
          offsetSpeed: u.uOffsetSpeed?.value,
          rotationSpeed: u.uRotationSpeed?.value,
          orbitSpeed: u.uOrbitSpeed?.value,
          pulseAmount: u.uPulseAmount?.value,
          pulseSpeed: u.uPulseSpeed?.value,
          breathAmount: u.uBreathAmount?.value,
          breathSpeed: u.uBreathSpeed?.value,
          colorMixSpeed: u.uColorMixSpeed?.value,
          emissivePulseSpeed: u.uEmissivePulseSpeed?.value,
          baseColor: u.uBaseColor?.value?.getHexString(),
          accentColor: u.uAccentColor?.value?.getHexString(),
          emissiveColor: u.uEmissiveColor?.value?.getHexString(),
          metalness: u.uMetalness?.value,
          roughness: u.uRoughness?.value,
          envMapIntensity: u.uEnvMapIntensity?.value,
          fresnelPower: u.uFresnelPower?.value,
          emissiveIntensity: u.uEmissiveIntensity?.value,
          lightColor: u.uLightColor?.value?.getHexString(),
          lightIntensity: u.uLightIntensity?.value,
          lightDir: u.uLightDir?.value ? { x: u.uLightDir.value.x, y: u.uLightDir.value.y, z: u.uLightDir.value.z } : null,
          ambientColor: u.uAmbientColor?.value?.getHexString(),
          ambientIntensity: u.uAmbientIntensity?.value,
          // Effectors - Plain
          plainEnabled: u.uPlainEnabled?.value,
          plainPosition: u.uPlainPosition?.value ? { x: u.uPlainPosition.value.x, y: u.uPlainPosition.value.y, z: u.uPlainPosition.value.z } : null,
          plainScale: u.uPlainScale?.value,
          plainRotation: u.uPlainRotation?.value ? { x: u.uPlainRotation.value.x, y: u.uPlainRotation.value.y, z: u.uPlainRotation.value.z } : null,
          // Effectors - Noise
          noiseEnabled: u.uNoiseEnabled?.value,
          noiseStrength: u.uNoiseStrength?.value,
          noiseScale: u.uNoiseScale?.value,
          noiseSpeed: u.uNoiseSpeed?.value,
          noiseOctaves: u.uNoiseOctaves?.value,
          noiseScaleEffect: u.uNoiseScaleEffect?.value,
          noiseRotationEffect: u.uNoiseRotEffect?.value,
          // Effectors - Delay
          delayEnabled: u.uDelayEnabled?.value,
          delayAmount: u.uDelayAmount?.value,
          delayMode: u.uDelayMode?.value,
          delayOffset: u.uDelayOffset?.value,
          // Effectors - Field
          fieldEnabled: u.uFieldEnabled?.value,
          fieldPosition: u.uFieldPosition?.value ? { x: u.uFieldPosition.value.x, y: u.uFieldPosition.value.y, z: u.uFieldPosition.value.z } : null,
          fieldRadius: u.uFieldRadius?.value,
          fieldFalloff: u.uFieldFalloff?.value,
          fieldStrength: u.uFieldStrength?.value,
          fieldInvert: u.uFieldInvert?.value,
          fieldScaleEffect: u.uFieldScaleEffect?.value,
          fieldPushEffect: u.uFieldPushEffect?.value,
          // Effectors - Formula
          formulaEnabled: u.uFormulaEnabled?.value,
          formulaType: u.uFormulaType?.value,
          formulaAxis: u.uFormulaAxis?.value,
          formulaFrequency: u.uFormulaFrequency?.value,
          formulaAmplitude: u.uFormulaAmplitude?.value,
          formulaSpeed: u.uFormulaSpeed?.value,
          formulaScaleEffect: u.uFormulaScaleEffect?.value,
          formulaPosEffect: u.uFormulaPosEffect?.value,
          // Effectors - Random
          randomEnabled: u.uRandomEnabled?.value,
          randomSeed: u.uRandomSeed?.value,
          randomPosStrength: u.uRandomPosStrength?.value,
          randomScaleStrength: u.uRandomScaleStrength?.value,
          randomScaleUniform: u.uRandomScaleUniform?.value,
          randomRotStrength: u.uRandomRotStrength?.value
        };
      }
      
      // Capture particle system uniforms AND rebuild parameters
      if (sceneObj.particleSystemMesh?.material?.uniforms) {
        const u = sceneObj.particleSystemMesh.material.uniforms;
        const particleData = sceneObj.data?.particleSystem || {};
        state[sceneIdx].particleSystem = {
          // Rebuild-required parameters (from data object)
          count: particleData.count,
          emitterType: particleData.emitter?.type,
          emitterRadius: particleData.emitter?.radius,
          offsetX: sceneObj.particleSystemMesh.position.x,
          offsetY: sceneObj.particleSystemMesh.position.y,
          offsetZ: sceneObj.particleSystemMesh.position.z,
          // Uniform-based parameters
          particleSize: u.uParticleSize?.value,
          lifetime: u.uLifetime?.value,
          emissionRate: u.uEmissionRate?.value,
          gravity: u.uGravity?.value ? { x: u.uGravity.value.x, y: u.uGravity.value.y, z: u.uGravity.value.z } : null,
          drag: u.uDrag?.value,
          bounce: u.uBounce?.value,
          floorY: u.uFloorY?.value,
          floorEnabled: u.uFloorEnabled?.value,
          turbEnabled: u.uTurbEnabled?.value,
          turbStrength: u.uTurbStrength?.value,
          turbScale: u.uTurbScale?.value,
          turbSpeed: u.uTurbSpeed?.value,
          turbOctaves: u.uTurbOctaves?.value,
          vortexEnabled: u.uVortexEnabled?.value,
          vortexStrength: u.uVortexStrength?.value,
          vortexInward: u.uVortexInward?.value,
          attractorEnabled: u.uAttractorEnabled?.value,
          attractorStrength: u.uAttractorStrength?.value,
          attractorRadius: u.uAttractorRadius?.value,
          attractorFalloff: u.uAttractorFalloff?.value,
          curlEnabled: u.uCurlEnabled?.value,
          curlStrength: u.uCurlStrength?.value,
          curlScale: u.uCurlScale?.value,
          curlSpeed: u.uCurlSpeed?.value,
          curlEvolution: u.uCurlEvolution?.value,
          cohesionEnabled: u.uCohesionEnabled?.value,
          cohesionStrength: u.uCohesionStrength?.value,
          cohesionRadius: u.uCohesionRadius?.value,
          surfaceTension: u.uSurfaceTension?.value,
          viscosity: u.uViscosity?.value,
          windEnabled: u.uWindEnabled?.value,
          windStrength: u.uWindStrength?.value,
          windGust: u.uWindGust?.value,
          windGustFreq: u.uWindGustFreq?.value,
          swarmEnabled: u.uSwarmEnabled?.value,
          swarmSeparation: u.uSwarmSeparation?.value,
          swarmAlignment: u.uSwarmAlignment?.value,
          swarmCohesion: u.uSwarmCohesion?.value,
          oscillationEnabled: u.uOscillationEnabled?.value,
          oscillationAmplitude: u.uOscillationAmplitude?.value,
          oscillationFrequency: u.uOscillationFrequency?.value,
          oscillationPhaseOffset: u.uOscillationPhaseOffset?.value,
          oscillationRadial: u.uOscillationRadial?.value,
          buoyancyEnabled: u.uBuoyancyEnabled?.value,
          buoyancyTemperature: u.uBuoyancyTemperature?.value,
          buoyancyDensity: u.uBuoyancyDensity?.value,
          buoyancyThermalNoise: u.uBuoyancyThermalNoise?.value,
          buoyancyCoolingRate: u.uBuoyancyCoolingRate?.value,
          membraneEnabled: u.uMembraneEnabled?.value,
          membraneStiffness: u.uMembraneStiffness?.value,
          membraneMaxStretch: u.uMembraneMaxStretch?.value,
          membraneConnectionRadius: u.uMembraneConnectionRadius?.value,
          membraneDamping: u.uMembraneDamping?.value,
          sizeStart: u.uSizeStart?.value,
          sizeMid: u.uSizeMid?.value,
          sizeEnd: u.uSizeEnd?.value,
          opacityStart: u.uOpacityStart?.value,
          opacityMid: u.uOpacityMid?.value,
          opacityEnd: u.uOpacityEnd?.value,
          rotationSpeed: u.uRotationSpeed?.value,
          colorStart: u.uColorStart?.value?.getHexString(),
          colorMid: u.uColorMid?.value?.getHexString(),
          colorEnd: u.uColorEnd?.value?.getHexString(),
          glowIntensity: u.uGlowIntensity?.value,
          shape: u.uShape?.value,
          flareAspect: u.uFlareAspect?.value,
          flareSharpness: u.uFlareSharpness?.value,
          alignToMotion: u.uAlignToMotion?.value
        };
      }
      
      // Capture displaced sphere uniforms
      if (sceneObj.displacedSphereMesh?.material?.uniforms) {
        const u = sceneObj.displacedSphereMesh.material.uniforms;
        const sphereData = sceneObj.data?.displacedSphere || {};
        state[sceneIdx].displacedSphere = {
          // Geometry (rebuild-required)
          detail: sphereData.detail,
          radius: sphereData.radius,
          // Effect type (from uniform)
          effectType: u.uEffectType?.value,
          // Displacement
          dispStrength: u.uDispStrength?.value,
          dispScale: u.uDispScale?.value,
          dispSpeed: u.uDispSpeed?.value,
          dispOctaves: u.uDispOctaves?.value,
          dispPersistence: u.uDispPersistence?.value,
          // Spikes
          spikeCount: u.uSpikeCount?.value,
          spikeSharpness: u.uSpikeSharpness?.value,
          // Cracks
          crackWidth: u.uCrackWidth?.value,
          crackDepth: u.uCrackDepth?.value,
          // Pulse
          pulseSpeed: u.uPulseSpeed?.value,
          pulseStrength: u.uPulseStrength?.value,
          // Fractal
          fractalPower: u.uFractalPower?.value,
          fractalIterations: u.uFractalIterations?.value,
          // Material colors
          baseColor: u.uBaseColor?.value?.getHexString(),
          accentColor: u.uAccentColor?.value?.getHexString(),
          emissiveColor: u.uEmissiveColor?.value?.getHexString(),
          emissiveIntensity: u.uEmissiveIntensity?.value,
          metalness: u.uMetalness?.value,
          roughness: u.uRoughness?.value,
          envMapIntensity: u.uEnvMapIntensity?.value,
          fresnelPower: u.uFresnelPower?.value,
          rimColor: u.uRimColor?.value?.getHexString(),
          // Audio reactivity
          audioDisplace: u.uAudioDisplace?.value,
          audioEmissive: u.uAudioEmissive?.value
        };
      }
      
      // Capture lights - include lightIndex for reliable restore
      sceneObj.threeScene.children.filter(c => c.isLight).forEach(light => {
        state[sceneIdx].lights.push({
          lightIndex: light.userData?.lightIndex,
          color: light.color?.getHexString(),
          intensity: light.intensity,
          position: light.position ? { x: light.position.x, y: light.position.y, z: light.position.z } : null,
          skyColor: light.skyColor?.getHexString(),
          groundColor: light.groundColor?.getHexString()
        });
      });
      
      // Capture objects (meshes and GLTF models) - include objIndex for reliable restore
      sceneObj.threeScene.children.filter(c => (c.isMesh || c.userData?.isGLTF) && c.userData?.objIndex !== undefined).forEach(obj => {
        // For GLTF models, get material from first child mesh
        let mat = obj.material;
        if (!mat && obj.userData.isGLTF) {
          obj.traverse((child) => {
            if (!mat && child.isMesh && child.material) {
              mat = child.material;
            }
          });
        }
        
        const objState = {
          objIndex: obj.userData.objIndex,
          isGLTF: obj.userData.isGLTF || false,
          position: { x: obj.position.x, y: obj.position.y, z: obj.position.z },
          rotation: { x: obj.rotation.x, y: obj.rotation.y, z: obj.rotation.z },
          scale: { x: obj.scale.x, y: obj.scale.y, z: obj.scale.z },
          material: mat ? {
            color: mat.color?.getHexString(),
            opacity: mat.opacity,
            metalness: mat.metalness,
            roughness: mat.roughness,
            envMapIntensity: mat.envMapIntensity
          } : null
        };
        
        // Capture displacement uniforms if present
        const dispMat = obj.userData?.displacementMaterial || 
                        (obj.material?.uniforms?.uDisplacementStrength ? obj.material : null);
        if (dispMat?.uniforms) {
          objState.displacement = {
            strength: dispMat.uniforms.uDisplacementStrength?.value,
            frequency: dispMat.uniforms.uDisplacementFrequency?.value,
            speed: dispMat.uniforms.uDisplacementSpeed?.value,
            noiseScale: dispMat.uniforms.uNoiseScale?.value
          };
        }
        
        state[sceneIdx].objects.push(objState);
      });
    }
    return state;
  }

  function getSegmentSceneState() {
    const state = {};
    for (const [key, sceneObj] of segmentSceneInstances) {
      const sceneData = serializeRuntimeSceneState(THREE, sceneObj);
      if (sceneData) state[key] = sceneData;
    }
    return state;
  }

  function findSceneSegmentRecordByKey(key) {
    for (const track of getTracksByType('scene')) {
      for (let index = 0; index < track.segments.length; index++) {
        if (getSegmentKey(track.id, index) === key) {
          return { track, index, segment: track.segments[index] };
        }
      }
    }
    return null;
  }

  function setSegmentSceneState(state) {
    if (!state) return;
    for (const [key, restoredSceneData] of Object.entries(state)) {
      const sceneObj = segmentSceneInstances.get(key);
      if (!sceneObj || !restoredSceneData) continue;
      const previousSceneData = serializeRuntimeSceneState(THREE, sceneObj);
      restoreSerializedSceneState(-1, sceneObj, restoredSceneData, previousSceneData);
      const record = findSceneSegmentRecordByKey(key);
      if (record) {
        record.segment.sceneOverrides = cloneSerializable(restoredSceneData);
      }
    }
  }

  const SCENE_SYSTEM_RESULT_KEYS = {
    grassField: 'grassMesh',
    voxelTunnel: 'voxelMesh',
    sphereCloner: 'sphereClonerMesh',
    heartField: 'heartFieldMesh',
    landscapeField: 'landscapeMesh',
    clothSimulation: 'clothSimulationMesh',
    particleSystem: 'particleSystemMesh',
    displacedSphere: 'displacedSphereMesh',
    fractalTree: 'fractalTreeMesh',
    sierpinskiPyramid: 'sierpinskiMesh',
    mengerSponge: 'mengerSpongeMesh',
    coralGrowth: 'coralGrowthMesh',
    crystalLattice: 'crystalLatticeMesh',
    starCrawl: 'starCrawlMesh',
    fireworksDisplay: 'fireworksDisplayMesh',
  };

  const SCENE_OBJECT_TEXTURE_PROPS = [
    'map', 'normalMap', 'roughnessMap', 'metalnessMap', 'aoMap',
    'emissiveMap', 'envMap', 'bumpMap', 'displacementMap', 'lightMap',
    'alphaMap',
  ];

  function cloneSerializable(value) {
    return value == null ? value : JSON.parse(JSON.stringify(value));
  }

  function syncSerializableObject(target, source) {
    if (Array.isArray(target) && Array.isArray(source)) {
      target.splice(0, target.length, ...source.map(cloneSerializable));
      return target;
    }
    if (!target || typeof target !== 'object' || Array.isArray(target)) {
      return cloneSerializable(source);
    }

    Object.keys(target).forEach(key => {
      if (!Object.prototype.hasOwnProperty.call(source, key)) delete target[key];
    });
    Object.entries(source).forEach(([key, value]) => {
      if (
        value && typeof value === 'object' &&
        target[key] && typeof target[key] === 'object' &&
        Array.isArray(value) === Array.isArray(target[key])
      ) {
        syncSerializableObject(target[key], value);
      } else {
        target[key] = cloneSerializable(value);
      }
    });
    return target;
  }

  function sceneSystemConfigsDiffer(currentData, restoredData) {
    return Object.keys(SCENE_SYSTEM_RESULT_KEYS).some(key =>
      JSON.stringify(currentData?.[key] ?? null) !==
      JSON.stringify(restoredData?.[key] ?? null)
    );
  }

  function applySerializedLightState(sceneObj, lights = []) {
    lights.forEach((lightData, lightIdx) => {
      const light = findThreeLightByIndex(sceneObj, lightIdx);
      if (!light) return;

      if (lightData.color && light.color) light.color.set(lightData.color);
      if (lightData.skyColor && light.skyColor) light.skyColor.set(lightData.skyColor);
      if (lightData.groundColor && light.groundColor) light.groundColor.set(lightData.groundColor);
      if (lightData.intensity !== undefined) light.intensity = lightData.intensity;
      if (lightData.position && light.position) {
        light.position.set(
          lightData.position.x ?? light.position.x,
          lightData.position.y ?? light.position.y,
          lightData.position.z ?? light.position.z,
        );
      }
      if (lightData.distance !== undefined && light.distance !== undefined) light.distance = lightData.distance;
      if (lightData.decay !== undefined && light.decay !== undefined) light.decay = lightData.decay;
      if (lightData.angle !== undefined && light.angle !== undefined) light.angle = lightData.angle;
      if (lightData.penumbra !== undefined && light.penumbra !== undefined) light.penumbra = lightData.penumbra;
      if (lightData.width !== undefined && light.width !== undefined) light.width = lightData.width;
      if (lightData.height !== undefined && light.height !== undefined) light.height = lightData.height;
      if (lightData.castShadow !== undefined) light.castShadow = lightData.castShadow;
      if (lightData.visible !== undefined) light.visible = lightData.visible;

      if (lightData.target && light.target?.position) {
        light.target.position.set(
          lightData.target.x ?? light.target.position.x,
          lightData.target.y ?? light.target.position.y,
          lightData.target.z ?? light.target.position.z,
        );
        light.target.updateMatrixWorld();
      }
      if (light.shadow) {
        if (lightData.shadowBias !== undefined) light.shadow.bias = lightData.shadowBias;
        if (lightData.shadowRadius !== undefined) light.shadow.radius = lightData.shadowRadius;
        if (lightData.shadowCameraSize !== undefined && light.isDirectionalLight) {
          const size = lightData.shadowCameraSize;
          light.shadow.camera.left = -size;
          light.shadow.camera.right = size;
          light.shadow.camera.top = size;
          light.shadow.camera.bottom = -size;
          light.shadow.camera.updateProjectionMatrix();
        }
      }
    });
  }

  function applySerializedObjectMaterial(material, objectData) {
    if (!material || !objectData) return;
    const materialData = typeof objectData.material === 'object' && objectData.material
      ? objectData.material
      : {};
    const color = materialData.color || objectData.color;
    const emissive = materialData.emissive || objectData.emissive;

    if (color && material.color) material.color.set(color);
    if (emissive && material.emissive) material.emissive.set(emissive);
    if (materialData.emissiveIntensity !== undefined && material.emissiveIntensity !== undefined) {
      material.emissiveIntensity = materialData.emissiveIntensity;
    }
    [
      'opacity', 'metalness', 'roughness', 'envMapIntensity', 'bumpScale',
      'displacementScale', 'displacementBias', 'aoMapIntensity',
      'lightMapIntensity',
    ].forEach(property => {
      if (materialData[property] !== undefined && material[property] !== undefined) {
        material[property] = materialData[property];
      }
    });
    if (materialData.normalScale !== undefined && material.normalScale) {
      material.normalScale.setScalar(materialData.normalScale);
    }
    if (materialData.wireframe !== undefined && material.wireframe !== undefined) {
      material.wireframe = materialData.wireframe;
    }
    if (materialData.flatShading !== undefined && material.flatShading !== undefined) {
      material.flatShading = materialData.flatShading;
    }
    if (materialData.side !== undefined) {
      material.side = {
        front: THREE.FrontSide,
        back: THREE.BackSide,
        double: THREE.DoubleSide,
      }[materialData.side] ?? material.side;
    }
    if (materialData.transparent !== undefined) {
      material.transparent = materialData.transparent;
    } else if (materialData.opacity !== undefined) {
      material.transparent = materialData.opacity < 1;
    }
    material.needsUpdate = true;
  }

  function applySerializedObjectState(sceneIdx, sceneObj, objects = [], previousData = {}) {
    objects.forEach((objectData, objIdx) => {
      const rootObject = findThreeMeshByIndex(sceneObj, objIdx);
      if (!rootObject) return;

      if (objectData.position) {
        rootObject.position.set(
          objectData.position.x ?? 0,
          objectData.position.y ?? 0,
          objectData.position.z ?? 0,
        );
      }
      if (objectData.rotation) {
        rootObject.rotation.set(
          THREE.MathUtils.degToRad(objectData.rotation.x ?? 0),
          THREE.MathUtils.degToRad(objectData.rotation.y ?? 0),
          THREE.MathUtils.degToRad(objectData.rotation.z ?? 0),
        );
      }
      if (typeof objectData.scale === 'number') {
        rootObject.scale.setScalar(objectData.scale);
      } else if (objectData.scale) {
        rootObject.scale.set(
          objectData.scale.x ?? 1,
          objectData.scale.y ?? 1,
          objectData.scale.z ?? 1,
        );
      }
      rootObject.visible = objectData.visible !== false;
      rootObject.castShadow = objectData.castShadow !== false;
      rootObject.receiveShadow = objectData.receiveShadow !== false;

      const applyMaterial = material => {
        const materials = Array.isArray(material) ? material : [material];
        materials.forEach(item => applySerializedObjectMaterial(item, objectData));
      };
      if (rootObject.isMesh) applyMaterial(rootObject.material);
      rootObject.traverse?.(child => {
        if (child.isMesh && child !== rootObject) applyMaterial(child.material);
      });

      const previousObject = previousData.objects?.[objIdx] || {};
      const previousMaterial = typeof previousObject.material === 'object' && previousObject.material
        ? previousObject.material
        : {};
      const restoredMaterial = typeof objectData.material === 'object' && objectData.material
        ? objectData.material
        : {};
      SCENE_OBJECT_TEXTURE_PROPS.forEach(property => {
        const previousPath = previousMaterial[property] || previousObject[property] || null;
        const restoredPath = restoredMaterial[property] || objectData[property] || null;
        if (previousPath !== restoredPath) {
          void applyTextureToObject(sceneIdx, objIdx, property, restoredPath);
        }
      });
    });
  }

  function applySerializedGroupState(sceneObj, groups = []) {
    groups.forEach(groupData => {
      const group = sceneObj.threeGroups?.[groupData.id];
      if (!group) return;
      if (groupData.position) {
        group.position.set(
          groupData.position.x ?? 0,
          groupData.position.y ?? 0,
          groupData.position.z ?? 0,
        );
      }
      if (groupData.rotation) {
        group.rotation.set(
          THREE.MathUtils.degToRad(groupData.rotation.x ?? 0),
          THREE.MathUtils.degToRad(groupData.rotation.y ?? 0),
          THREE.MathUtils.degToRad(groupData.rotation.z ?? 0),
        );
      }
      if (groupData.scale) {
        group.scale.set(
          groupData.scale.x ?? 1,
          groupData.scale.y ?? 1,
          groupData.scale.z ?? 1,
        );
      }
      group.visible = groupData.visible !== false;
    });
  }

  function restoreSerializedSceneState(sceneIdx, sceneObj, restoredSceneData, previousSceneData) {
    if (!restoredSceneData) return;
    const restoredSnapshot = cloneSerializable(restoredSceneData);
    const systemConfigChanged = sceneSystemConfigsDiffer(previousSceneData, restoredSnapshot);

    // The data object is the source for the parameter panel and persistence.
    // Synchronize it only after the legacy add/delete restore has compared
    // counts. Keeping object identities intact prevents procedural runtimes
    // from retaining stale config references after an unrelated undo.
    sceneObj.data = syncSerializableObject(sceneObj.data || {}, restoredSnapshot);
    const restoredData = sceneObj.data;

    if (restoredData.background) {
      if (sceneObj.threeScene.background?.set) {
        sceneObj.threeScene.background.set(restoredData.background);
      } else {
        sceneObj.threeScene.background = new THREE.Color(restoredData.background);
      }
    } else {
      sceneObj.threeScene.background = null;
    }
    if (restoredData.fog) {
      if (!sceneObj.threeScene.fog) {
        sceneObj.threeScene.fog = new THREE.Fog(
          restoredData.fog.color || '#000000',
          restoredData.fog.near ?? 1,
          restoredData.fog.far ?? 1000,
        );
      } else {
        if (restoredData.fog.color) sceneObj.threeScene.fog.color.set(restoredData.fog.color);
        if (restoredData.fog.near !== undefined) sceneObj.threeScene.fog.near = restoredData.fog.near;
        if (restoredData.fog.far !== undefined) sceneObj.threeScene.fog.far = restoredData.fog.far;
      }
    } else {
      sceneObj.threeScene.fog = null;
    }

    applySerializedLightState(sceneObj, restoredData.lights);
    applySerializedObjectState(sceneIdx, sceneObj, restoredData.objects, previousSceneData);
    applySerializedGroupState(sceneObj, restoredData.groups);

    if (systemConfigChanged) {
      disposeSceneSystems(sceneObj);
      const restoredSystems = sceneSystemRegistry.createEnabled(
        restoredData,
        { scene: sceneObj.threeScene, data: restoredData },
        (key, error) => console.error(`[Undo] Failed to restore ${key}:`, error),
      );
      Object.entries(SCENE_SYSTEM_RESULT_KEYS).forEach(([key, resultKey]) => {
        sceneObj[resultKey] = restoredSystems[resultKey] || null;
      });
      if (sceneObj.data?.crystalLattice?.useSceneLights) {
        syncCrystalLightsFromScene(sceneObj);
      }
    }
  }

  // Restore 3D object state
  function set3DObjectState(state) {
    if (!state) return;
    
    for (const sceneIdxStr in state) {
      // Convert string key to number for consistent comparison in getSceneByIdx
      const sceneIdx = parseInt(sceneIdxStr, 10);
      const sceneObj = getSceneByIdx(sceneIdx);
      if (!sceneObj || !sceneObj.threeScene) continue;
      
      const savedState = state[sceneIdx];
      const previousSceneData = savedState.sceneData
        ? serializeRuntimeSceneState(THREE, sceneObj)
        : null;
      
      // Restore camera (both THREE.js object AND data for UI)
      if (savedState.camera && sceneObj.camera) {
        if (savedState.camera.fov !== undefined && sceneObj.camera.isPerspectiveCamera) {
          sceneObj.camera.fov = savedState.camera.fov;
          sceneObj.camera.updateProjectionMatrix();
          // Sync to data for UI
          if (sceneObj.data?.camera) sceneObj.data.camera.fov = savedState.camera.fov;
        }
        if (savedState.camera.position && sceneObj.camera.position) {
          sceneObj.camera.position.set(savedState.camera.position.x, savedState.camera.position.y, savedState.camera.position.z);
          // Sync to data for UI
          if (sceneObj.data?.camera?.position) {
            sceneObj.data.camera.position.x = savedState.camera.position.x;
            sceneObj.data.camera.position.y = savedState.camera.position.y;
            sceneObj.data.camera.position.z = savedState.camera.position.z;
          }
        }
        if (savedState.camera.rotation && sceneObj.camera.rotation) {
          sceneObj.camera.rotation.set(savedState.camera.rotation.x, savedState.camera.rotation.y, savedState.camera.rotation.z);
          // Sync to data for UI (data stores degrees, Three.js uses radians)
          if (sceneObj.data?.camera) {
            sceneObj.data.camera.rotation = sceneObj.data.camera.rotation || {};
            sceneObj.data.camera.rotation.x = THREE.MathUtils.radToDeg(savedState.camera.rotation.x);
            sceneObj.data.camera.rotation.y = THREE.MathUtils.radToDeg(savedState.camera.rotation.y);
            sceneObj.data.camera.rotation.z = THREE.MathUtils.radToDeg(savedState.camera.rotation.z);
          }
        }
      }
      
      // Restore scene background and fog
      if (savedState.scene) {
        if (savedState.scene.background && sceneObj.threeScene.background) {
          sceneObj.threeScene.background.setHex(parseInt(savedState.scene.background, 16));
          // Sync to data for UI
          if (sceneObj.data) sceneObj.data.background = '#' + savedState.scene.background;
        }
        if (savedState.scene.fog && sceneObj.threeScene.fog) {
          if (savedState.scene.fog.color) {
            sceneObj.threeScene.fog.color.setHex(parseInt(savedState.scene.fog.color, 16));
            if (sceneObj.data?.fog) sceneObj.data.fog.color = '#' + savedState.scene.fog.color;
          }
          if (savedState.scene.fog.near !== undefined) {
            sceneObj.threeScene.fog.near = savedState.scene.fog.near;
            if (sceneObj.data?.fog) sceneObj.data.fog.near = savedState.scene.fog.near;
          }
          if (savedState.scene.fog.far !== undefined) {
            sceneObj.threeScene.fog.far = savedState.scene.fog.far;
            if (sceneObj.data?.fog) sceneObj.data.fog.far = savedState.scene.fog.far;
          }
        }
      }
      
      // Restore grass uniforms
      if (savedState.grass && sceneObj.grassMesh?.material?.uniforms) {
        const u = sceneObj.grassMesh.material.uniforms;
        if (savedState.grass.windSpeed !== undefined && u.uWindSpeed) u.uWindSpeed.value = savedState.grass.windSpeed;
        if (savedState.grass.windStrength !== undefined && u.uWindStrength) u.uWindStrength.value = savedState.grass.windStrength;
        if (savedState.grass.baseColor && u.uBaseColor) u.uBaseColor.value.setHex(parseInt(savedState.grass.baseColor, 16));
        if (savedState.grass.tipColor && u.uTipColor) u.uTipColor.value.setHex(parseInt(savedState.grass.tipColor, 16));
      }
      
      // Restore sphere cloner uniforms AND rebuild parameters
      if (savedState.sphereCloner && sceneObj.sphereClonerMesh) {
        const u = sceneObj.sphereClonerMesh.material?.uniforms || {};
        const s = savedState.sphereCloner;
        
        // Restore rebuild-required parameters to data object
        if (sceneObj.data?.sphereCloner) {
          const data = sceneObj.data.sphereCloner;
          if (s.count !== undefined) data.count = s.count;
          if (s.cloneRadius !== undefined) data.cloneRadius = s.cloneRadius;
          if (s.sizeVariation !== undefined) data.sizeVariation = s.sizeVariation;
          if (s.cloneType !== undefined) data.cloneType = s.cloneType;
          if (s.cloneDetail !== undefined) data.cloneDetail = s.cloneDetail;
          if (s.distributionType !== undefined) data.distributionType = s.distributionType;
          if (s.customCloneModel !== undefined) data.customCloneModel = s.customCloneModel;
          if (s.customDistModel !== undefined) data.customDistModel = s.customDistModel;
          if (s.torusInnerRadius !== undefined) data.torusInnerRadius = s.torusInnerRadius;
          if (s.cylinderHeight !== undefined) data.cylinderHeight = s.cylinderHeight;
          if (s.helixTurns !== undefined) data.helixTurns = s.helixTurns;
          if (s.planeWidth !== undefined) data.planeWidth = s.planeWidth;
          if (s.planeDepth !== undefined) data.planeDepth = s.planeDepth;
          if (s.lineLength !== undefined) data.lineLength = s.lineLength;
          if (s.lineAxis !== undefined) data.lineAxis = s.lineAxis;
          if (s.spiralTurns !== undefined) data.spiralTurns = s.spiralTurns;
          if (s.offsetX !== undefined) data.offsetX = s.offsetX;
          if (s.offsetY !== undefined) data.offsetY = s.offsetY;
          if (s.offsetZ !== undefined) data.offsetZ = s.offsetZ;
          if (s.rotationX !== undefined) data.rotationX = s.rotationX;
          if (s.rotationY !== undefined) data.rotationY = s.rotationY;
          if (s.rotationZ !== undefined) data.rotationZ = s.rotationZ;
        }

        applySphereClonerPlacement(
          sceneObj.data?.sphereCloner || s,
          sceneObj.sphereClonerMesh,
        );
        
        // Restore uniform-based parameters
        if (s.baseRadius !== undefined && u.uBaseRadius) u.uBaseRadius.value = s.baseRadius;
        if (s.offsetAmount !== undefined && u.uOffsetAmount) u.uOffsetAmount.value = s.offsetAmount;
        if (s.offsetSpeed !== undefined && u.uOffsetSpeed) u.uOffsetSpeed.value = s.offsetSpeed;
        if (s.rotationSpeed !== undefined && u.uRotationSpeed) u.uRotationSpeed.value = s.rotationSpeed;
        if (s.orbitSpeed !== undefined && u.uOrbitSpeed) u.uOrbitSpeed.value = s.orbitSpeed;
        if (s.pulseAmount !== undefined && u.uPulseAmount) u.uPulseAmount.value = s.pulseAmount;
        if (s.pulseSpeed !== undefined && u.uPulseSpeed) u.uPulseSpeed.value = s.pulseSpeed;
        if (s.breathAmount !== undefined && u.uBreathAmount) u.uBreathAmount.value = s.breathAmount;
        if (s.breathSpeed !== undefined && u.uBreathSpeed) u.uBreathSpeed.value = s.breathSpeed;
        if (s.colorMixSpeed !== undefined && u.uColorMixSpeed) u.uColorMixSpeed.value = s.colorMixSpeed;
        if (s.emissivePulseSpeed !== undefined && u.uEmissivePulseSpeed) u.uEmissivePulseSpeed.value = s.emissivePulseSpeed;
        if (s.baseColor && u.uBaseColor) u.uBaseColor.value.setHex(parseInt(s.baseColor, 16));
        if (s.accentColor && u.uAccentColor) u.uAccentColor.value.setHex(parseInt(s.accentColor, 16));
        if (s.emissiveColor && u.uEmissiveColor) u.uEmissiveColor.value.setHex(parseInt(s.emissiveColor, 16));
        if (s.metalness !== undefined && u.uMetalness) u.uMetalness.value = s.metalness;
        if (s.roughness !== undefined && u.uRoughness) u.uRoughness.value = s.roughness;
        if (s.envMapIntensity !== undefined && u.uEnvMapIntensity) u.uEnvMapIntensity.value = s.envMapIntensity;
        if (s.fresnelPower !== undefined && u.uFresnelPower) u.uFresnelPower.value = s.fresnelPower;
        if (s.emissiveIntensity !== undefined && u.uEmissiveIntensity) u.uEmissiveIntensity.value = s.emissiveIntensity;
        if (s.lightColor && u.uLightColor) u.uLightColor.value.setHex(parseInt(s.lightColor, 16));
        if (s.lightIntensity !== undefined && u.uLightIntensity) u.uLightIntensity.value = s.lightIntensity;
        if (s.lightDir && u.uLightDir) u.uLightDir.value.set(s.lightDir.x, s.lightDir.y, s.lightDir.z);
        if (s.ambientColor && u.uAmbientColor) u.uAmbientColor.value.setHex(parseInt(s.ambientColor, 16));
        if (s.ambientIntensity !== undefined && u.uAmbientIntensity) u.uAmbientIntensity.value = s.ambientIntensity;
        // Effectors - Plain
        if (s.plainEnabled !== undefined && u.uPlainEnabled) u.uPlainEnabled.value = s.plainEnabled;
        if (s.plainPosition && u.uPlainPosition) u.uPlainPosition.value.set(s.plainPosition.x, s.plainPosition.y, s.plainPosition.z);
        if (s.plainScale !== undefined && u.uPlainScale) u.uPlainScale.value = s.plainScale;
        if (s.plainRotation && u.uPlainRotation) u.uPlainRotation.value.set(s.plainRotation.x, s.plainRotation.y, s.plainRotation.z);
        // Effectors - Noise
        if (s.noiseEnabled !== undefined && u.uNoiseEnabled) u.uNoiseEnabled.value = s.noiseEnabled;
        if (s.noiseStrength !== undefined && u.uNoiseStrength) u.uNoiseStrength.value = s.noiseStrength;
        if (s.noiseScale !== undefined && u.uNoiseScale) u.uNoiseScale.value = s.noiseScale;
        if (s.noiseSpeed !== undefined && u.uNoiseSpeed) u.uNoiseSpeed.value = s.noiseSpeed;
        if (s.noiseOctaves !== undefined && u.uNoiseOctaves) u.uNoiseOctaves.value = s.noiseOctaves;
        if (s.noiseScaleEffect !== undefined && u.uNoiseScaleEffect) u.uNoiseScaleEffect.value = s.noiseScaleEffect;
        if (s.noiseRotationEffect !== undefined && u.uNoiseRotEffect) u.uNoiseRotEffect.value = s.noiseRotationEffect;
        // Effectors - Delay
        if (s.delayEnabled !== undefined && u.uDelayEnabled) u.uDelayEnabled.value = s.delayEnabled;
        if (s.delayAmount !== undefined && u.uDelayAmount) u.uDelayAmount.value = s.delayAmount;
        if (s.delayMode !== undefined && u.uDelayMode) u.uDelayMode.value = s.delayMode;
        if (s.delayOffset !== undefined && u.uDelayOffset) u.uDelayOffset.value = s.delayOffset;
        // Effectors - Field
        if (s.fieldEnabled !== undefined && u.uFieldEnabled) u.uFieldEnabled.value = s.fieldEnabled;
        if (s.fieldPosition && u.uFieldPosition) u.uFieldPosition.value.set(s.fieldPosition.x, s.fieldPosition.y, s.fieldPosition.z);
        if (s.fieldRadius !== undefined && u.uFieldRadius) u.uFieldRadius.value = s.fieldRadius;
        if (s.fieldFalloff !== undefined && u.uFieldFalloff) u.uFieldFalloff.value = s.fieldFalloff;
        if (s.fieldStrength !== undefined && u.uFieldStrength) u.uFieldStrength.value = s.fieldStrength;
        if (s.fieldInvert !== undefined && u.uFieldInvert) u.uFieldInvert.value = s.fieldInvert;
        if (s.fieldScaleEffect !== undefined && u.uFieldScaleEffect) u.uFieldScaleEffect.value = s.fieldScaleEffect;
        if (s.fieldPushEffect !== undefined && u.uFieldPushEffect) u.uFieldPushEffect.value = s.fieldPushEffect;
        // Effectors - Formula
        if (s.formulaEnabled !== undefined && u.uFormulaEnabled) u.uFormulaEnabled.value = s.formulaEnabled;
        if (s.formulaType !== undefined && u.uFormulaType) u.uFormulaType.value = s.formulaType;
        if (s.formulaAxis !== undefined && u.uFormulaAxis) u.uFormulaAxis.value = s.formulaAxis;
        if (s.formulaFrequency !== undefined && u.uFormulaFrequency) u.uFormulaFrequency.value = s.formulaFrequency;
        if (s.formulaAmplitude !== undefined && u.uFormulaAmplitude) u.uFormulaAmplitude.value = s.formulaAmplitude;
        if (s.formulaSpeed !== undefined && u.uFormulaSpeed) u.uFormulaSpeed.value = s.formulaSpeed;
        if (s.formulaScaleEffect !== undefined && u.uFormulaScaleEffect) u.uFormulaScaleEffect.value = s.formulaScaleEffect;
        if (s.formulaPosEffect !== undefined && u.uFormulaPosEffect) u.uFormulaPosEffect.value = s.formulaPosEffect;
        // Effectors - Random
        if (s.randomEnabled !== undefined && u.uRandomEnabled) u.uRandomEnabled.value = s.randomEnabled;
        if (s.randomSeed !== undefined && u.uRandomSeed) u.uRandomSeed.value = s.randomSeed;
        if (s.randomPosStrength !== undefined && u.uRandomPosStrength) u.uRandomPosStrength.value = s.randomPosStrength;
        if (s.randomScaleStrength !== undefined && u.uRandomScaleStrength) u.uRandomScaleStrength.value = s.randomScaleStrength;
        if (s.randomScaleUniform !== undefined && u.uRandomScaleUniform) u.uRandomScaleUniform.value = s.randomScaleUniform;
        if (s.randomRotStrength !== undefined && u.uRandomRotStrength) u.uRandomRotStrength.value = s.randomRotStrength;
        
        // Also sync to scene data object so UI shows correct values when repopulated
        if (sceneObj.data?.sphereCloner) {
          const data = sceneObj.data.sphereCloner;
          data.effectors = data.effectors || {};
          // Plain
          data.effectors.plain = data.effectors.plain || {};
          if (s.plainEnabled !== undefined) data.effectors.plain.enabled = s.plainEnabled > 0.5;
          if (s.plainPosition) data.effectors.plain.position = { x: s.plainPosition.x, y: s.plainPosition.y, z: s.plainPosition.z };
          if (s.plainScale !== undefined) data.effectors.plain.scale = s.plainScale;
          if (s.plainRotation) data.effectors.plain.rotation = { x: s.plainRotation.x, y: s.plainRotation.y, z: s.plainRotation.z };
          // Noise
          data.effectors.noise = data.effectors.noise || {};
          if (s.noiseEnabled !== undefined) data.effectors.noise.enabled = s.noiseEnabled > 0.5;
          if (s.noiseStrength !== undefined) data.effectors.noise.strength = s.noiseStrength;
          if (s.noiseScale !== undefined) data.effectors.noise.scale = s.noiseScale;
          if (s.noiseSpeed !== undefined) data.effectors.noise.speed = s.noiseSpeed;
          if (s.noiseOctaves !== undefined) data.effectors.noise.octaves = s.noiseOctaves;
          if (s.noiseScaleEffect !== undefined) data.effectors.noise.scaleEffect = s.noiseScaleEffect;
          if (s.noiseRotationEffect !== undefined) data.effectors.noise.rotationEffect = s.noiseRotationEffect;
          // Delay
          data.effectors.delay = data.effectors.delay || {};
          if (s.delayEnabled !== undefined) data.effectors.delay.enabled = s.delayEnabled > 0.5;
          if (s.delayAmount !== undefined) data.effectors.delay.amount = s.delayAmount;
          if (s.delayMode !== undefined) data.effectors.delay.mode = s.delayMode;
          if (s.delayOffset !== undefined) data.effectors.delay.offset = s.delayOffset;
          // Field
          data.effectors.field = data.effectors.field || {};
          if (s.fieldEnabled !== undefined) data.effectors.field.enabled = s.fieldEnabled > 0.5;
          if (s.fieldPosition) data.effectors.field.position = { x: s.fieldPosition.x, y: s.fieldPosition.y, z: s.fieldPosition.z };
          if (s.fieldRadius !== undefined) data.effectors.field.radius = s.fieldRadius;
          if (s.fieldFalloff !== undefined) data.effectors.field.falloff = s.fieldFalloff;
          if (s.fieldStrength !== undefined) data.effectors.field.strength = s.fieldStrength;
          if (s.fieldInvert !== undefined) data.effectors.field.invert = s.fieldInvert > 0.5;
          if (s.fieldScaleEffect !== undefined) data.effectors.field.scaleEffect = s.fieldScaleEffect;
          if (s.fieldPushEffect !== undefined) data.effectors.field.pushEffect = s.fieldPushEffect;
          // Formula
          data.effectors.formula = data.effectors.formula || {};
          if (s.formulaEnabled !== undefined) data.effectors.formula.enabled = s.formulaEnabled > 0.5;
          if (s.formulaType !== undefined) data.effectors.formula.type = s.formulaType;
          if (s.formulaAxis !== undefined) data.effectors.formula.axis = s.formulaAxis;
          if (s.formulaFrequency !== undefined) data.effectors.formula.frequency = s.formulaFrequency;
          if (s.formulaAmplitude !== undefined) data.effectors.formula.amplitude = s.formulaAmplitude;
          if (s.formulaSpeed !== undefined) data.effectors.formula.speed = s.formulaSpeed;
          if (s.formulaScaleEffect !== undefined) data.effectors.formula.scaleEffect = s.formulaScaleEffect;
          if (s.formulaPosEffect !== undefined) data.effectors.formula.positionEffect = s.formulaPosEffect;
          // Random
          data.effectors.random = data.effectors.random || {};
          if (s.randomEnabled !== undefined) data.effectors.random.enabled = s.randomEnabled > 0.5;
          if (s.randomSeed !== undefined) data.effectors.random.seed = s.randomSeed;
          if (s.randomPosStrength !== undefined) data.effectors.random.positionStrength = s.randomPosStrength;
          if (s.randomScaleStrength !== undefined) data.effectors.random.scaleStrength = s.randomScaleStrength;
          if (s.randomScaleUniform !== undefined) data.effectors.random.scaleUniform = s.randomScaleUniform > 0.5;
          if (s.randomRotStrength !== undefined) data.effectors.random.rotationStrength = s.randomRotStrength;
        }
      }
      
      // Restore particle system uniforms AND rebuild parameters
      if (savedState.particleSystem && sceneObj.particleSystemMesh?.material?.uniforms) {
        const u = sceneObj.particleSystemMesh.material.uniforms;
        const s = savedState.particleSystem;
        
        // Restore rebuild-required parameters to data object
        if (sceneObj.data?.particleSystem) {
          const data = sceneObj.data.particleSystem;
          if (s.count !== undefined) data.count = s.count;
          if (s.emitterType !== undefined || s.emitterRadius !== undefined) {
            data.emitter = data.emitter || {};
            if (s.emitterType !== undefined) data.emitter.type = s.emitterType;
            if (s.emitterRadius !== undefined) data.emitter.radius = s.emitterRadius;
          }
          if (s.offsetX !== undefined) data.offsetX = s.offsetX;
          if (s.offsetY !== undefined) data.offsetY = s.offsetY;
          if (s.offsetZ !== undefined) data.offsetZ = s.offsetZ;
          data.rendering = data.rendering || {};
          if (s.shape !== undefined) {
            data.rendering.shape = ['orb', 'star', 'streak', 'ring', 'diamond'][Math.round(s.shape)] || 'orb';
          }
          if (s.flareAspect !== undefined) data.rendering.aspect = s.flareAspect;
          if (s.flareSharpness !== undefined) data.rendering.sharpness = s.flareSharpness;
          if (s.alignToMotion !== undefined) data.rendering.alignToMotion = s.alignToMotion > 0.5;
        }

        if (s.offsetX !== undefined) sceneObj.particleSystemMesh.position.x = s.offsetX;
        if (s.offsetY !== undefined) sceneObj.particleSystemMesh.position.y = s.offsetY;
        if (s.offsetZ !== undefined) sceneObj.particleSystemMesh.position.z = s.offsetZ;
        
        // Restore uniform-based parameters
        if (s.particleSize !== undefined && u.uParticleSize) u.uParticleSize.value = s.particleSize;
        if (s.lifetime !== undefined && u.uLifetime) u.uLifetime.value = s.lifetime;
        if (s.emissionRate !== undefined && u.uEmissionRate) u.uEmissionRate.value = s.emissionRate;
        if (s.gravity && u.uGravity) u.uGravity.value.set(s.gravity.x, s.gravity.y, s.gravity.z);
        if (s.drag !== undefined && u.uDrag) u.uDrag.value = s.drag;
        if (s.bounce !== undefined && u.uBounce) u.uBounce.value = s.bounce;
        if (s.floorY !== undefined && u.uFloorY) u.uFloorY.value = s.floorY;
        if (s.floorEnabled !== undefined && u.uFloorEnabled) u.uFloorEnabled.value = s.floorEnabled;
        if (s.turbEnabled !== undefined && u.uTurbEnabled) u.uTurbEnabled.value = s.turbEnabled;
        if (s.turbStrength !== undefined && u.uTurbStrength) u.uTurbStrength.value = s.turbStrength;
        if (s.turbScale !== undefined && u.uTurbScale) u.uTurbScale.value = s.turbScale;
        if (s.turbSpeed !== undefined && u.uTurbSpeed) u.uTurbSpeed.value = s.turbSpeed;
        if (s.turbOctaves !== undefined && u.uTurbOctaves) u.uTurbOctaves.value = s.turbOctaves;
        if (s.vortexEnabled !== undefined && u.uVortexEnabled) u.uVortexEnabled.value = s.vortexEnabled;
        if (s.vortexStrength !== undefined && u.uVortexStrength) u.uVortexStrength.value = s.vortexStrength;
        if (s.vortexInward !== undefined && u.uVortexInward) u.uVortexInward.value = s.vortexInward;
        if (s.attractorEnabled !== undefined && u.uAttractorEnabled) u.uAttractorEnabled.value = s.attractorEnabled;
        if (s.attractorStrength !== undefined && u.uAttractorStrength) u.uAttractorStrength.value = s.attractorStrength;
        if (s.attractorRadius !== undefined && u.uAttractorRadius) u.uAttractorRadius.value = s.attractorRadius;
        if (s.attractorFalloff !== undefined && u.uAttractorFalloff) u.uAttractorFalloff.value = s.attractorFalloff;
        if (s.curlEnabled !== undefined && u.uCurlEnabled) u.uCurlEnabled.value = s.curlEnabled;
        if (s.curlStrength !== undefined && u.uCurlStrength) u.uCurlStrength.value = s.curlStrength;
        if (s.curlScale !== undefined && u.uCurlScale) u.uCurlScale.value = s.curlScale;
        if (s.curlSpeed !== undefined && u.uCurlSpeed) u.uCurlSpeed.value = s.curlSpeed;
        if (s.curlEvolution !== undefined && u.uCurlEvolution) u.uCurlEvolution.value = s.curlEvolution;
        if (s.cohesionEnabled !== undefined && u.uCohesionEnabled) u.uCohesionEnabled.value = s.cohesionEnabled;
        if (s.cohesionStrength !== undefined && u.uCohesionStrength) u.uCohesionStrength.value = s.cohesionStrength;
        if (s.cohesionRadius !== undefined && u.uCohesionRadius) u.uCohesionRadius.value = s.cohesionRadius;
        if (s.surfaceTension !== undefined && u.uSurfaceTension) u.uSurfaceTension.value = s.surfaceTension;
        if (s.viscosity !== undefined && u.uViscosity) u.uViscosity.value = s.viscosity;
        if (s.windEnabled !== undefined && u.uWindEnabled) u.uWindEnabled.value = s.windEnabled;
        if (s.windStrength !== undefined && u.uWindStrength) u.uWindStrength.value = s.windStrength;
        if (s.windGust !== undefined && u.uWindGust) u.uWindGust.value = s.windGust;
        if (s.windGustFreq !== undefined && u.uWindGustFreq) u.uWindGustFreq.value = s.windGustFreq;
        if (s.swarmEnabled !== undefined && u.uSwarmEnabled) u.uSwarmEnabled.value = s.swarmEnabled;
        if (s.swarmSeparation !== undefined && u.uSwarmSeparation) u.uSwarmSeparation.value = s.swarmSeparation;
        if (s.swarmAlignment !== undefined && u.uSwarmAlignment) u.uSwarmAlignment.value = s.swarmAlignment;
        if (s.swarmCohesion !== undefined && u.uSwarmCohesion) u.uSwarmCohesion.value = s.swarmCohesion;
        if (s.oscillationEnabled !== undefined && u.uOscillationEnabled) u.uOscillationEnabled.value = s.oscillationEnabled;
        if (s.oscillationAmplitude !== undefined && u.uOscillationAmplitude) u.uOscillationAmplitude.value = s.oscillationAmplitude;
        if (s.oscillationFrequency !== undefined && u.uOscillationFrequency) u.uOscillationFrequency.value = s.oscillationFrequency;
        if (s.oscillationPhaseOffset !== undefined && u.uOscillationPhaseOffset) u.uOscillationPhaseOffset.value = s.oscillationPhaseOffset;
        if (s.oscillationRadial !== undefined && u.uOscillationRadial) u.uOscillationRadial.value = s.oscillationRadial;
        if (s.buoyancyEnabled !== undefined && u.uBuoyancyEnabled) u.uBuoyancyEnabled.value = s.buoyancyEnabled;
        if (s.buoyancyTemperature !== undefined && u.uBuoyancyTemperature) u.uBuoyancyTemperature.value = s.buoyancyTemperature;
        if (s.buoyancyDensity !== undefined && u.uBuoyancyDensity) u.uBuoyancyDensity.value = s.buoyancyDensity;
        if (s.buoyancyThermalNoise !== undefined && u.uBuoyancyThermalNoise) u.uBuoyancyThermalNoise.value = s.buoyancyThermalNoise;
        if (s.buoyancyCoolingRate !== undefined && u.uBuoyancyCoolingRate) u.uBuoyancyCoolingRate.value = s.buoyancyCoolingRate;
        if (s.membraneEnabled !== undefined && u.uMembraneEnabled) u.uMembraneEnabled.value = s.membraneEnabled;
        if (s.membraneStiffness !== undefined && u.uMembraneStiffness) u.uMembraneStiffness.value = s.membraneStiffness;
        if (s.membraneMaxStretch !== undefined && u.uMembraneMaxStretch) u.uMembraneMaxStretch.value = s.membraneMaxStretch;
        if (s.membraneConnectionRadius !== undefined && u.uMembraneConnectionRadius) u.uMembraneConnectionRadius.value = s.membraneConnectionRadius;
        if (s.membraneDamping !== undefined && u.uMembraneDamping) u.uMembraneDamping.value = s.membraneDamping;
        if (s.sizeStart !== undefined && u.uSizeStart) u.uSizeStart.value = s.sizeStart;
        if (s.sizeMid !== undefined && u.uSizeMid) u.uSizeMid.value = s.sizeMid;
        if (s.sizeEnd !== undefined && u.uSizeEnd) u.uSizeEnd.value = s.sizeEnd;
        if (s.opacityStart !== undefined && u.uOpacityStart) u.uOpacityStart.value = s.opacityStart;
        if (s.opacityMid !== undefined && u.uOpacityMid) u.uOpacityMid.value = s.opacityMid;
        if (s.opacityEnd !== undefined && u.uOpacityEnd) u.uOpacityEnd.value = s.opacityEnd;
        if (s.rotationSpeed !== undefined && u.uRotationSpeed) u.uRotationSpeed.value = s.rotationSpeed;
        if (s.colorStart && u.uColorStart) u.uColorStart.value.setHex(parseInt(s.colorStart, 16));
        if (s.colorMid && u.uColorMid) u.uColorMid.value.setHex(parseInt(s.colorMid, 16));
        if (s.colorEnd && u.uColorEnd) u.uColorEnd.value.setHex(parseInt(s.colorEnd, 16));
        if (s.glowIntensity !== undefined && u.uGlowIntensity) u.uGlowIntensity.value = s.glowIntensity;
        if (s.shape !== undefined && u.uShape) u.uShape.value = s.shape;
        if (s.flareAspect !== undefined && u.uFlareAspect) u.uFlareAspect.value = s.flareAspect;
        if (s.flareSharpness !== undefined && u.uFlareSharpness) u.uFlareSharpness.value = s.flareSharpness;
        if (s.alignToMotion !== undefined && u.uAlignToMotion) u.uAlignToMotion.value = s.alignToMotion;
      }
      
      // Restore displaced sphere uniforms
      if (savedState.displacedSphere && sceneObj.displacedSphereMesh?.material?.uniforms) {
        const u = sceneObj.displacedSphereMesh.material.uniforms;
        const s = savedState.displacedSphere;
        
        // Displacement
        if (s.dispStrength !== undefined && u.uDispStrength) u.uDispStrength.value = s.dispStrength;
        if (s.dispScale !== undefined && u.uDispScale) u.uDispScale.value = s.dispScale;
        if (s.dispSpeed !== undefined && u.uDispSpeed) u.uDispSpeed.value = s.dispSpeed;
        if (s.dispOctaves !== undefined && u.uDispOctaves) u.uDispOctaves.value = s.dispOctaves;
        if (s.dispPersistence !== undefined && u.uDispPersistence) u.uDispPersistence.value = s.dispPersistence;
        // Spikes
        if (s.spikeCount !== undefined && u.uSpikeCount) u.uSpikeCount.value = s.spikeCount;
        if (s.spikeSharpness !== undefined && u.uSpikeSharpness) u.uSpikeSharpness.value = s.spikeSharpness;
        // Cracks
        if (s.crackWidth !== undefined && u.uCrackWidth) u.uCrackWidth.value = s.crackWidth;
        if (s.crackDepth !== undefined && u.uCrackDepth) u.uCrackDepth.value = s.crackDepth;
        // Pulse
        if (s.pulseSpeed !== undefined && u.uPulseSpeed) u.uPulseSpeed.value = s.pulseSpeed;
        if (s.pulseStrength !== undefined && u.uPulseStrength) u.uPulseStrength.value = s.pulseStrength;
        // Fractal
        if (s.fractalPower !== undefined && u.uFractalPower) u.uFractalPower.value = s.fractalPower;
        if (s.fractalIterations !== undefined && u.uFractalIterations) u.uFractalIterations.value = s.fractalIterations;
        // Material
        if (s.baseColor && u.uBaseColor) u.uBaseColor.value.setHex(parseInt(s.baseColor, 16));
        if (s.accentColor && u.uAccentColor) u.uAccentColor.value.setHex(parseInt(s.accentColor, 16));
        if (s.emissiveColor && u.uEmissiveColor) u.uEmissiveColor.value.setHex(parseInt(s.emissiveColor, 16));
        if (s.emissiveIntensity !== undefined && u.uEmissiveIntensity) u.uEmissiveIntensity.value = s.emissiveIntensity;
        if (s.metalness !== undefined && u.uMetalness) u.uMetalness.value = s.metalness;
        if (s.roughness !== undefined && u.uRoughness) u.uRoughness.value = s.roughness;
        if (s.envMapIntensity !== undefined && u.uEnvMapIntensity) u.uEnvMapIntensity.value = s.envMapIntensity;
        if (s.fresnelPower !== undefined && u.uFresnelPower) u.uFresnelPower.value = s.fresnelPower;
        if (s.rimColor && u.uRimColor) u.uRimColor.value.setHex(parseInt(s.rimColor, 16));
        // Audio reactivity
        if (s.audioDisplace !== undefined && u.uAudioDisplace) u.uAudioDisplace.value = s.audioDisplace;
        if (s.audioEmissive !== undefined && u.uAudioEmissive) u.uAudioEmissive.value = s.audioEmissive;
        
        // Sync to data for UI
        if (sceneObj.data?.displacedSphere) {
          const data = sceneObj.data.displacedSphere;
          // Geometry (note: doesn't rebuild, just updates data for UI consistency)
          if (s.detail !== undefined) data.detail = s.detail;
          if (s.radius !== undefined) data.radius = s.radius;
          data.displacement = data.displacement || {};
          if (s.dispStrength !== undefined) data.displacement.strength = s.dispStrength;
          if (s.dispScale !== undefined) data.displacement.scale = s.dispScale;
          if (s.dispSpeed !== undefined) data.displacement.speed = s.dispSpeed;
          data.spikes = data.spikes || {};
          if (s.spikeCount !== undefined) data.spikes.count = s.spikeCount;
          if (s.spikeSharpness !== undefined) data.spikes.sharpness = s.spikeSharpness;
          data.cracks = data.cracks || {};
          if (s.crackWidth !== undefined) data.cracks.width = s.crackWidth;
          if (s.crackDepth !== undefined) data.cracks.depth = s.crackDepth;
          data.pulse = data.pulse || {};
          if (s.pulseSpeed !== undefined) data.pulse.speed = s.pulseSpeed;
          if (s.pulseStrength !== undefined) data.pulse.strength = s.pulseStrength;
          data.fractal = data.fractal || {};
          if (s.fractalPower !== undefined) data.fractal.power = s.fractalPower;
          if (s.fractalIterations !== undefined) data.fractal.iterations = s.fractalIterations;
          data.colors = data.colors || {};
          if (s.baseColor) data.colors.base = '#' + s.baseColor;
          if (s.accentColor) data.colors.accent = '#' + s.accentColor;
          if (s.emissiveColor) data.colors.emissive = '#' + s.emissiveColor;
          if (s.emissiveIntensity !== undefined) data.colors.emissiveIntensity = s.emissiveIntensity;
          data.material = data.material || {};
          if (s.metalness !== undefined) data.material.metalness = s.metalness;
          if (s.roughness !== undefined) data.material.roughness = s.roughness;
          if (s.envMapIntensity !== undefined) data.material.envMapIntensity = s.envMapIntensity;
          if (s.fresnelPower !== undefined) data.material.fresnelPower = s.fresnelPower;
          data.audio = data.audio || {};
          if (s.audioDisplace !== undefined) data.audio.displaceReact = s.audioDisplace;
          if (s.audioEmissive !== undefined) data.audio.emissiveReact = s.audioEmissive;
        }
      }
      
      // Check if we need to restore deleted/added lights
      const currentLightCount = sceneObj.data?.lights?.length || 0;
      const savedLightCount = savedState.dataLights?.length || 0;
      
      if (savedLightCount !== currentLightCount && savedState.dataLights) {
        // Lights were added or removed - restore from saved data
        // First remove all current lights
        const currentLights = sceneObj.threeScene.children.filter(c => c.isLight);
        currentLights.forEach(light => {
          sceneObj.threeScene.remove(light);
          light.dispose?.();
        });
        
        // Restore data array
        sceneObj.data.lights = JSON.parse(JSON.stringify(savedState.dataLights));
        
        // Recreate lights from data
        for (let lightIdx = 0; lightIdx < sceneObj.data.lights.length; lightIdx++) {
          const lightData = sceneObj.data.lights[lightIdx];
          let light;
          switch(lightData.type) {
            case 'ambient':
              light = new THREE.AmbientLight(lightData.color || '#ffffff', lightData.intensity ?? 0.5);
              break;
            case 'directional':
              light = new THREE.DirectionalLight(lightData.color || '#ffffff', lightData.intensity ?? 1);
              if (lightData.position) light.position.set(lightData.position.x ?? 0, lightData.position.y ?? 10, lightData.position.z ?? 0);
              if (lightData.castShadow) {
                light.castShadow = true;
                light.shadow.mapSize.width = 2048;
                light.shadow.mapSize.height = 2048;
              }
              break;
            case 'hemisphere':
              light = new THREE.HemisphereLight(lightData.skyColor || '#87CEEB', lightData.groundColor || '#3a5a2a', lightData.intensity ?? 0.6);
              break;
            case 'point':
              light = new THREE.PointLight(lightData.color || '#ffffff', lightData.intensity ?? 1, lightData.distance ?? 50);
              if (lightData.position) light.position.set(lightData.position.x ?? 0, lightData.position.y ?? 5, lightData.position.z ?? 0);
              if (lightData.castShadow) light.castShadow = true;
              break;
            case 'spot':
              light = new THREE.SpotLight(lightData.color || '#ffffff', lightData.intensity ?? 1);
              if (lightData.position) light.position.set(lightData.position.x ?? 0, lightData.position.y ?? 10, lightData.position.z ?? 0);
              if (lightData.angle) light.angle = lightData.angle;
              if (lightData.penumbra) light.penumbra = lightData.penumbra;
              if (lightData.castShadow) light.castShadow = true;
              break;
            case 'rectarea':
              light = new THREE.RectAreaLight(lightData.color || '#ffffff', lightData.intensity ?? 1, lightData.width ?? 10, lightData.height ?? 10);
              if (lightData.position) light.position.set(lightData.position.x ?? 0, lightData.position.y ?? 5, lightData.position.z ?? 0);
              break;
            default:
              light = new THREE.AmbientLight(lightData.color || '#ffffff', lightData.intensity ?? 0.5);
          }
          if (light) {
            light.name = lightData.name || lightData.type;
            light.userData = { lightIndex: lightIdx };
            if (lightData.visible === false) light.visible = false;
            sceneObj.threeScene.add(light);
          }
        }
        console.log(`[Undo] Restored ${sceneObj.data.lights.length} lights for scene ${sceneIdx}`);
      } else {
        // Same number of lights - just restore properties using reliable finder
        savedState.lights.forEach((lightState, i) => {
          // Use stored lightIndex if available, otherwise fall back to array position
          const lightIdx = lightState.lightIndex !== undefined ? lightState.lightIndex : i;
          const light = findThreeLightByIndex(sceneObj, lightIdx);
          if (!light) return;
          
          if (lightState.color) {
            light.color?.setHex(parseInt(lightState.color, 16));
            // Sync to data for UI
            if (sceneObj.data?.lights?.[lightIdx]) sceneObj.data.lights[lightIdx].color = '#' + lightState.color;
          }
          if (lightState.intensity !== undefined) {
            light.intensity = lightState.intensity;
            if (sceneObj.data?.lights?.[lightIdx]) sceneObj.data.lights[lightIdx].intensity = lightState.intensity;
          }
          if (lightState.position && light.position) {
            light.position.set(lightState.position.x, lightState.position.y, lightState.position.z);
            // Sync to data for UI
            if (sceneObj.data?.lights?.[lightIdx]?.position) {
              sceneObj.data.lights[lightIdx].position.x = lightState.position.x;
              sceneObj.data.lights[lightIdx].position.y = lightState.position.y;
              sceneObj.data.lights[lightIdx].position.z = lightState.position.z;
            }
          }
          if (lightState.skyColor && light.skyColor) {
            light.skyColor.setHex(parseInt(lightState.skyColor, 16));
            if (sceneObj.data?.lights?.[lightIdx]) sceneObj.data.lights[lightIdx].skyColor = '#' + lightState.skyColor;
          }
          if (lightState.groundColor && light.groundColor) {
            light.groundColor.setHex(parseInt(lightState.groundColor, 16));
            if (sceneObj.data?.lights?.[lightIdx]) sceneObj.data.lights[lightIdx].groundColor = '#' + lightState.groundColor;
          }
        });
      }
      
      // Check if we need to restore deleted/added objects
      const currentObjCount = sceneObj.data?.objects?.length || 0;
      const savedObjCount = savedState.dataObjects?.length || 0;
      
      if (savedObjCount !== currentObjCount && savedState.dataObjects) {
        // Objects were added or removed - smart restore
        // Find which objIndexes exist in saved vs current
        const currentMeshes = sceneObj.threeScene.children.filter(c => 
          c.isMesh && !c.isGrass && !c.isVoxel && c.userData?.objIndex !== undefined
        );
        const currentObjIndexes = new Set(currentMeshes.map(m => m.userData.objIndex));
        const savedObjIndexes = new Set(savedState.dataObjects.map((_, i) => i));
        
        // Find objects that need to be removed (in current but not in saved)
        const toRemove = [...currentObjIndexes].filter(idx => !savedObjIndexes.has(idx) || idx >= savedObjCount);
        
        // Find objects that need to be added (in saved but not in current)
        const toAdd = [...savedObjIndexes].filter(idx => !currentObjIndexes.has(idx));
        
        // Remove objects that shouldn't exist
        toRemove.forEach(idx => {
          const mesh = currentMeshes.find(m => m.userData.objIndex === idx);
          if (mesh) {
            sceneObj.threeScene.remove(mesh);
            mesh.geometry?.dispose();
            mesh.material?.dispose?.();
            console.log(`[Undo] Removed extra object at index ${idx}`);
          }
        });
        
        // Restore data array (but keep reference to existing meshes)
        sceneObj.data.objects = JSON.parse(JSON.stringify(savedState.dataObjects));
        
        // Restore properties for existing meshes that are being kept
        currentMeshes.forEach(mesh => {
          const idx = mesh.userData.objIndex;
          if (savedObjIndexes.has(idx) && idx < savedObjCount) {
            // This mesh is still valid - restore its properties from savedState.objects
            const savedObjState = savedState.objects.find(o => o.objIndex === idx);
            if (savedObjState) {
              // Restore transform from Three.js state (already in radians)
              if (savedObjState.position) {
                mesh.position.set(savedObjState.position.x, savedObjState.position.y, savedObjState.position.z);
              }
              if (savedObjState.rotation) {
                mesh.rotation.set(savedObjState.rotation.x, savedObjState.rotation.y, savedObjState.rotation.z);
              }
              if (savedObjState.scale) {
                mesh.scale.set(savedObjState.scale.x, savedObjState.scale.y, savedObjState.scale.z);
              }
              // Restore material properties (but NOT textures - those are intact on the mesh)
              if (savedObjState.material && mesh.material) {
                if (savedObjState.material.color) {
                  mesh.material.color?.setHex(parseInt(savedObjState.material.color, 16));
                }
                if (savedObjState.material.opacity !== undefined) {
                  mesh.material.opacity = savedObjState.material.opacity;
                }
                if (savedObjState.material.metalness !== undefined && mesh.material.metalness !== undefined) {
                  mesh.material.metalness = savedObjState.material.metalness;
                }
                if (savedObjState.material.roughness !== undefined && mesh.material.roughness !== undefined) {
                  mesh.material.roughness = savedObjState.material.roughness;
                }
              }
            }
            // Update userData with saved data
            const savedObjData = savedState.dataObjects[idx];
            mesh.userData = { ...savedObjData, objIndex: idx };
          }
        });
        
        // Add back deleted objects
        toAdd.forEach(objIndex => {
          const objData = savedState.dataObjects[objIndex];
          if (!objData) return;
          
          // Skip GLTF models for now - they require async loading
          if (objData.geometry === 'gltf') {
            console.log(`[Undo] Cannot restore GLTF model at index ${objIndex} - requires reload`);
            return;
          }
          
          let geometry;
          switch(objData.geometry) {
            case 'plane':
              geometry = new THREE.PlaneGeometry(objData.width ?? 10, objData.height ?? 10, 32, 32);
              break;
            case 'sphere':
              geometry = new THREE.SphereGeometry(objData.radius ?? 1, 32, 32);
              break;
            case 'box':
              geometry = new THREE.BoxGeometry(objData.width ?? 2, objData.height ?? 2, objData.depth ?? 2);
              break;
            case 'capsule':
              geometry = new THREE.CapsuleGeometry(objData.radius ?? 0.5, objData.length ?? 1, 16, 32);
              break;
            case 'cylinder':
              geometry = new THREE.CylinderGeometry(objData.radiusTop ?? 1, objData.radiusBottom ?? 1, objData.height ?? 2, 32);
              break;
            case 'torus':
              geometry = new THREE.TorusGeometry(objData.radius ?? 1, objData.tube ?? 0.4, 16, 100);
              break;
            case 'cone':
              geometry = new THREE.ConeGeometry(objData.radius ?? 1, objData.height ?? 2, 32);
              break;
            default:
              geometry = new THREE.BoxGeometry(2, 2, 2);
          }
          
          const matData = typeof objData.material === 'object' ? objData.material : {};
          const material = new THREE.MeshStandardMaterial({
            color: matData.color || objData.color || '#cccccc',
            metalness: matData.metalness ?? 0.3,
            roughness: matData.roughness ?? 0.7,
            opacity: matData.opacity ?? 1,
            transparent: matData.transparent || false,
          });
          
          // If there's an emissive color, set it
          if (matData.emissive) {
            material.emissive = new THREE.Color(matData.emissive);
            material.emissiveIntensity = matData.emissiveIntensity ?? 1;
          }
          
          const mesh = new THREE.Mesh(geometry, material);
          mesh.name = objData.name || objData.geometry;
          
          // Apply transform - check if rotation is in radians or degrees
          if (objData.position) {
            mesh.position.set(objData.position.x ?? 0, objData.position.y ?? 0, objData.position.z ?? 0);
          }
          if (objData.rotation) {
            // Check if values look like radians (small values) or degrees (large values)
            const rx = objData.rotation.x ?? 0;
            const ry = objData.rotation.y ?? 0;
            const rz = objData.rotation.z ?? 0;
            const looksLikeRadians = Math.abs(rx) <= 2*Math.PI && Math.abs(ry) <= 2*Math.PI && Math.abs(rz) <= 2*Math.PI && 
                                     (Math.abs(rx) > 0.1 || Math.abs(ry) > 0.1 || Math.abs(rz) > 0.1 || (rx === 0 && ry === 0 && rz === 0));
            if (looksLikeRadians || (Math.abs(rx) < 7 && Math.abs(ry) < 7 && Math.abs(rz) < 7)) {
              // Already in radians or close to zero
              mesh.rotation.set(rx, ry, rz);
            } else {
              // Convert from degrees
              mesh.rotation.set(rx * Math.PI / 180, ry * Math.PI / 180, rz * Math.PI / 180);
            }
          }
          if (objData.scale) {
            if (typeof objData.scale === 'number') {
              mesh.scale.set(objData.scale, objData.scale, objData.scale);
            } else {
              mesh.scale.set(objData.scale.x ?? 1, objData.scale.y ?? 1, objData.scale.z ?? 1);
            }
          }
          
          mesh.castShadow = objData.castShadow !== false;
          mesh.receiveShadow = objData.receiveShadow !== false;
          mesh.visible = objData.visible !== false;
          mesh.userData = { ...objData, objIndex };
          
          sceneObj.threeScene.add(mesh);
          
          // Load textures asynchronously if they exist in the saved data
          const textureProps = ['map', 'normalMap', 'roughnessMap', 'metalnessMap', 'aoMap', 
                               'emissiveMap', 'envMap', 'bumpMap', 'displacementMap', 'lightMap', 'alphaMap'];
          textureProps.forEach(prop => {
            const texPath = matData[prop];
            if (texPath && typeof texPath === 'string') {
              // Load texture asynchronously
              applyTextureToObject(parseInt(sceneIdx), objIndex, prop, texPath);
            }
          });
          
          console.log(`[Undo] Restored object "${objData.name}" at index ${objIndex}`);
        });
        
        console.log(`[Undo] Restored objects: removed ${toRemove.length}, added ${toAdd.length}`);
      } else {
        // Same number of objects - just restore properties
      // Restore objects - look up by objIndex for reliability
      savedState.objects.forEach((objState) => {
        // Find object by objIndex (could be mesh or GLTF group)
        let targetObj = null;
        sceneObj.threeScene.traverse((obj) => {
          if ((obj.isMesh || obj.userData?.isGLTF) && obj.userData?.objIndex === objState.objIndex) {
            // Prefer the root object (GLTF group) over child meshes
            if (!targetObj || obj.userData?.isGLTF) {
              targetObj = obj;
            }
          }
        });
        if (!targetObj) return;
        
        const objData = sceneObj.data?.objects?.[objState.objIndex];
        
        if (objState.position) {
          targetObj.position.set(objState.position.x, objState.position.y, objState.position.z);
          // Sync to data for UI
          if (objData?.position) {
            objData.position.x = objState.position.x;
            objData.position.y = objState.position.y;
            objData.position.z = objState.position.z;
          }
        }
        if (objState.rotation) {
          targetObj.rotation.set(objState.rotation.x, objState.rotation.y, objState.rotation.z);
          if (objData?.rotation) {
            objData.rotation.x = objState.rotation.x;
            objData.rotation.y = objState.rotation.y;
            objData.rotation.z = objState.rotation.z;
          }
        }
        if (objState.scale) {
          targetObj.scale.set(objState.scale.x, objState.scale.y, objState.scale.z);
          // Sync to data for UI - convert to object format if needed
          if (objData) {
            objData.scale = {
              x: objState.scale.x,
              y: objState.scale.y,
              z: objState.scale.z
            };
          }
        }
        
        // For material, handle both regular meshes and GLTF models
        if (objState.material) {
          // Get material - for GLTF, apply to all child meshes
          const applyMaterial = (mat) => {
            if (!mat) return;
            if (objState.material.color) {
              mat.color?.setHex(parseInt(objState.material.color, 16));
            }
            if (objState.material.opacity !== undefined) {
              mat.opacity = objState.material.opacity;
            }
            if (objState.material.metalness !== undefined && mat.metalness !== undefined) {
              mat.metalness = objState.material.metalness;
            }
            if (objState.material.roughness !== undefined && mat.roughness !== undefined) {
              mat.roughness = objState.material.roughness;
            }
            if (objState.material.envMapIntensity !== undefined && mat.envMapIntensity !== undefined) {
              mat.envMapIntensity = objState.material.envMapIntensity;
            }
          };
          
          if (targetObj.userData?.isGLTF) {
            // Apply to all child meshes in GLTF
            targetObj.traverse((child) => {
              if (child.isMesh) applyMaterial(child.material);
            });
          } else {
            applyMaterial(targetObj.material);
          }
          
          // Sync to data for UI
          if (objData) {
            if (objState.material.color) objData.color = '#' + objState.material.color;
            if (objState.material.opacity !== undefined) objData.opacity = objState.material.opacity;
            if (objState.material.metalness !== undefined) objData.metalness = objState.material.metalness;
            if (objState.material.roughness !== undefined) objData.roughness = objState.material.roughness;
            if (objState.material.envMapIntensity !== undefined) objData.envMapIntensity = objState.material.envMapIntensity;
          }
        }
        
        // Restore displacement uniforms
        if (objState.displacement) {
          const dispMat = targetObj.userData?.displacementMaterial || 
                          (targetObj.material?.uniforms?.uDisplacementStrength ? targetObj.material : null);
          if (dispMat?.uniforms) {
            if (objState.displacement.strength !== undefined && dispMat.uniforms.uDisplacementStrength) {
              dispMat.uniforms.uDisplacementStrength.value = objState.displacement.strength;
              if (objData?.displacement) objData.displacement.strength = objState.displacement.strength;
            }
            if (objState.displacement.frequency !== undefined && dispMat.uniforms.uDisplacementFrequency) {
              dispMat.uniforms.uDisplacementFrequency.value = objState.displacement.frequency;
              if (objData?.displacement) objData.displacement.frequency = objState.displacement.frequency;
            }
            if (objState.displacement.speed !== undefined && dispMat.uniforms.uDisplacementSpeed) {
              dispMat.uniforms.uDisplacementSpeed.value = objState.displacement.speed;
              if (objData?.displacement) objData.displacement.speed = objState.displacement.speed;
            }
            if (objState.displacement.noiseScale !== undefined && dispMat.uniforms.uNoiseScale) {
              dispMat.uniforms.uNoiseScale.value = objState.displacement.noiseScale;
              if (objData?.displacement) objData.displacement.noiseScale = objState.displacement.noiseScale;
            }
          }
        }
      });
      }

      // Restore the complete canonical scene state after the legacy runtime
      // restore has handled object/light additions and removals.
      restoreSerializedSceneState(
        sceneIdx,
        sceneObj,
        savedState.sceneData,
        previousSceneData,
      );
    }
  }

  function getTimelineState() {
    // Convert exposedParams Sets to arrays for serialization
    const exposedParamsArray = {};
    for (const [segId, paramSet] of Object.entries(exposedParams)) {
      if (paramSet instanceof Set) {
      exposedParamsArray[segId] = [...paramSet];
      } else {
        exposedParamsArray[segId] = paramSet;
      }
    }
    
    // Deep clone tracks and strip runtime-only flags that shouldn't be persisted
    const tracksForSave = JSON.parse(JSON.stringify(timelineTracks));
    for (const track of tracksForSave) {
      for (const seg of track.segments || []) {
        delete seg._texturesLoaded; // Runtime flag, not persisted
      }
    }
    
    return {
      // New track-based format
      tracks: tracksForSave,
      trackIdCounter: trackIdCounter,
      // Also save legacy format for compatibility
      sceneSegments: JSON.parse(JSON.stringify(getSceneSegments())),
      imageSegments: JSON.parse(JSON.stringify(getImageSegments())),
      postfxSegments: JSON.parse(JSON.stringify(getPostfxSegments())),
      shaderSegments: JSON.parse(JSON.stringify(getShaderSegments())),
      imageIdCounter: imageIdCounter,
      postfxIdCounter: postfxIdCounter,
      shaderIdCounter: shaderIdCounter,
      sceneIdCounter: sceneIdCounter,
      textIdCounter: textIdCounter,
      lutIdCounter: lutIdCounter,
      audioIdCounter: audioIdCounter,
      keyframeData: JSON.parse(JSON.stringify(keyframeData)),
      exposedParams: exposedParamsArray,
      exposedParamsMeta: JSON.parse(JSON.stringify(exposedParamsMeta)),
      curveTypes: JSON.parse(JSON.stringify(curveTypes)),
      lfoData: JSON.parse(JSON.stringify(lfoData)),
      // Save selected segment for undo/redo
      selectedSegment: selectedSegment ? { track: selectedSegment.track, index: selectedSegment.index, trackId: selectedSegment.trackId } : null,
      // Save selected scene object for undo/redo (Camera, Light, Object, etc.)
      selectedSceneObject: selectedSceneObject ? JSON.parse(JSON.stringify(selectedSceneObject)) : null,
      // Save audio state for undo/redo
      audioFile: currentAudioFile,
      audioDuration: currentAudioDuration,
      durationGuess: durationGuess,
      wavePeaks: wavePeaks ? [...wavePeaks] : null,
    };
  }

  function getFullState() {
    return {
      timeline: getTimelineState(),
      objects3D: get3DObjectState(),
      segmentScenes: getSegmentSceneState(),
    };
  }

  function setTimelineState(state, preserveSelection = false) {
    // Restore track-based format if available
    if (state.tracks) {
      timelineTracks = JSON.parse(JSON.stringify(state.tracks));
      if (state.trackIdCounter !== undefined) trackIdCounter = state.trackIdCounter;
      
      // Clear runtime flags that shouldn't be persisted
      for (const track of timelineTracks) {
        for (const seg of track.segments || []) {
          delete seg._texturesLoaded;
        }
      }
    } else {
      // Fallback: restore from legacy format into default tracks
      timelineTracks = [];
      trackIdCounter = 0;
      timelineTracks.push({
        id: generateTrackId(),
        type: 'scene',
        name: 'Scene 1',
        order: 0,
        segments: JSON.parse(JSON.stringify(state.sceneSegments || []))
      });
      timelineTracks.push({
        id: generateTrackId(),
        type: 'shader',
        name: 'Shader 1',
        order: 1,
        segments: JSON.parse(JSON.stringify(state.shaderSegments || []))
      });
      timelineTracks.push({
        id: generateTrackId(),
        type: 'media',
        name: 'Media 1',
        order: 2,
        segments: JSON.parse(JSON.stringify(state.imageSegments || []))
      });
      timelineTracks.push({
        id: generateTrackId(),
        type: 'postfx',
        name: 'Post FX 1',
        order: 3,
        segments: JSON.parse(JSON.stringify(state.postfxSegments || []))
      });
    }
    migrateSceneClipOverrideOwnership();
    
    imageIdCounter = state.imageIdCounter || 0;
    postfxIdCounter = state.postfxIdCounter || 0;
    shaderIdCounter = state.shaderIdCounter || 0;
    sceneIdCounter = state.sceneIdCounter || 10;
    textIdCounter = state.textIdCounter || 0;
    lutIdCounter = state.lutIdCounter || 0;
    audioIdCounter = state.audioIdCounter || 0;
    
    // Restore keyframe data
    if (state.keyframeData) {
      keyframeData = JSON.parse(JSON.stringify(state.keyframeData));
    }
    
    // Restore exposedParams (convert arrays back to Sets)
    if (state.exposedParams) {
      exposedParams = {};
      for (const [segId, paramArray] of Object.entries(state.exposedParams)) {
        exposedParams[segId] = new Set(paramArray);
      }
    }
    
    // Restore exposedParamsMeta
    if (state.exposedParamsMeta) {
      exposedParamsMeta = JSON.parse(JSON.stringify(state.exposedParamsMeta));
    }
    
    // Restore curve types
    if (state.curveTypes) {
      curveTypes = JSON.parse(JSON.stringify(state.curveTypes));
    }

    if (state.lfoData) {
      lfoData = JSON.parse(JSON.stringify(state.lfoData));
    }
    migrateIndexedSegmentState(
      [keyframeData, exposedParams, exposedParamsMeta, curveTypes, lfoData],
      timelineTracks,
    );
    
    // Restore audio state if it changed
    if (state.audioFile !== undefined) {
      const audioChanged = state.audioFile !== currentAudioFile;
      if (audioChanged) {
        currentAudioFile = state.audioFile;
        currentAudioDuration = state.audioDuration || 60;
        durationGuess = state.durationGuess || 60;
        wavePeaks = state.wavePeaks ? [...state.wavePeaks] : null;
        
        if (state.audioFile) {
          // Restore audio - load the file
          AUDIO_URL = state.audioFile;
          if (audioEl) {
            audioEl.src = state.audioFile;
            audioEl.load();
          }
          // Redraw waveform if we have peaks
          if (wavePeaks) {
            drawWave();
          }
        } else {
          // Audio was removed - clear it
          AUDIO_URL = null;
          if (audioEl) {
            audioEl.pause();
            audioEl.src = '';
            audioEl.load();
          }
          drawWave();
        }
        updateAudioTrackUI();
      }
    }
    
    // Restore selected segment if saved, otherwise clear it
    if (state.selectedSegment) {
      selectedSegment = { 
        track: state.selectedSegment.track, 
        index: state.selectedSegment.index,
        trackId: state.selectedSegment.trackId 
      };
    } else if (!preserveSelection) {
      selectedSegment = null;
    }
    
    renderTracks();
    refreshPrimaryAudioState();
    
    // Refresh keyframe editor if in keyframe mode
    if (editorMode === 'keyframe') {
      renderKeyframeEditor();
    }
  }

  function setFullState(state) {
    // Restore selected scene object from saved state
    const savedSelectedSceneObject = state.timeline?.selectedSceneObject || null;
    
    if (state.timeline) {
      setTimelineState(state.timeline);
      syncSegmentSceneInstances();
    }
    if (state.objects3D) {
      set3DObjectState(state.objects3D);
    }
    if (state.segmentScenes) {
      setSegmentSceneState(state.segmentScenes);
    }
    // Refresh parameter panel if something is selected
    if (selectedSegment) {
      // Use trackId if available for accurate segment lookup, otherwise fall back to track type
      let seg = null;
      if (selectedSegment.trackId) {
        const track = getTrackById(selectedSegment.trackId);
        if (track && track.segments[selectedSegment.index]) {
          seg = track.segments[selectedSegment.index];
        }
      }
      // Fallback to legacy method if trackId lookup failed
      if (!seg) {
      const segs = getSegmentsForTrack(selectedSegment.track);
        seg = segs[selectedSegment.index];
      }
      if (seg) {
        populateObjectList(selectedSegment.track, seg);
        
        // Restore the selected scene object and re-populate its params
        if (savedSelectedSceneObject && selectedSegment.track === 'scenes') {
          const sceneIdx = savedSelectedSceneObject.sceneIdx ?? seg.sceneIndex;
          const { type, id } = savedSelectedSceneObject;
          document.querySelectorAll('.obj-item').forEach(el => el.classList.remove('selected'));
          selectSceneObject(sceneIdx, type, id);
          document.querySelector(parameterPanelItemSelector(type, id))?.classList.add('selected');
        }
      }
    }
  }

  function saveUndoState() {
    undoStack.push(getFullState());
    if (undoStack.length > MAX_UNDO_STATES) {
      undoStack.shift();
    }
    // Clear redo stack when a new action is performed
    redoStack = [];
    updateUndoRedoButtons();
  }

  function saveUndoStateForAction() {
    // A checkbox/select can synchronously trigger a rebuild. In that case its
    // delegated edit session already owns the correct pre-change snapshot.
    if (parameterEditSession?.hasPending()) return;
    saveUndoState();
  }

  parameterEditSession = createParameterEditSession({
    capture: getFullState,
    pushUndo: state => {
      undoStack.push(state);
      if (undoStack.length > MAX_UNDO_STATES) undoStack.shift();
    },
    clearRedo: () => { redoStack = []; },
    onChange: updateUndoRedoButtons,
  });

  // Call this when starting to edit a parameter (on focus/mousedown)
  function beginParamEdit() {
    parameterEditSession.begin();
  }

  // Call this when finishing a parameter edit (on blur/mouseup/change)
  function endParamEdit() {
    parameterEditSession.end();
  }

  function isParameterControl(target) {
    return target instanceof Element &&
      !!target.closest('input, select, textarea');
  }

  // Parameter panels are rebuilt dynamically and historically each panel had
  // to remember to wire its own undo lifecycle. Delegate that lifecycle once
  // from the stable panel root so new scene systems and non-slider controls
  // automatically participate in undo.
  paramContent.addEventListener('pointerdown', event => {
    if (isParameterControl(event.target)) beginParamEdit();
  });
  paramContent.addEventListener('focusin', event => {
    if (isParameterControl(event.target)) beginParamEdit();
  });
  paramContent.addEventListener('change', event => {
    if (isParameterControl(event.target)) endParamEdit();
  });
  paramContent.addEventListener('focusout', event => {
    if (isParameterControl(event.target)) endParamEdit();
  });
  paramContent.addEventListener('pointerup', event => {
    if (isParameterControl(event.target)) endParamEdit();
  });
  paramContent.addEventListener('pointercancel', event => {
    if (isParameterControl(event.target)) endParamEdit();
  });
  createParameterSliderValueController({
    root: paramContent,
    dialogController: keyframeValueDialogController,
    formatValue: formatKeyframeValue,
    parseValue: parseKeyframeValue,
    beginEdit: beginParamEdit,
    endEdit: endParamEdit,
    onApplied: ({ value, valueSpec, displayName }) => {
      showToast(`${displayName}: ${formatKeyframeValue(value, valueSpec)}`);
    },
  });
  function performUndo() {
    // If Cmd/Ctrl-Z arrives while a slider or input is still active, commit
    // its pre-edit snapshot first. Cancelling here used to discard the exact
    // state the user was trying to restore.
    parameterEditSession.end();
    if (undoStack.length === 0) {
      console.log('[Undo] Stack empty, nothing to undo');
      return;
    }
    
    console.log(`[Undo] Performing undo (${undoStack.length} states in stack)`);
    
    // Save current state to redo stack
    redoStack.push(getFullState());
    
    // Restore previous state
    const prevState = undoStack.pop();
    setFullState(prevState);
    updateUndoRedoButtons();
    
    console.log('[Undo] Undo complete');
  }

  function performRedo() {
    // Keep delayed parameter-edit completion from invalidating redo while the
    // restored state rebuilds the parameter panel.
    parameterEditSession.cancel();
    if (redoStack.length === 0) return;
    
    // Save current state to undo stack
    undoStack.push(getFullState());
    
    // Restore next state
    const nextState = redoStack.pop();
    setFullState(nextState);
    updateUndoRedoButtons();
  }

  function updateUndoRedoButtons() {
    btnUndo.disabled = undoStack.length === 0;
    btnRedo.disabled = redoStack.length === 0;
  }

  // Initialize button states
  updateUndoRedoButtons();

  // Populate scene dropdown
  for (let i = 0; i < SCENE_FILES.length; i++) {
    const opt = document.createElement("option");
    opt.value = `scene:${i}`;
    opt.textContent = `Scene ${i}`;
    selScene.appendChild(opt);
  }
  // Add postfx options
  for (const fx of POSTFX_OPTIONS) {
    const opt = document.createElement("option");
    opt.value = `postfx:${fx.id}`;
    opt.textContent = `PostFX: ${fx.name}`;
    selScene.appendChild(opt);
  }

  // =========================
  // SCENE LOADER
  // =========================
  async function loadJSON(url) {
    const res = await fetch(url, { cache: "no-cache" });
    if (!res.ok) throw new Error(`Failed to fetch ${url}`);
    return await res.json();
  }

  function setSceneSystemTime(instance, time) {
    const uniforms = instance?.material?.uniforms;
    if (uniforms?.uTime) uniforms.uTime.value = time;
  }

  function setNestedSceneSystemTime(instance, time, meshKeys) {
    for (const meshKey of meshKeys) {
      setSceneSystemTime(instance?.userData?.[meshKey], time);
    }
  }

  function disposeSceneSystemObject(instance) {
    instance?.userData?.dispose?.();
    instance?.parent?.remove(instance);
    instance?.traverse?.(object => {
      animationMixers.release(object);
      object.geometry?.dispose?.();
      const materials = Array.isArray(object.material) ? object.material : [object.material];
      for (const material of materials) {
        if (!material) continue;
        for (const value of Object.values(material)) {
          if (value?.isTexture) value.dispose();
        }
        material.dispose?.();
      }
    });
  }

  const sceneSystemRegistry = createSceneSystemRegistry([
    {
      key: 'grassField', resultKey: 'grassMesh',
      create: (config, { scene }) => createGrassField(config, scene),
      animate: setSceneSystemTime, dispose: disposeSceneSystemObject,
    },
    {
      key: 'voxelTunnel', resultKey: 'voxelMesh',
      create: (config, { scene, data }) => createVoxelTunnel(
        config,
        scene,
        (data.lights || []).filter(light => light.type === 'point' && light.animation),
      ),
      animate: setSceneSystemTime, dispose: disposeSceneSystemObject,
    },
    {
      key: 'sphereCloner', resultKey: 'sphereClonerMesh',
      create: (config, { scene }) => createSphereCloner(config, scene),
      animate: setSceneSystemTime, dispose: disposeSceneSystemObject,
    },
    {
      key: 'heartField', resultKey: 'heartFieldMesh',
      create: (config, { scene }) => createHeartFieldSystem(THREE, config, scene),
      animate: (instance, time, { audioData = {} }) => updateHeartField(THREE, instance, time, audioData),
      dispose: disposeSceneSystemObject,
    },
    {
      key: 'landscapeField', resultKey: 'landscapeMesh',
      create: (config, { scene }) => createLandscapeFieldSystem(THREE, config, scene),
      animate: (instance, time, { audioData = {} }) => updateLandscapeField(instance, time, audioData),
      dispose: disposeSceneSystemObject,
    },
    {
      key: 'clothSimulation', resultKey: 'clothSimulationMesh',
      create: (config, { scene }) => createClothSimulationSystem(THREE, config, scene),
      animate: (instance, time, { audioData = {} }) => updateClothSimulation(instance, time, audioData),
      dispose: disposeSceneSystemObject,
    },
    {
      key: 'particleSystem', resultKey: 'particleSystemMesh',
      create: (config, { scene }) => createParticleSystem(config, scene),
      animate: (instance, time, { viewportHeight }) => {
        setSceneSystemTime(instance, time);
        if (viewportHeight && instance.material?.uniforms?.uViewportHeight) {
          instance.material.uniforms.uViewportHeight.value = viewportHeight;
        }
      },
      dispose: disposeSceneSystemObject,
    },
    {
      key: 'displacedSphere', resultKey: 'displacedSphereMesh',
      create: (config, { scene }) => createDisplacedSphere(config, scene),
      animate: (instance, time, { audioData = {} }) => {
        setSceneSystemTime(instance, time);
        const uniforms = instance.material?.uniforms;
        if (uniforms?.uBass) uniforms.uBass.value = audioData.bass || 0;
        if (uniforms?.uTreble) uniforms.uTreble.value = audioData.treble || 0;
      },
      dispose: disposeSceneSystemObject,
    },
    {
      key: 'fractalTree', resultKey: 'fractalTreeMesh',
      create: (config, { scene }) => createFractalTree(config, scene),
      animate: (instance, time) => setNestedSceneSystemTime(instance, time, ['branchMesh', 'leafMesh']),
      dispose: disposeSceneSystemObject,
    },
    {
      key: 'sierpinskiPyramid', resultKey: 'sierpinskiMesh',
      create: (config, { scene }) => createSierpinskiPyramid(config, scene),
      animate: (instance, time) => setNestedSceneSystemTime(instance, time, ['tetraMesh']),
      dispose: disposeSceneSystemObject,
    },
    {
      key: 'mengerSponge', resultKey: 'mengerSpongeMesh',
      create: (config, { scene }) => createMengerSponge(config, scene),
      animate: (instance, time) => setNestedSceneSystemTime(instance, time, ['cubeMesh']),
      dispose: disposeSceneSystemObject,
    },
    {
      key: 'coralGrowth', resultKey: 'coralGrowthMesh',
      create: (config, { scene }) => createCoralGrowth(config, scene),
      animate: (instance, time) => setNestedSceneSystemTime(instance, time, ['branchMesh', 'polypMesh']),
      dispose: disposeSceneSystemObject,
    },
    {
      key: 'crystalLattice', resultKey: 'crystalLatticeMesh',
      create: (config, { scene }) => createCrystalLattice(config, scene),
      animate: (instance, time, { sceneObj }) => {
        setNestedSceneSystemTime(instance, time, ['crystalMesh']);
        if (sceneObj?.data?.crystalLattice?.useSceneLights) syncCrystalLightsFromScene(sceneObj);
      },
      dispose: disposeSceneSystemObject,
    },
    {
      key: 'starCrawl', resultKey: 'starCrawlMesh',
      create: (config, { scene, starCrawlBakedImage }) => createStarCrawlSystem(
        THREE,
        config,
        scene,
        {
          documentTarget: document,
          bakedImage: starCrawlBakedImage,
        },
      ),
      animate: (instance, time, context) => {
        updateStarCrawl(instance, resolveStarCrawlTime(time, context));
      },
      dispose: disposeSceneSystemObject,
    },
    {
      key: 'fireworksDisplay', resultKey: 'fireworksDisplayMesh',
      create: (config, { scene }) => createFireworksDisplaySystem(THREE, config, scene),
      animate: (instance, time, context) => {
        const config = instance?.userData?.config || {};
        updateFireworksDisplay(
          instance,
          resolveFireworksTime(time, context, config),
          context,
        );
      },
      dispose: disposeSceneSystemObject,
    },
  ]);

  function animateSceneSystems(sceneObj, time, context = {}) {
    sceneSystemRegistry.animateEnabled(sceneObj, time, { ...context, sceneObj });
  }

  function disposeSceneSystems(sceneObj) {
    sceneSystemRegistry.disposeEnabled(sceneObj);
  }

  async function createThreeScene(data) {
    const { scene, camera, animatedLights } = createSceneFoundation(
      THREE,
      data,
      getTargetAspectRatio()
    );

    await createSceneObjects(THREE, scene, data.objects || [], {
      GLTFLoader,
      OBJLoader,
      FBXLoader,
      STLLoader,
      animationMixers,
      isPlaying: () => silentPlaying,
      isVideoFile,
      documentTarget: document,
    });

    let starCrawlBakedImage = null;
    if (data.starCrawl?.enabled) {
      try {
        starCrawlBakedImage = await loadStarCrawlBakedImage(data.starCrawl);
      } catch (error) {
        console.warn('[Star Crawl] Baked texture unavailable; using live text:', error);
      }
      if (!starCrawlBakedImage) {
        await ensureStarCrawlFont(data.starCrawl, document.fonts);
      }
    }

    const systemInstances = sceneSystemRegistry.createEnabled(
      data,
      { scene, data, starCrawlBakedImage },
      (key, error) => {
      console.error(`[${key}] Error creating scene system:`, error);
      },
    );
    const {
      grassMesh,
      voxelMesh,
      sphereClonerMesh,
      heartFieldMesh,
      landscapeMesh,
      clothSimulationMesh,
      particleSystemMesh,
      displacedSphereMesh,
      fractalTreeMesh,
      sierpinskiMesh,
      mengerSpongeMesh,
      coralGrowthMesh,
      crystalLatticeMesh,
      starCrawlMesh,
      fireworksDisplayMesh,
    } = systemInstances;
    
    const threeGroups = createSceneGroups(THREE, scene, data.groups);

    return { threeScene: scene, camera, data, grassMesh, voxelMesh, sphereClonerMesh, heartFieldMesh, landscapeMesh, clothSimulationMesh, particleSystemMesh, displacedSphereMesh, fractalTreeMesh, sierpinskiMesh, mengerSpongeMesh, coralGrowthMesh, crystalLatticeMesh, starCrawlMesh, fireworksDisplayMesh, animatedLights, threeGroups };
  }

  // Create instanced grass field with wind shader
  function createGrassField(config, scene) {
    return createGrassFieldSystem(THREE, config, scene);
  }

  // Create instanced voxel tunnel with displacement shader
  function createVoxelTunnel(config, scene, pointLights = []) {
    return createVoxelTunnelSystem(THREE, config, scene, pointLights);
  }

  // Create instanced sphere cloner with spiked spheres on invisible base sphere
  function createSphereCloner(config, scene) {
    return createSphereClonerSystem(THREE, config, scene, {
      loadCustomCloneModel,
      createAnimatedClones,
      loadCustomDistributionSurface,
    });
  }
  
  // Helper function to load custom clone model (returns geometry and animation info)
  async function loadCustomCloneModel(modelPath, targetRadius) {
    const ext = modelPath.split('.').pop().toLowerCase();
    let result = { geometry: null, scene: null, animations: [], hasAnimations: false };
    
    try {
      if (ext === 'glb' || ext === 'gltf') {
        const gltf = await new Promise((resolve, reject) => {
          const loader = new GLTFLoader();
          loader.load('/' + modelPath, resolve, undefined, reject);
        });
        
        // Check for animations
        if (gltf.animations && gltf.animations.length > 0) {
          result.hasAnimations = true;
          result.animations = gltf.animations;
          result.scene = gltf.scene;
          console.log(`[Cloner] Animated model detected: ${gltf.animations.length} animation(s)`);
        }
        
        // Extract geometry from first mesh found
        gltf.scene.traverse(child => {
          if (!result.geometry && child.isMesh && child.geometry) {
            result.geometry = child.geometry.clone();
          }
        });
      } else if (ext === 'obj') {
        const obj = await new Promise((resolve, reject) => {
          const loader = new OBJLoader();
          loader.load('/' + modelPath, resolve, undefined, reject);
        });
        obj.traverse(child => {
          if (!result.geometry && child.isMesh && child.geometry) {
            result.geometry = child.geometry.clone();
          }
        });
      } else if (ext === 'fbx') {
        const fbx = await new Promise((resolve, reject) => {
          const loader = new FBXLoader();
          loader.load('/' + modelPath, resolve, undefined, reject);
        });
        
        // Check for animations
        if (fbx.animations && fbx.animations.length > 0) {
          result.hasAnimations = true;
          result.animations = fbx.animations;
          result.scene = fbx;
          console.log(`[Cloner] Animated FBX model detected: ${fbx.animations.length} animation(s)`);
        }
        
        // Extract geometry
        fbx.traverse(child => {
          if (!result.geometry && child.isMesh && child.geometry) {
            result.geometry = child.geometry.clone();
          }
        });
      }
      
      // Scale geometry if found (for non-animated use)
      if (result.geometry) {
        result.geometry.computeBoundingSphere();
        const currentRadius = result.geometry.boundingSphere.radius;
        const scale = targetRadius / currentRadius;
        result.geometry.scale(scale, scale, scale);
        result.geometry.center();
        result.geometry.computeVertexNormals();
      }
      
      return result;
    } catch (err) {
      console.error('[Cloner] Error loading custom clone model:', err);
    }
    return result;
  }
  
  // Maximum clones for animated models (performance limit)
  const MAX_ANIMATED_CLONES = 50;
  
  // Cleanup animated cloner (remove AnimationMixers from global map)
  function cleanupAnimatedCloner(clonerGroup) {
    if (!clonerGroup) return;
    
    // Remove all animation mixers for clones in this group
    clonerGroup.traverse(child => animationMixers.release(child));
    
    // Also clear the stored mixers array
    if (clonerGroup.userData.cloneMixers) {
      clonerGroup.userData.cloneMixers.forEach(mixer => mixer.stopAllAction());
      clonerGroup.userData.cloneMixers = [];
    }
    
    console.log('[Cloner] Cleaned up animated cloner');
  }
  
  // Create animated clones as individual SkinnedMeshes
  function createAnimatedClones(config, scene, modelScene, animations, positions) {
    const count = Math.min(positions.length, MAX_ANIMATED_CLONES);
    const cloneRadius = config.cloneRadius ?? 0.15;
    const sizeVariation = config.sizeVariation ?? 0.5;
    
    // Warn if count was limited
    if (positions.length > MAX_ANIMATED_CLONES) {
      console.warn(`[Cloner] Animated model clone count limited to ${MAX_ANIMATED_CLONES} for performance (requested: ${positions.length})`);
    }
    
    // Create a group to hold all clones
    const clonerGroup = new THREE.Group();
    clonerGroup.name = 'AnimatedClonerGroup';
    clonerGroup.userData.isAnimatedCloner = true;
    clonerGroup.userData.cloneMixers = [];
    
    // Calculate scale factor to match target clone radius
    const boundingBox = new THREE.Box3().setFromObject(modelScene);
    const size = boundingBox.getSize(new THREE.Vector3());
    const maxDim = Math.max(size.x, size.y, size.z);
    const baseScale = (cloneRadius * 2) / maxDim;
    
    console.log(`[Cloner] Creating ${count} animated clones...`);
    
    for (let i = 0; i < count; i++) {
      // Clone the model (SkeletonUtils.clone handles SkinnedMesh properly)
      const clone = SkeletonUtils.clone(modelScene);
      
      // Apply position from distribution
      const pos = positions[i];
      clone.position.set(pos.x, pos.y, pos.z);
      
      // Apply scale with variation
      const scaleVariation = 0.5 + Math.random() * sizeVariation + 0.5;
      const finalScale = baseScale * scaleVariation;
      clone.scale.setScalar(finalScale);
      
      // Random rotation for variety
      clone.rotation.y = Math.random() * Math.PI * 2;
      
      // Create animation mixer for this clone
      const mixer = new THREE.AnimationMixer(clone);
      
      // Play all animations with time offset for variety
      const timeOffset = (i / count) * (animations[0]?.duration || 1);
      animations.forEach(clip => {
        const action = mixer.clipAction(clip);
        action.play();
        // Offset the animation time so clones aren't in sync
        action.time = timeOffset;
      });
      
      // Start paused if timeline is not playing
      const isPlaying = silentPlaying;
      if (!isPlaying) {
        mixer.timeScale = 0;
      }
      
      // Store mixer for updates
      clonerGroup.userData.cloneMixers.push(mixer);
      animationMixers.set(clone, mixer);
      
      // Enable shadows
      clone.traverse(child => {
        if (child.isMesh) {
          child.castShadow = true;
          child.receiveShadow = true;
        }
      });
      
      clone.userData.clonerIndex = i;
      clonerGroup.add(clone);
    }
    
    scene.add(clonerGroup);
    console.log(`[Cloner] Created ${count} animated clones with individual AnimationMixers`);
    
    return clonerGroup;
  }
  
  // Generate distribution positions for cloner
  
  // Helper function to load custom distribution surface
  async function loadCustomDistributionSurface(modelPath, baseRadius, count, mesh) {
    const ext = modelPath.split('.').pop().toLowerCase();
    let surfaceGeo = null;
    
    try {
      if (ext === 'glb' || ext === 'gltf') {
        const gltf = await new Promise((resolve, reject) => {
          const loader = new GLTFLoader();
          loader.load('/' + modelPath, resolve, undefined, reject);
        });
        gltf.scene.traverse(child => {
          if (!surfaceGeo && child.isMesh && child.geometry) {
            surfaceGeo = child.geometry.clone();
          }
        });
      } else if (ext === 'obj') {
        const obj = await new Promise((resolve, reject) => {
          const loader = new OBJLoader();
          loader.load('/' + modelPath, resolve, undefined, reject);
        });
        obj.traverse(child => {
          if (!surfaceGeo && child.isMesh && child.geometry) {
            surfaceGeo = child.geometry.clone();
          }
        });
      }
      
      if (surfaceGeo) {
        // Center and scale to base radius
        surfaceGeo.computeBoundingSphere();
        const currentRadius = surfaceGeo.boundingSphere.radius || 1;
        const scale = baseRadius / currentRadius;
        surfaceGeo.scale(scale, scale, scale);
        surfaceGeo.center();
        
        // Get positions from geometry
        const posAttr = surfaceGeo.getAttribute('position');
        const normalAttr = surfaceGeo.getAttribute('normal');
        
        if (!posAttr) return null;
        
        // Sample points from the surface
        const positions = new Float32Array(count * 3);
        const normals = new Float32Array(count * 3);
        const vertCount = posAttr.count;
        
        // If indexed geometry, use face distribution
        const indices = surfaceGeo.index;
        
        for (let i = 0; i < count; i++) {
          let px, py, pz, nx = 0, ny = 1, nz = 0;
          
          if (indices && indices.count > 0) {
            // Pick random face and barycentric coordinate
            const faceIdx = Math.floor(Math.random() * (indices.count / 3)) * 3;
            const i0 = indices.getX(faceIdx);
            const i1 = indices.getX(faceIdx + 1);
            const i2 = indices.getX(faceIdx + 2);
            
            // Random barycentric coordinates
            let u = Math.random(), v = Math.random();
            if (u + v > 1) { u = 1 - u; v = 1 - v; }
            const w = 1 - u - v;
            
            px = posAttr.getX(i0) * u + posAttr.getX(i1) * v + posAttr.getX(i2) * w;
            py = posAttr.getY(i0) * u + posAttr.getY(i1) * v + posAttr.getY(i2) * w;
            pz = posAttr.getZ(i0) * u + posAttr.getZ(i1) * v + posAttr.getZ(i2) * w;
            
            if (normalAttr) {
              nx = normalAttr.getX(i0) * u + normalAttr.getX(i1) * v + normalAttr.getX(i2) * w;
              ny = normalAttr.getY(i0) * u + normalAttr.getY(i1) * v + normalAttr.getY(i2) * w;
              nz = normalAttr.getZ(i0) * u + normalAttr.getZ(i1) * v + normalAttr.getZ(i2) * w;
              const len = Math.sqrt(nx*nx + ny*ny + nz*nz) || 1;
              nx /= len; ny /= len; nz /= len;
            }
          } else {
            // Non-indexed: pick random vertex
            const idx = Math.floor(Math.random() * vertCount);
            px = posAttr.getX(idx);
            py = posAttr.getY(idx);
            pz = posAttr.getZ(idx);
            if (normalAttr) {
              nx = normalAttr.getX(idx);
              ny = normalAttr.getY(idx);
              nz = normalAttr.getZ(idx);
            }
          }
          
          positions[i * 3] = px;
          positions[i * 3 + 1] = py;
          positions[i * 3 + 2] = pz;
          normals[i * 3] = nx;
          normals[i * 3 + 1] = ny;
          normals[i * 3 + 2] = nz;
        }
        
        surfaceGeo.dispose();
        return { positions, normals };
      }
    } catch (err) {
      console.error('[Cloner] Error loading custom distribution surface:', err);
    }
    return null;
  }

  // =========================
  // PARTICLE SYSTEM
  // =========================
  function createParticleSystem(config, scene) {
    return createParticleSystemModule(THREE, config, scene);
  }

  // =========================
  // DISPLACED SPHERE
  // =========================
  // High-poly sphere with procedural vertex displacement
  function createDisplacedSphere(config, scene) {
    return createDisplacedSphereSystem(THREE, config, scene);
  }

  // =========================
  // FRACTAL TREE (IFS-based recursive tree)
  // =========================
  function createFractalTree(config, scene) {
    return createFractalTreeSystem(THREE, config, scene);
  }

  // =========================
  // SIERPINSKI PYRAMID (IFS fractal tetrahedron)
  // =========================
  function createSierpinskiPyramid(config, scene) {
    return createSierpinskiPyramidSystem(THREE, config, scene);
  }

  // =========================
  // MENGER SPONGE (IFS fractal cube)
  // =========================
  function createMengerSponge(config, scene) {
    return createMengerSpongeSystem(THREE, config, scene);
  }

  // =========================
  // CORAL GROWTH (IFS organic branching)
  // =========================
  function createCoralGrowth(config, scene) {
    return createCoralGrowthSystem(THREE, config, scene);
  }

  // =========================
  // CRYSTAL LATTICE (IFS geometric structure)
  // =========================
  function createCrystalLattice(config, scene) {
    return createCrystalLatticeSystem(THREE, config, scene);
  }

  async function loadAllScenes() {
    loadedScenes = [];
    
    // Get scene segments from the project
    const sceneSegments = getSceneSegments();
    
    // Build a map of which scenes need to be loaded and their sources
    // Key: sceneIndex, Value: { file: string | null, fromStorage: boolean }
    const scenesToLoad = new Map();
    
    // Check scene segments in timeline tracks - these may have custom sceneFile paths
    for (const seg of sceneSegments) {
      if (seg.sceneIndex !== undefined && seg.sceneIndex >= 0) {
        // If segment has a custom sceneFile, use that
        if (seg.sceneFile) {
          scenesToLoad.set(seg.sceneIndex, { file: seg.sceneFile, fromStorage: false });
        } else if (!scenesToLoad.has(seg.sceneIndex)) {
          // Fall back to default SCENE_FILES if no custom file
          const defaultFile = SCENE_FILES[seg.sceneIndex];
          scenesToLoad.set(seg.sceneIndex, { file: defaultFile || null, fromStorage: false });
        }
      }
    }
    
    // Check localStorage for any scenes with saved data (only for default scene indices)
    for (let i = 0; i < SCENE_FILES.length; i++) {
      const key = `demotool_scene_scene${String(i).padStart(2, '0')}`;
      if (localStorage.getItem(key) && !scenesToLoad.has(i)) {
        scenesToLoad.set(i, { file: SCENE_FILES[i], fromStorage: true });
      }
    }
    
    // If no scenes are used, create one empty scene
    if (scenesToLoad.size === 0) {
      console.log('[SCENES] No scenes to load, creating empty default scene');
      const emptyScene = await createThreeScene({
        background: '#111',
        camera: { position: { x: 0, y: 5, z: 10 } },
        lights: [{ type: 'ambient', intensity: 0.5 }],
        objects: []
      });
      loadedScenes.push(emptyScene);
      return;
    }
    
    // Find the max scene index to determine array size
    const maxIndex = Math.max(...scenesToLoad.keys());
    console.log('[SCENES] Loading scenes:', Array.from(scenesToLoad.entries()));
    
    // Initialize loadedScenes array with nulls
    for (let i = 0; i <= maxIndex; i++) {
      loadedScenes.push(null);
    }
    
    // Load each scene that's actually used
    let loadedCount = 0;
    const totalToLoad = scenesToLoad.size;
    
    for (const [sceneIdx, { file, fromStorage }] of scenesToLoad) {
      loadedCount++;
      const displayName = file ? file.split('/').pop() : `scene ${sceneIdx}`;
      setProgress(0.1 + 0.3 * (loadedCount / totalToLoad), `Loading ${displayName}...`);
      
      try {
        let data;
        
        if (file) {
          // Load from file (prepend / if needed)
          const fetchPath = file.startsWith('/') ? file : '/' + file;
          data = await loadJSON(fetchPath);
          data._sourceFile = file;
        } else {
          // No file specified, create empty scene
          data = {
            background: '#111',
            camera: { position: { x: 0, y: 5, z: 10 } },
            lights: [{ type: 'ambient', intensity: 0.5 }],
            objects: []
          };
        }
        
        // Check for localStorage overrides using the actual scene file name
        // (loadSceneFromStorage can't be used here because the scene isn't loaded yet)
        if (file) {
          const filename = file.split('/').pop().replace('.json', '');
          const storageKey = STORAGE_PREFIX + filename;
          try {
            const stored = localStorage.getItem(storageKey);
            if (stored) {
              const wrapped = JSON.parse(stored);
              // Check version compatibility
              if (wrapped._version === STORAGE_VERSION) {
                const storedData = wrapped.data || wrapped;
                console.log(`[SCENES] Applying localStorage overrides for ${filename}`);
                data = deepMerge(data, storedData);
              } else {
                console.warn(`[SCENES] Version mismatch for ${filename}, ignoring stored data`);
                localStorage.removeItem(storageKey);
              }
            }
          } catch (err) {
            console.warn(`[SCENES] Failed to parse localStorage for ${filename}:`, err);
          }
        } else {
          // For legacy scene indices without a file, try the old method
        const storedData = loadSceneFromStorage(sceneIdx);
        if (storedData) {
          console.log(`[SCENES] Applying localStorage overrides for scene ${sceneIdx}`);
          data = deepMerge(data, storedData);
          }
        }
        
        const sceneObj = await createThreeScene(data);
        loadedScenes[sceneIdx] = sceneObj;
        console.log(`[SCENES] Loaded scene ${sceneIdx} from ${file || 'empty'}`);
      } catch (err) {
        console.warn(`[SCENES] Failed to load scene ${sceneIdx} from ${file}:`, err);
        // Create empty fallback scene
        loadedScenes[sceneIdx] = await createThreeScene({
          background: '#111',
          camera: { position: { x: 0, y: 5, z: 10 } },
          lights: [{ type: 'ambient', intensity: 0.5 }],
          objects: []
        });
      }
    }
  }
  
  // Deep merge helper for combining base JSON with localStorage overrides
  // Only overrides properties that exist in the target (defensive merge)
  function deepMerge(target, source, depth = 0) {
    if (depth > 10) return target; // Prevent infinite recursion
    if (!source || typeof source !== 'object') return target;
    if (!target || typeof target !== 'object') return target;
    
    const output = Array.isArray(target) ? [...target] : { ...target };
    
    for (const key in source) {
      // Skip internal metadata keys
      if (key.startsWith('_')) continue;
      
      const sourceVal = source[key];
      const targetVal = target[key];
      
      // Only merge if the key exists in target (prevents adding obsolete params)
      // Exception: allow new values for primitive properties (numbers, strings, colors)
      // Exception: allow certain objects that may be added during editing
      const targetHasKey = key in target;
      const isSimpleValue = typeof sourceVal !== 'object' || sourceVal === null;
      // Allow these object keys to be added even if they don't exist in target:
      // - material, color, colors, emissive, light: material/lighting properties
      // - rotation, position, scale: transform properties that may not exist in original JSON
      // - camera, animation: camera properties that may be added during editing
      const isAllowedKey = ['material', 'color', 'colors', 'emissive', 'light', 'rotation', 'position', 'scale', 'camera', 'animation'].includes(key);
      
      if (!targetHasKey && !isSimpleValue && !isAllowedKey) {
        // Skip complex structures that don't exist in target (unless it's an allowed key)
        continue;
      }
      
      if (sourceVal && typeof sourceVal === 'object' && !Array.isArray(sourceVal)) {
        if (targetVal && typeof targetVal === 'object' && !Array.isArray(targetVal)) {
          // Both are objects - recurse
          output[key] = deepMerge(targetVal, sourceVal, depth + 1);
        } else if (targetHasKey) {
          // Type mismatch handling
          // Special case: scale/position/rotation can be saved as object but original might be number/array
          if (key === 'scale' && typeof targetVal === 'number' && 
              sourceVal.x !== undefined && sourceVal.y !== undefined && sourceVal.z !== undefined) {
            // Source is {x,y,z} object, target is number - use source values as object (more precise)
            output[key] = sourceVal;
          } else if ((key === 'position' || key === 'rotation' || key === 'scale') && 
                     Array.isArray(targetVal) && sourceVal.x !== undefined) {
            // Source is {x,y,z} object, target is array - convert to array format
            output[key] = [sourceVal.x, sourceVal.y, sourceVal.z];
          } else if (key === 'material' && typeof targetVal === 'string') {
            // Material was a string type (e.g., "standard") but saved as object with all properties
            // Use the saved object which contains color, textures, etc.
            output[key] = sourceVal;
          } else {
            console.warn(`Type mismatch for ${key}: target is ${typeof targetVal}, source is object. Keeping target.`);
          }
        } else {
          // Key doesn't exist in target but source has an object - add it
          // This handles cases like adding a material object where none existed
          output[key] = sourceVal;
        }
      } else if (Array.isArray(sourceVal)) {
        if (Array.isArray(targetVal)) {
          // For arrays, only merge if same length (e.g., lights array)
          if (sourceVal.length === targetVal.length) {
            output[key] = targetVal.map((item, idx) => 
              typeof item === 'object' ? deepMerge(item, sourceVal[idx], depth + 1) : sourceVal[idx]
            );
          } else {
            // Use source array when lengths differ - the saved state should be authoritative
            output[key] = sourceVal;
          }
        } else {
          // Target is not an array but source is - use source
          output[key] = sourceVal;
        }
      } else if (sourceVal !== undefined) {
        // Simple value - override if types are compatible
        if (targetHasKey) {
          const targetType = typeof targetVal;
          const sourceType = typeof sourceVal;
          if (targetType === sourceType || targetVal === undefined || targetVal === null) {
            output[key] = sourceVal;
          } else {
            console.warn(`Type mismatch for ${key}: target is ${targetType}, source is ${sourceType}. Keeping target.`);
          }
        } else {
          // New simple property - allow it
          output[key] = sourceVal;
        }
      }
    }
    return output;
  }

  // =========================
  // AUDIO
  // =========================
  console.log('[Editor] Initializing audio...');
  const AudioCtx = window.AudioContext || window.webkitAudioContext;
  let audioCtx;
  try {
    audioCtx = new AudioCtx();
    console.log('[Editor] AudioContext created, state:', audioCtx.state);
  } catch (e) {
    console.error('[Editor] AudioContext failed:', e);
    // Create a dummy context that won't break things
    audioCtx = { state: 'suspended', resume: () => Promise.resolve() };
  }

  const audioEl = new Audio();
  audioEl.crossOrigin = "anonymous";
  audioEl.preload = "auto";
  console.log('[Editor] Audio element created');

  // Flag to track if audio is fully loaded for seeking
  let audioFullyLoaded = false;

  // Load audio as blob to enable seeking (workaround for servers without byte-range support)
  async function loadAudioAsBlob() {
    // Handle no audio case
    if (!AUDIO_URL || !currentAudioFile) {
      console.log("No audio file set");
      audioFullyLoaded = true;
      return;
    }
    
    // Use the correct path (currentAudioFile may already have the correct path)
    // Avoid double slashes if path already starts with /
    const basePath = currentAudioFile || AUDIO_URL;
    const audioUrl = basePath.startsWith('/') ? basePath : '/' + basePath;
    
    try {
      console.log("Loading audio file as blob for seeking support...", audioUrl);
      const response = await fetch(audioUrl);
      if (!response.ok) {
        throw new Error(`HTTP ${response.status}: ${response.statusText}`);
      }
      const blob = await response.blob();
      const blobUrl = URL.createObjectURL(blob);
      audioEl.src = blobUrl;
      audioFullyLoaded = true;
      console.log("Audio loaded as blob, seeking should now work");
    } catch (err) {
      console.warn("Failed to load audio as blob, falling back to direct URL:", err);
      audioEl.src = audioUrl;
      audioFullyLoaded = true; // Mark as loaded so waiting loop doesn't timeout
    }
  }
  
  // Audio loading will be called after project data is loaded
  // loadAudioAsBlob() - called from start()

  let srcNode = null;
  let analyser = null;
  let gain = null;
  let audioGraphConnected = false;
  
  // Create audio nodes lazily to avoid issues with suspended AudioContext
  function ensureAudioNodes() {
    if (analyser && gain) return true;
    try {
      if (!analyser) {
        analyser = audioCtx.createAnalyser();
        analyser.fftSize = 2048;
      }
      if (!gain) {
        gain = audioCtx.createGain();
      }
      if (!audioGraphConnected) {
        analyser.connect(gain);
        gain.connect(audioCtx.destination);
        audioGraphConnected = true;
      }
      return true;
    } catch (err) {
      console.warn("Error creating audio nodes:", err);
      return false;
    }
  }

  // Connect audio nodes after audio element is ready
  function connectAudioNodes() {
    if (srcNode) return; // Already connected
    if (!ensureAudioNodes()) return; // Make sure nodes exist
    try {
      srcNode = audioCtx.createMediaElementSource(audioEl);
      srcNode.connect(analyser);
      console.log("Audio nodes connected");
    } catch (err) {
      console.warn("Error connecting audio nodes:", err);
    }
  }
  
  audioEl.addEventListener('loadedmetadata', connectAudioNodes);
  audioEl.addEventListener('canplay', connectAudioNodes);

  const clipSourceNodes = new WeakMap();
  const audioClipSources = createSeekableAudioSourceCache();
  function connectAudioClipNodes(element) {
    if (clipSourceNodes.has(element) || !ensureAudioNodes()) return;
    try {
      const source = audioCtx.createMediaElementSource(element);
      source.connect(analyser);
      clipSourceNodes.set(element, source);
    } catch (error) {
      console.warn('[Audio clips] Could not connect clip to mix bus:', error);
    }
  }

  audioClipPlayback = createAudioClipPlayback({
    createAudio: () => new Audio(),
    onCreate: connectAudioClipNodes,
    resolveSource: isPlayMode ? source => source : audioClipSources.resolve,
  });

  // Freq array created lazily when analyser is available
  let freq = null;

  function measureAudio() {
    // Return zeros if audio analysis isn't available
    if (!analyser) {
      return { bass: 0, mid: 0, treble: 0 };
    }
    
    // Create freq array on first use
    if (!freq) {
      freq = new Uint8Array(analyser.frequencyBinCount);
    }
    
    analyser.getByteFrequencyData(freq);
    let bass = 0, mid = 0, treble = 0;
    const binHz = (audioCtx.sampleRate / 2) / freq.length;
    let bn = 0, mn = 0, tn = 0;
    for (let i = 0; i < freq.length; i++) {
      const hz = i * binHz;
      const v = freq[i] / 255;
      if (hz < 150) { bass += v; bn++; }
      else if (hz < 2000) { mid += v; mn++; }
      else { treble += v; tn++; }
    }
    return {
      bass: bn ? clamp(bass / bn * 1.6, 0, 1) : 0,
      mid: mn ? clamp(mid / mn * 1.3, 0, 1) : 0,
      treble: tn ? clamp(treble / tn * 1.2, 0, 1) : 0,
    };
  }

  // =========================
  // WAVEFORM
  // =========================
  let wavePeaks = null;        // Low-res overview (12K bins)
  let waveRawSamples = null;   // Raw audio samples for high-res drawing
  let waveSampleRate = 44100;  // Sample rate of the audio

  function setProgress(pct, msg) {
    const progressRow = document.getElementById('progressRow');
    if (pct > 0) {
      progressRow.classList.add('visible');
    }
    progressFill.style.width = `${clamp(pct, 0, 1) * 100}%`;
    progressText.textContent = msg || "";
    
    // If embedded in an iframe, notify parent of loading progress
    if (window.self !== window.top) {
      try {
        window.parent.postMessage({
          type: EDITOR_EVENTS.LOADING_PROGRESS,
          progress: pct,
          message: msg || ''
        }, playerMessageOrigin);
      } catch (e) {}
    }
  }

  async function buildWaveformPeaks(arrayBuffer) {
    setProgress(0.5, "Decoding audio...");
    const audioBuf = await audioCtx.decodeAudioData(arrayBuffer.slice(0));
    const waveform = buildWaveformData(audioBuf);
    waveRawSamples = waveform.samples;
    waveSampleRate = waveform.sampleRate;
    return { peaks: waveform.peaks, duration: waveform.duration };
  }
  
  // Compute peak for a time range (for high-res waveform)
  function getPeakForTimeRange(startTime, endTime) {
    if (!waveRawSamples) return 0;
    
    const startSample = Math.floor(startTime * waveSampleRate);
    const endSample = Math.ceil(endTime * waveSampleRate);
    
    let maxv = 0;
    const clampedStart = Math.max(0, startSample);
    const clampedEnd = Math.min(waveRawSamples.length, endSample);
    
    for (let i = clampedStart; i < clampedEnd; i++) {
      const v = Math.abs(waveRawSamples[i]);
      if (v > maxv) maxv = v;
    }
    return maxv;
  }
  
  // Export waveform as JSON (for pre-generation)
  window.exportWaveform = function() {
    if (!wavePeaks) {
      console.error("No waveform loaded yet");
      return;
    }
    const data = {
      peaks: Array.from(wavePeaks),
      duration: durationGuess
    };
    const blob = new Blob([JSON.stringify(data)], { type: "application/json" });
    const a = document.createElement("a");
    a.href = URL.createObjectURL(blob);
    a.download = "waveform.json";
    a.click();
    console.log("Waveform exported! Place the file in the music/ folder.");
  };

  // =========================
  // RESIZE
  // =========================
  function resizeRenderer() {
    // Get container dimensions
    // In play mode, use the actual window/iframe dimensions for more reliable sizing
    let containerW, containerH;
    
    if (isPlayMode) {
      // Use window inner dimensions - works better in iframes
      containerW = window.innerWidth;
      containerH = window.innerHeight;
    } else {
      containerW = viewportWrap.clientWidth;
      containerH = viewportWrap.clientHeight;
    }
    
    // Calculate canvas size maintaining aspect ratio
    let canvasW, canvasH;
    const containerAspect = containerW / containerH;
    const targetAspect = getTargetAspectRatio();
    
    if (containerAspect > targetAspect) {
      // Container is wider than target - letterbox on sides
      canvasH = containerH;
      canvasW = Math.floor(containerH * targetAspect);
    } else {
      // Container is taller than target - letterbox on top/bottom
      canvasW = containerW;
      canvasH = Math.floor(containerW / targetAspect);
    }
    
    // Apply size to container and canvas
    canvasContainer.style.width = `${canvasW}px`;
    canvasContainer.style.height = `${canvasH}px`;
    threeCanvas.style.width = `${canvasW}px`;
    threeCanvas.style.height = `${canvasH}px`;
    
    // Size the image overlay canvas to match
    const dpr = Math.min(2, window.devicePixelRatio);
    imageOverlayCanvas.width = canvasW * dpr;
    imageOverlayCanvas.height = canvasH * dpr;
    imageOverlayCanvas.style.width = `${canvasW}px`;
    imageOverlayCanvas.style.height = `${canvasH}px`;
    imageOverlayCtx.setTransform(dpr, 0, 0, dpr, 0, 0);
    
    // CanvasTexture cannot resize, so keep its backing surface in sync.
    refreshMediaOverlayTexture();
    
    // Calculate side panel widths (fill the black letterbox areas)
    const sideSpace = (containerW - canvasW) / 2;
    if (sideSpace > 50) {
      // Enough space for panels
      objectListPanel.style.width = `${sideSpace}px`;
      objectListPanel.style.display = 'flex';
      paramPanel.style.width = `${sideSpace}px`;
      paramPanel.style.display = 'flex';
    } else {
      // Not enough space, hide panels
      objectListPanel.style.display = 'none';
      paramPanel.style.display = 'none';
    }
    
    renderer.setPixelRatio(dpr);
    renderer.setSize(canvasW, canvasH, false);

    // Update all cameras with correct aspect ratio
    const currentAspect = getTargetAspectRatio();
    for (const scene of loadedScenes) {
      if (scene && scene.camera && scene.camera.isPerspectiveCamera) {
        scene.camera.aspect = currentAspect;
        scene.camera.updateProjectionMatrix();
      }
    }
    
    // Resize post-fx targets
    resizePostFxTargets();
    
    // Resize scene render targets
    resizeSceneRenderTargets();
  }

  async function beginOfflineComposition(width, height) {
    const shouldResumeRenderLoop = running;
    running = false;
    // Allow the already-scheduled live frame to observe running=false before
    // the export starts mutating the shared composition surface.
    await new Promise(resolve => requestAnimationFrame(resolve));

    compositionSurfaceOverride = { width, height, dpr: 1 };
    renderer.setPixelRatio(1);
    renderer.setSize(width, height, false);
    imageOverlayCanvas.width = width;
    imageOverlayCanvas.height = height;
    imageOverlayCtx.setTransform(1, 0, 0, 1, 0, 0);
    refreshMediaOverlayTexture();
    resizePostFxTargets();
    resizeSceneRenderTargets();

    let restored = false;
    return () => {
      if (restored) return;
      restored = true;
      compositionSurfaceOverride = null;
      resizeRenderer();
      if (shouldResumeRenderLoop && !running) {
        running = true;
        lastTime = performance.now();
        requestAnimationFrame(render);
      }
    };
  }

  window.addEventListener("resize", () => {
    resizeRenderer();
    layoutTimeline();
    drawWaveAndRuler();
    renderTracks();
    if (editorMode === 'keyframe') renderKeyframeEditor();
  });

  // =========================
  // SPLITTER
  // =========================
  let splitDrag = null;

  splitter.addEventListener("pointerdown", (e) => {
    e.preventDefault();
    splitter.setPointerCapture(e.pointerId);
    splitDrag = { y0: e.clientY, vp0: viewportWrap.getBoundingClientRect().height };
    splitter.classList.add("dragging");
  });

  window.addEventListener("pointermove", (e) => {
    if (!splitDrag) return;
    const dy = e.clientY - splitDrag.y0;
    const h = clamp(splitDrag.vp0 + dy, 200, window.innerHeight - 300);
    viewportWrap.style.flex = `0 0 ${h}px`;
    requestAnimationFrame(() => {
      resizeRenderer();
      layoutTimeline();
      drawWaveAndRuler();
      renderTracks();
    });
  });

  window.addEventListener("pointerup", () => {
    if (splitDrag) {
      splitDrag = null;
      splitter.classList.remove("dragging");
    }
  });

  // =========================
  // TIMELINE LAYOUT
  // =========================
  const TRACK_HEADER_WIDTH = 188;
  let pxPerSec = 4;
  let zoom = 1.0;

  const timelineCanvasRenderer = createTimelineCanvasRenderer({
    viewport: timelineViewport,
    content: timelineContent,
    rulerCanvas,
    waveCanvas,
    zoomInput: zoomSlider,
    getDuration: () => durationGuess,
    getLoop: () => ({ enabled: loopEnabled, start: loopStart, end: loopEnd }),
    getWaveform: () => ({
      peaks: wavePeaks,
      rawSamples: waveRawSamples,
      duration: durationGuess,
      hasAudio: !!currentAudioFile,
    }),
    getPeakForTimeRange,
    formatTime: fmtTime,
    getDevicePixelRatio: () => window.devicePixelRatio,
    headerWidth: TRACK_HEADER_WIDTH,
    onMetrics: metrics => {
      pxPerSec = metrics.pixelsPerSecond;
      zoom = metrics.zoom;
    },
  });

  function layoutTimeline() {
    timelineCanvasRenderer.layout();
  }
  
  function timeToX(t) { return timelineCanvasRenderer.timeToX(t); }
  function xToTime(x) { return timelineCanvasRenderer.xToTime(x); }

  function visibleRange() {
    return timelineCanvasRenderer.visibleRange();
  }

  // =========================
  // DRAW RULER + WAVEFORM
  // =========================
  function drawRuler() {
    timelineCanvasRenderer.drawRuler();
  }

  function drawWave() {
    timelineCanvasRenderer.drawWave();
  }

  function updateTimelineLoopRange() {
    const tracksElement = document.getElementById('tracks');
    if (!tracksElement) return;
    const range = timelineLoopRangeStyle(
      { enabled: loopEnabled, start: loopStart, end: loopEnd },
      pxPerSec,
    );
    tracksElement.classList.toggle('loop-range-visible', range.visible);
    tracksElement.style.setProperty('--timeline-loop-left', `${range.left}px`);
    tracksElement.style.setProperty('--timeline-loop-width', `${range.width}px`);
  }

  function drawWaveAndRuler() {
    drawRuler();
    drawWave();
    updateTimelineLoopRange();
  }

  // =========================
  // KEYFRAME EDITOR RENDERING
  // =========================
  const keyframeViewport = document.getElementById('keyframeViewport');
  const keyframeContent = document.getElementById('keyframeContent');
  const keyframeRulerCanvas = document.getElementById('keyframeRulerCanvas');
  const keyframeWaveCanvas = document.getElementById('keyframeWaveCanvas');
  const keyframeWavePlayhead = document.getElementById('keyframeWavePlayhead');
  const keyframeTracks = document.getElementById('keyframeTracks');
  const keyframePlayhead = document.getElementById('keyframePlayhead');
  const keyframeEmpty = document.getElementById('keyframeEmpty');
  const kfSnapBtn = document.getElementById('kfSnapBtn');
  const kfGridSelect = document.getElementById('kfGridSelect');
  const keyframeValueTooltip = document.createElement('div');
  keyframeValueTooltip.className = 'keyframe-value-tooltip';
  keyframeValueTooltip.setAttribute('role', 'tooltip');
  keyframeValueTooltip.setAttribute('aria-hidden', 'true');
  document.body.appendChild(keyframeValueTooltip);
  
  let keyframeClipStart = 0;
  let keyframeClipDuration = 10;
  let keyframePxPerSec = 50;
  let kfSnapEnabled = false;
  let lastKeyframeHorizontalScroll = -1;

  function keyframeParamDisplayName(paramId) {
    return paramId
      .replace(/_/g, ' ')
      .replace(/^(cam|obj|grass|voxel|pfx|img|scene|light|audio)[ _]/i, '');
  }

  function hideKeyframeValueTooltip() {
    keyframeValueTooltip.classList.remove('visible');
    keyframeValueTooltip.setAttribute('aria-hidden', 'true');
  }

  function positionKeyframeValueTooltip(diamond) {
    const rect = diamond.getBoundingClientRect();
    keyframeValueTooltip.style.left = `${rect.left + rect.width / 2}px`;
    keyframeValueTooltip.style.top = `${rect.top - 8}px`;
  }
  
  // Snap keyframe time to BPM grid
  function snapKeyframeTime(t) {
    if (!kfSnapEnabled) return t;
    const bpm = parseFloat(bpmInput.value) || 129.2;
    const beat = 60.0 / bpm;
    const gridMul = parseFloat(kfGridSelect?.value) || 0.25;
    const step = beat * gridMul;
    return Math.round(t / step) * step;
  }
  
  function renderKeyframeEditor() {
    if (!keyframeTracks || !keyframeEmpty) return;
    hideKeyframeValueTooltip();
    
    if (!selectedSegment) {
      keyframeTracks.innerHTML = '';
      keyframeEmpty.style.display = 'block';
      keyframeEmpty.textContent = 'Please select a clip in Timeline mode first.';
      selectedTrack = null; // Clear track selection when no segment
      return;
    }
    
    const segId = getSegmentId(selectedSegment);
    const exposed = exposedParams[segId];
    
    if (!exposed || exposed.size === 0) {
      keyframeTracks.innerHTML = '';
      keyframeEmpty.style.display = 'block';
      keyframeEmpty.textContent = 'Please select some parameters from the Parameters menu using the K button.';
      selectedTrack = null; // Clear track selection when no tracks
      return;
    }
    
    // Clear selection if the selected track no longer exists in exposed params
    if (selectedTrack && !exposed.has(selectedTrack)) {
      selectedTrack = null;
    }
    
    keyframeEmpty.style.display = 'none';
    
    const seg = getCurrentSegment();
    if (!seg) return;
    
    keyframeClipStart = seg.start;
    keyframeClipDuration = seg.end - seg.start;
    
    // Calculate pixels per second to fit clip in viewport, applying zoom
    const viewW = keyframeViewport.clientWidth - 100; // subtract label width
    const kfZoom = parseFloat(zoomSlider.value) || 1;
    keyframePxPerSec = (viewW / keyframeClipDuration) * kfZoom;
    
    // Build track HTML
    let html = '';
    for (const paramId of exposed) {
      const displayName = keyframeParamDisplayName(paramId);
      const curveType = getCurveType(segId, paramId);
      const lfo = lfoData[segId]?.[paramId];
      const lfoEnabled = lfo?.enabled || false;
      const laneHeight = normalizeKeyframeLaneHeight(
        exposedParamsMeta[segId]?.[paramId]?.laneHeight,
      );
      html += `<div class="keyframe-track" data-param="${paramId}" style="height:${laneHeight}px" role="option" tabindex="0" aria-selected="${selectedTrack === paramId}">
        <div class="keyframe-track-label" title="Click to select. Drag ≡ to reorder. Shift+wheel over the selected lane to resize.">
          <div class="keyframe-track-title-row">
            <span class="keyframe-track-drag-handle" draggable="true" title="Drag to reorder automation lane" aria-label="Drag ${displayName} lane to reorder">≡</span>
            <span class="keyframe-track-name">${displayName}</span>
          </div>
          <div class="curve-type-buttons">
            <button class="curve-type-btn${curveType === 'linear' ? ' active' : ''}" data-param="${paramId}" data-curve="linear" title="Linear">L</button>
            <button class="curve-type-btn${curveType === 'bezier' ? ' active' : ''}" data-param="${paramId}" data-curve="bezier" title="Bezier (smooth)">B</button>
            <button class="curve-type-btn${curveType === 'step' ? ' active' : ''}" data-param="${paramId}" data-curve="step" title="Step (on/off)">S</button>
            <button class="lfo-btn${lfoEnabled ? ' active' : ''}" data-param="${paramId}" title="LFO - Oscillate to BPM">LFO</button>
            <button class="track-delete-btn" data-param="${paramId}" title="Remove all keyframes for this parameter">🗑</button>
          </div>
          <div class="lfo-controls${lfoEnabled ? ' visible' : ''}" data-param="${paramId}">
            <select class="lfo-shape" data-param="${paramId}" title="Wave shape">
              <option value="sine"${lfo?.shape === 'sine' ? ' selected' : ''}>Sine</option>
              <option value="triangle"${lfo?.shape === 'triangle' ? ' selected' : ''}>Tri</option>
              <option value="square"${lfo?.shape === 'square' ? ' selected' : ''}>Sqr</option>
              <option value="sawtooth"${lfo?.shape === 'sawtooth' ? ' selected' : ''}>Saw</option>
              <option value="pulse"${lfo?.shape === 'pulse' ? ' selected' : ''}>Pulse</option>
            </select>
            <select class="lfo-freq" data-param="${paramId}" title="Frequency (beats)">
              <option value="8"${lfo?.frequency == 8 ? ' selected' : ''}>8</option>
              <option value="4"${lfo?.frequency == 4 ? ' selected' : ''}>4</option>
              <option value="2"${lfo?.frequency == 2 ? ' selected' : ''}>2</option>
              <option value="1"${lfo?.frequency == 1 || !lfo ? ' selected' : ''}>1</option>
              <option value="0.5"${lfo?.frequency == 0.5 ? ' selected' : ''}>1/2</option>
              <option value="0.25"${lfo?.frequency == 0.25 ? ' selected' : ''}>1/4</option>
              <option value="0.125"${lfo?.frequency == 0.125 ? ' selected' : ''}>1/8</option>
            </select>
            <div class="lfo-slider-wrap">
              <label>A</label>
              <input type="range" class="lfo-amp" data-param="${paramId}" min="0" max="1" step="0.05" value="${lfo?.amplitude ?? 0.5}" title="Amplitude">
            </div>
            <div class="lfo-slider-wrap">
              <label>φ</label>
              <input type="range" class="lfo-phase" data-param="${paramId}" min="0" max="1" step="0.05" value="${lfo?.phase ?? 0}" title="Phase offset">
            </div>
          </div>
        </div>
        <div class="keyframe-track-area">
          <canvas class="keyframe-track-canvas" data-param="${paramId}"></canvas>
        </div>
      </div>`;
    }
    keyframeTracks.innerHTML = html;
    
    // Restore track selection after rebuild
    if (selectedTrack) {
      const trackEl = keyframeTracks.querySelector(`.keyframe-track[data-param="${selectedTrack}"]`);
      if (trackEl) {
        trackEl.classList.add('selected');
      }
    }
    
    // Set content width for scrolling
    if (keyframeContent) {
      keyframeContent.style.width = `${100 + keyframeClipDuration * keyframePxPerSec}px`;
    }
    
    // Force layout reflow to ensure elements have dimensions
    void keyframeTracks.offsetHeight;
    
    // Draw immediately after layout
    drawKeyframeRuler();
    drawKeyframeWaveform();
    drawAllKeyframeTracks(segId);
    updateKeyframePlayhead();
  }
  
  function drawKeyframeRuler() {
    if (!keyframeRulerCanvas) return;
    if (keyframeViewport?.clientWidth) {
      keyframeRulerCanvas.style.width = `${keyframeViewport.clientWidth}px`;
    }
    drawKeyframeRulerCanvas({
      canvas: keyframeRulerCanvas,
      dpr: Math.min(2, window.devicePixelRatio),
      scroll: keyframeViewport?.scrollLeft || 0,
      pixelsPerSecond: keyframePxPerSec,
      clipStart: keyframeClipStart,
      clipDuration: keyframeClipDuration,
      formatTime: fmtTime,
    });
  }
  
  function drawKeyframeWaveform() {
    if (!keyframeWaveCanvas) return;
    if (keyframeViewport?.clientWidth) {
      keyframeWaveCanvas.style.width = `${keyframeViewport.clientWidth}px`;
    }
    drawKeyframeWaveformCanvas({
      canvas: keyframeWaveCanvas,
      dpr: Math.min(2, window.devicePixelRatio),
      scroll: keyframeViewport?.scrollLeft || 0,
      pixelsPerSecond: keyframePxPerSec,
      clipStart: keyframeClipStart,
      clipDuration: keyframeClipDuration,
      waveform: { peaks: wavePeaks, rawSamples: waveRawSamples, duration: durationGuess },
      getPeakForTimeRange,
    });
  }
  
  // Get slider range for a parameter (min/max from stored metadata or slider inputs)
  function getParamRange(paramId, segmentId = null) {
    const slider = document.getElementById(paramId);
    const sliderStep = slider ? parseFloat(slider.step) : NaN;

    // First, try to get from stored metadata (for keyframe persistence)
    const segId = segmentId || (selectedSegment ? getSegmentId(selectedSegment) : null);
    if (segId) {
      const meta = exposedParamsMeta[segId]?.[paramId];
      if (meta && meta.min !== undefined && meta.max !== undefined) {
        const metaStep = parseFloat(meta.step);
        return {
          min: meta.min,
          max: meta.max,
          step: Number.isFinite(metaStep)
            ? metaStep
            : (Number.isFinite(sliderStep) ? sliderStep : undefined),
        };
      }
    }
    
    // Try to find the slider element
    if (slider) {
      const min = parseFloat(slider.min);
      const max = parseFloat(slider.max);
      if (!isNaN(min) && !isNaN(max)) {
        return {
          min,
          max,
          step: Number.isFinite(sliderStep) ? sliderStep : undefined,
        };
      }
    }
    // Fallback: return default range
    return { min: 0, max: 1, step: 0.01 };
  }

  function drawAllKeyframeTracks(segId) {
    const canvases = document.querySelectorAll('.keyframe-track-canvas');
    
    canvases.forEach(canvas => {
      const paramId = canvas.dataset.param;
      const ctx = canvas.getContext('2d');
      const rect = canvas.getBoundingClientRect();
      const dpr = Math.min(2, window.devicePixelRatio);
      
      const w = rect.width;
      const h = rect.height;
      
      // Skip if canvas has no dimensions yet (layout not complete)
      if (w <= 0 || h <= 0) return;
      
      canvas.width = w * dpr;
      canvas.height = h * dpr;
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
      
      const kfs = keyframeData[segId]?.[paramId] || [];
      
      // Get range from slider (not from keyframe values)
      const paramRange = getParamRange(paramId);
      const minV = paramRange.min;
      const maxV = paramRange.max;
      const range = maxV - minV || 1;
      const padding = 8;
      
      // Draw range labels on left side
      ctx.fillStyle = 'rgba(255,255,255,0.25)';
      ctx.font = '8px monospace';
      ctx.textAlign = 'left';
      ctx.fillText(maxV.toFixed(1), 2, padding + 6);
      ctx.fillText(minV.toFixed(1), 2, h - padding + 2);
      
      // Draw horizontal grid lines (value range)
      ctx.strokeStyle = 'rgba(255,255,255,0.06)';
      ctx.beginPath();
      ctx.moveTo(0, h/2);
      ctx.lineTo(w, h/2);
      ctx.stroke();
      
      // Draw 25% and 75% lines
      ctx.strokeStyle = 'rgba(255,255,255,0.03)';
      ctx.beginPath();
      ctx.moveTo(0, h * 0.25);
      ctx.lineTo(w, h * 0.25);
      ctx.moveTo(0, h * 0.75);
      ctx.lineTo(w, h * 0.75);
      ctx.stroke();
      
      // Draw vertical BPM grid lines when snap is enabled
      if (kfSnapEnabled) {
        const bpm = parseFloat(bpmInput.value) || 129.2;
        const beatDuration = 60.0 / bpm;
        const gridMul = parseFloat(kfGridSelect?.value) || 0.25;
        const stepDuration = beatDuration * gridMul;
        
        ctx.strokeStyle = 'rgba(120,180,255,0.12)';
        ctx.beginPath();
        for (let t = 0; t <= keyframeClipDuration; t += stepDuration) {
          const x = (t / keyframeClipDuration) * w;
          ctx.moveTo(x, 0);
          ctx.lineTo(x, h);
        }
        ctx.stroke();
      }
      
      const lfo = lfoData[segId]?.[paramId];
      const hasLfo = lfo?.enabled;
      
      if (kfs.length === 0 && !hasLfo) {
        // Draw "no keyframes" hint
        ctx.fillStyle = 'rgba(255,255,255,0.2)';
        ctx.font = '10px monospace';
        ctx.textAlign = 'left';
        ctx.fillText('Double-click to add keyframe', 30, h/2 + 4);
        return;
      }
      
      // Draw keyframe interpolated curve (if keyframes exist)
      if (kfs.length > 0) {
        ctx.strokeStyle = hasLfo ? 'rgba(120,180,255,0.3)' : 'rgba(120,180,255,0.6)';
      ctx.lineWidth = 1.5;
      ctx.beginPath();
      
      let firstPoint = true;
      for (let x = 0; x < w; x++) {
        const t = (x / w) * keyframeClipDuration;
        const v = interpolateKeyframes(segId, paramId, t);
        if (v === null) continue;
        
        // Clamp value to range for display
        const clampedV = Math.max(minV, Math.min(maxV, v));
        const y = h - padding - ((clampedV - minV) / range) * (h - padding * 2);
        if (firstPoint) {
          ctx.moveTo(x, y);
          firstPoint = false;
        } else {
          ctx.lineTo(x, y);
        }
      }
      ctx.stroke();
      ctx.lineWidth = 1;
      }
      
      // Draw LFO curve (if enabled)
      if (hasLfo) {
        ctx.strokeStyle = 'rgba(255,180,50,0.8)';
        ctx.lineWidth = 2;
        ctx.beginPath();
        
        let firstPoint = true;
        for (let x = 0; x < w; x++) {
          const t = (x / w) * keyframeClipDuration;
          const v = getInterpolatedValue(segId, paramId, t, paramRange);
          if (v === null) continue;
          
          // Clamp value to range for display
          const clampedV = Math.max(minV, Math.min(maxV, v));
          const y = h - padding - ((clampedV - minV) / range) * (h - padding * 2);
          if (firstPoint) {
            ctx.moveTo(x, y);
            firstPoint = false;
          } else {
            ctx.lineTo(x, y);
          }
        }
        ctx.stroke();
        ctx.lineWidth = 1;
      }
      
      // Draw keyframe diamonds
      const trackArea = canvas.parentElement;
      // Remove old diamonds
      trackArea.querySelectorAll('.keyframe-diamond').forEach(d => d.remove());
      
      kfs.forEach((kf, idx) => {
        const x = (kf.time / keyframeClipDuration) * w;
        // Clamp value to range for display
        const clampedV = Math.max(minV, Math.min(maxV, kf.value));
        const y = h - padding - ((clampedV - minV) / range) * (h - padding * 2);
        
        const diamond = document.createElement('div');
        diamond.className = 'keyframe-diamond';
        diamond.dataset.param = paramId;
        diamond.dataset.index = idx;
        diamond.dataset.valueLabel = formatKeyframeValue(kf.value, paramRange);
        diamond.style.left = `${x}px`;
        diamond.style.top = `${y}px`;
        diamond.setAttribute(
          'aria-label',
          `${keyframeParamDisplayName(paramId)}: ${diamond.dataset.valueLabel} at ${fmtTime(keyframeClipStart + kf.time)}`,
        );
        trackArea.appendChild(diamond);
      });
    });
  }
  
  // Draw a single track's curve only (for live preview during drag)
  function drawSingleKeyframeTrack(canvas, segId, paramId) {
    const ctx = canvas.getContext('2d');
    const rect = canvas.getBoundingClientRect();
    const dpr = Math.min(2, window.devicePixelRatio);
    
    const w = rect.width;
    const h = rect.height;
    if (w <= 0 || h <= 0) return;
    
    canvas.width = w * dpr;
    canvas.height = h * dpr;
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    
    const kfs = keyframeData[segId]?.[paramId] || [];
    
    // Get range from slider
    const paramRange = getParamRange(paramId);
    const minV = paramRange.min;
    const maxV = paramRange.max;
    const range = maxV - minV || 1;
    const padding = 8;
    
    // Draw range labels
    ctx.fillStyle = 'rgba(255,255,255,0.25)';
    ctx.font = '8px monospace';
    ctx.textAlign = 'left';
    ctx.fillText(maxV.toFixed(1), 2, padding + 6);
    ctx.fillText(minV.toFixed(1), 2, h - padding + 2);
    
    // Draw horizontal grid lines (value range)
    ctx.strokeStyle = 'rgba(255,255,255,0.06)';
    ctx.beginPath();
    ctx.moveTo(0, h/2);
    ctx.lineTo(w, h/2);
    ctx.stroke();
    
    ctx.strokeStyle = 'rgba(255,255,255,0.03)';
    ctx.beginPath();
    ctx.moveTo(0, h * 0.25);
    ctx.lineTo(w, h * 0.25);
    ctx.moveTo(0, h * 0.75);
    ctx.lineTo(w, h * 0.75);
    ctx.stroke();
    
    // Draw vertical BPM grid lines when snap is enabled
    if (kfSnapEnabled) {
      const bpm = parseFloat(bpmInput.value) || 129.2;
      const beatDuration = 60.0 / bpm;
      const gridMul = parseFloat(kfGridSelect?.value) || 0.25;
      const stepDuration = beatDuration * gridMul;
      
      ctx.strokeStyle = 'rgba(120,180,255,0.12)';
      ctx.beginPath();
      for (let t = 0; t <= keyframeClipDuration; t += stepDuration) {
        const x = (t / keyframeClipDuration) * w;
        ctx.moveTo(x, 0);
        ctx.lineTo(x, h);
      }
      ctx.stroke();
    }
    
    const lfo = lfoData[segId]?.[paramId];
    const hasLfo = lfo?.enabled;
    
    if (kfs.length === 0 && !hasLfo) return;
    
    // Draw keyframe interpolated curve
    if (kfs.length > 0) {
      ctx.strokeStyle = hasLfo ? 'rgba(120,180,255,0.3)' : 'rgba(120,180,255,0.6)';
    ctx.lineWidth = 1.5;
    ctx.beginPath();
    
    let firstPoint = true;
    for (let x = 0; x < w; x++) {
      const t = (x / w) * keyframeClipDuration;
      const v = interpolateKeyframes(segId, paramId, t);
      if (v === null) continue;
      
      const clampedV = Math.max(minV, Math.min(maxV, v));
      const y = h - padding - ((clampedV - minV) / range) * (h - padding * 2);
      if (firstPoint) {
        ctx.moveTo(x, y);
        firstPoint = false;
      } else {
        ctx.lineTo(x, y);
      }
    }
    ctx.stroke();
    ctx.lineWidth = 1;
    }
    
    // Draw LFO curve (if enabled)
    if (hasLfo) {
      ctx.strokeStyle = 'rgba(255,180,50,0.8)';
      ctx.lineWidth = 2;
      ctx.beginPath();
      
      let firstPoint = true;
      for (let x = 0; x < w; x++) {
        const t = (x / w) * keyframeClipDuration;
        const v = getInterpolatedValue(segId, paramId, t, paramRange);
        if (v === null) continue;
        
        const clampedV = Math.max(minV, Math.min(maxV, v));
        const y = h - padding - ((clampedV - minV) / range) * (h - padding * 2);
        if (firstPoint) {
          ctx.moveTo(x, y);
          firstPoint = false;
        } else {
          ctx.lineTo(x, y);
        }
      }
      ctx.stroke();
      ctx.lineWidth = 1;
    }
  }
  
  function updateKeyframePlayhead() {
    if (!keyframePlayhead || editorMode !== 'keyframe') return;
    
    const seg = getCurrentSegment();
    if (!seg) {
      keyframePlayhead.style.display = 'none';
      if (keyframeWavePlayhead) keyframeWavePlayhead.style.display = 'none';
      return;
    }
    
    const currentTime = getCurrentTime();
    const localTime = currentTime - seg.start;
    if (localTime < 0 || localTime > keyframeClipDuration) {
      keyframePlayhead.style.display = 'none';
      if (keyframeWavePlayhead) keyframeWavePlayhead.style.display = 'none';
      return;
    }
    
    const scroll = keyframeViewport?.scrollLeft || 0;
    const x = 100 + localTime * keyframePxPerSec - scroll;
    
    // Update tracks playhead
    keyframePlayhead.style.display = 'block';
    keyframePlayhead.style.left = `${x}px`;
    
    // Update waveform playhead
    if (keyframeWavePlayhead) {
      keyframeWavePlayhead.style.display = 'block';
      keyframeWavePlayhead.style.left = `${x}px`;
    }
  }
  
  // Handle keyframe viewport scroll
  if (keyframeViewport) {
    keyframeViewport.addEventListener('scroll', () => {
      const scrollLeft = keyframeViewport.scrollLeft;
      if (scrollLeft !== lastKeyframeHorizontalScroll) {
        lastKeyframeHorizontalScroll = scrollLeft;
        drawKeyframeRuler();
        drawKeyframeWaveform();
        updateKeyframePlayhead();
      }
    });
    
    // Click outside tracks deselects the track
    keyframeViewport.addEventListener('click', (e) => {
      // If clicking on a track or its children, don't deselect
      if (e.target.closest('.keyframe-track')) return;
      // Deselect track when clicking on ruler, waveform, or empty area
      deselectTrack();
    });
  }

  timelineViewport.addEventListener("scroll", () => {
    drawWaveAndRuler();
    scheduleVisibleAudioWaveformRender();
    updatePlayhead();
  });

  function updateZoomThumb() {
    if (!zoomThumb || !zoomSlider) return;
    const min = parseFloat(zoomSlider.min);
    const max = parseFloat(zoomSlider.max);
    const value = parseFloat(zoomSlider.value);
    const percent = (value - min) / (max - min);
    
    // Calculate position
    const sliderWidth = zoomSlider.offsetWidth || 120;
    const thumbWidth = 36;
    const offset = thumbWidth / 2;
    const position = offset + percent * (sliderWidth - thumbWidth);
    
    zoomThumb.style.left = position + 'px';
    zoomThumb.textContent = Math.round(value * 100) + '%';
  }
  
  // Initialize zoom thumb
  updateZoomThumb();

  function updateTimelineSegmentGeometry() {
    document.querySelectorAll('#dynamicTracks .seg').forEach(element => {
      const track = getTrackById(element.dataset.trackId);
      const segment = track?.segments[Number(element.dataset.index)];
      if (!segment) return;
      element.style.left = `${timeToX(segment.start)}px`;
      element.style.width = `${Math.max(1, timeToX(segment.end - segment.start))}px`;
    });
    updatePlayhead();
  }

  const timelineZoomRenderer = createTimelineZoomRenderer({
    requestFrame: callback => requestAnimationFrame(callback),
    cancelFrame: frameId => cancelAnimationFrame(frameId),
    onLive: () => {
      layoutTimeline();
      updateTimelineSegmentGeometry();
      drawWaveAndRuler();
      if (editorMode === 'keyframe') renderKeyframeEditor();
    },
    onCommit: () => {
      layoutTimeline();
      renderTracks();
      drawWaveAndRuler();
      if (editorMode === 'keyframe') renderKeyframeEditor();
    },
  });

  zoomSlider.addEventListener("input", () => {
    updateZoomThumb();
    timelineZoomRenderer.live();
  });
  zoomSlider.addEventListener("change", () => timelineZoomRenderer.commit());

  // =========================
  // DRAG-TO-ZOOM ON WAVEFORM/RULER
  // =========================
  // Click+drag up on waveform or ruler to zoom in, down to zoom out
  let timelineZoomDrag = null;
  
  function applyZoom(newValue) {
    const min = parseFloat(zoomSlider.min);
    const max = parseFloat(zoomSlider.max);
    zoomSlider.value = Math.max(min, Math.min(max, newValue));
    updateZoomThumb();
    timelineZoomRenderer.live();
  }
  
  function startZoomDrag(e) {
    timelineZoomDrag = {
      startY: e.clientY,
      startX: e.clientX,
      startZoom: parseFloat(zoomSlider.value),
      hasMoved: false,
      isZooming: false
    };
  }
  
  // Only attach to waveform and ruler canvases (use existing waveCanvas variable)
  document.getElementById('waveCanvas').addEventListener('mousedown', startZoomDrag);
  document.getElementById('rulerCanvas').addEventListener('mousedown', startZoomDrag);
  
  document.addEventListener('mousemove', (e) => {
    if (!timelineZoomDrag) return;
    
    const deltaY = timelineZoomDrag.startY - e.clientY; // Positive = dragged up
    const deltaX = Math.abs(e.clientX - timelineZoomDrag.startX);
    
    // Only start zooming if vertical movement is greater than horizontal
    // and we've moved at least 10px vertically
    if (!timelineZoomDrag.isZooming) {
      if (Math.abs(deltaY) > 10 && Math.abs(deltaY) > deltaX) {
        timelineZoomDrag.isZooming = true;
        document.body.style.cursor = 'ns-resize';
      } else if (deltaX > 10) {
        // Horizontal movement - cancel zoom drag, let other handlers take over
        timelineZoomDrag = null;
        return;
      }
    }
    
    if (timelineZoomDrag.isZooming) {
      timelineZoomDrag.hasMoved = true;
      
      // Sensitivity: 100px of movement = 2x zoom change
      const sensitivity = 0.02;
      const zoomDelta = deltaY * sensitivity;
      const newZoom = timelineZoomDrag.startZoom + zoomDelta;
      
      applyZoom(newZoom);
    }
  });
  
  document.addEventListener('mouseup', () => {
    if (timelineZoomDrag) {
      const shouldCommitZoom = timelineZoomDrag.isZooming;
      document.body.style.cursor = '';
      timelineZoomDrag = null;
      if (shouldCommitZoom) timelineZoomRenderer.commit();
    }
  });

  // =========================
  // SEGMENT RENDERING
  // =========================
  let visibleAudioWaveformFrame = null;

  function renderVisibleAudioWaveforms() {
    visibleAudioWaveformFrame = null;
    const scrollLeft = timelineViewport.scrollLeft;
    const viewportWidth = timelineViewport.clientWidth;

    document.querySelectorAll('#dynamicTracks .audio-segment-waveform').forEach(canvas => {
      const segmentElement = canvas.closest('.seg');
      const track = getTrackById(segmentElement?.dataset.trackId);
      const segment = track?.segments[Number(segmentElement?.dataset.index)];
      if (!segment) return;

      const window = audioWaveformViewportWindow({
        segmentStart: segment.start,
        segmentEnd: segment.end,
        pixelsPerSecond: pxPerSec,
        scrollLeft,
        viewportWidth,
        headerWidth: TRACK_HEADER_WIDTH,
      });
      if (!window.visible) {
        canvas.style.display = 'none';
        if (canvas.width > 1 || canvas.height > 1) {
          canvas.width = 1;
          canvas.height = 1;
        }
        canvas._audioWaveformRenderState = null;
        return;
      }

      const waveform = audioWaveformCache.get(segment.audioFile);
      const peaks = waveform?.peaks || null;
      const sourceOffset = Number(segment.sourceOffset || 0);
      const clipDuration = Math.max(0, segment.end - segment.start);
      const sourceDuration = Number(segment.sourceDuration || clipDuration);
      const canvasLeft = window.left;
      const canvasWidth = Math.max(1, window.width);
      const windowSourceOffset = sourceOffset + window.clipOffset;
      const renderState = canvas._audioWaveformRenderState;

      canvas.style.display = 'block';
      canvas.style.left = `${canvasLeft}px`;
      canvas.style.width = `${canvasWidth}px`;
      canvas.dataset.renderWidth = String(Math.round(canvasWidth));
      if (
        renderState?.peaks === peaks
        && renderState.left === canvasLeft
        && renderState.width === canvasWidth
        && renderState.sourceOffset === windowSourceOffset
        && renderState.clipDuration === window.clipDuration
      ) {
        return;
      }

      const maximumCache = canvas._audioWaveformMaximum;
      let amplitudeMaximum = maximumCache?.peaks === peaks
        && maximumCache.sourceOffset === sourceOffset
        && maximumCache.clipDuration === clipDuration
        && maximumCache.sourceDuration === sourceDuration
        ? maximumCache.value
        : null;
      if (amplitudeMaximum === null) {
        amplitudeMaximum = audioClipWaveformMaximum(peaks, {
          sourceOffset,
          clipDuration,
          sourceDuration,
        });
        canvas._audioWaveformMaximum = {
          peaks,
          sourceOffset,
          clipDuration,
          sourceDuration,
          value: amplitudeMaximum,
        };
      }

      drawAudioClipWaveform(canvas, peaks, {
        sourceOffset: windowSourceOffset,
        clipDuration: window.clipDuration,
        sourceDuration,
        amplitudeMaximum,
      });
      canvas._audioWaveformRenderState = {
        peaks,
        left: canvasLeft,
        width: canvasWidth,
        sourceOffset: windowSourceOffset,
        clipDuration: window.clipDuration,
      };
    });
  }

  function scheduleVisibleAudioWaveformRender() {
    if (visibleAudioWaveformFrame !== null) return;
    visibleAudioWaveformFrame = requestAnimationFrame(renderVisibleAudioWaveforms);
  }

  // Render dynamic tracks (track rows)
  function renderTracks() {
    const dynamicTracksContainer = document.getElementById('dynamicTracks');
    if (!dynamicTracksContainer) return;
    
    // Clear existing dynamic tracks
    dynamicTracksContainer.innerHTML = '';
    
    // Get tracks sorted by order
    const sortedTracks = getSortedTracks();
    
    // Create track row for each track
    for (const track of sortedTracks) {
      const trackRow = document.createElement('div');
      trackRow.className = `trackRow ${track.type}`;
      trackRow.dataset.trackId = track.id;
      trackRow.draggable = true;
      
      // Track header
      const header = document.createElement('div');
      header.className = 'track-header';
      
      // Drag handle
      const dragHandle = document.createElement('span');
      dragHandle.className = 'track-drag-handle';
      dragHandle.textContent = '≡';
      dragHandle.title = 'Drag to reorder';
      header.appendChild(dragHandle);
      
      // Track name
      const nameSpan = document.createElement('span');
      nameSpan.className = 'track-name';
      nameSpan.textContent = track.name;
      nameSpan.title = track.name;
      header.appendChild(nameSpan);

      const isTrackVisible = trackIsVisible(track);
      const visibilityBtn = document.createElement('button');
      visibilityBtn.className = `track-visibility-btn${isTrackVisible ? '' : ' is-hidden'}`;
      visibilityBtn.type = 'button';
      visibilityBtn.title = isTrackVisible ? 'Hide track' : 'Show track';
      visibilityBtn.setAttribute('aria-label', `${isTrackVisible ? 'Hide' : 'Show'} ${track.name}`);
      visibilityBtn.setAttribute('aria-pressed', String(isTrackVisible));
      visibilityBtn.innerHTML = isTrackVisible
        ? '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M2.5 12s3.5-6 9.5-6 9.5 6 9.5 6-3.5 6-9.5 6-9.5-6-9.5-6Z"/><circle cx="12" cy="12" r="2.8"/></svg>'
        : '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 3l18 18M10.6 6.1A10.8 10.8 0 0 1 12 6c6 0 9.5 6 9.5 6a16 16 0 0 1-2.3 3M6.2 6.2C3.8 8 2.5 12 2.5 12s3.5 6 9.5 6c1.3 0 2.5-.3 3.6-.7M9.9 9.9A3 3 0 0 0 14.1 14.1"/></svg>';
      visibilityBtn.addEventListener('click', event => {
        event.stopPropagation();
        toggleTimelineTrackVisibility(track.id);
      });
      header.appendChild(visibilityBtn);
      
      // Add segment button
      const addBtn = document.createElement('button');
      addBtn.className = 'track-add-btn';
      addBtn.textContent = '+';
      addBtn.title = 'Add segment';
      addBtn.addEventListener('click', () => addSegmentToTrack(track.id));
      header.appendChild(addBtn);
      
      // Delete track button
      const deleteBtn = document.createElement('button');
      deleteBtn.className = 'track-delete-btn';
      deleteBtn.textContent = '×';
      deleteBtn.title = 'Delete track';
      deleteBtn.addEventListener('click', () => {
        if (confirm(`Delete track "${track.name}" and all its segments?`)) {
          removeTrack(track.id);
        }
      });
      header.appendChild(deleteBtn);

      // Track selection on header click
      header.addEventListener('click', (e) => {
        if (
          e.target.closest('.track-add-btn')
          || e.target.closest('.track-delete-btn')
          || e.target.closest('.track-visibility-btn')
        ) {
          return;
        }
        selectTimelineTrack(track.id);
      });

      // Double-click header to toggle minimize
      header.addEventListener('dblclick', (e) => {
        if (
          e.target.closest('.track-add-btn')
          || e.target.closest('.track-delete-btn')
          || e.target.closest('.track-visibility-btn')
        ) {
          return;
        }
        toggleTrackMinimized(track.id);
      });

      if (selectedTimelineTrack === track.id) {
        header.classList.add('selected');
      }

      if (minimizedTracks.has(track.id)) {
        trackRow.classList.add('minimized');
      }

      if (!isTrackVisible) {
        trackRow.classList.add('track-hidden');
      }

      trackRow.appendChild(header);
      
      // Track content area
      const content = document.createElement('div');
      content.className = 'track-content';

      // Clicking open timeline space clears the current clip/track selection.
      content.addEventListener('click', (e) => {
        if (e.target === content || e.target.classList.contains('track-hint')) {
          clearTimelineSelection();
        }
      });
      
      // Double-click on empty area to add segment at click position
      content.addEventListener('dblclick', (e) => {
        // Only trigger if clicking on the content itself or hint, not on a segment
        if (e.target === content || e.target.classList.contains('track-hint')) {
          // Calculate time from click position
          const rect = content.getBoundingClientRect();
          const clickX = e.clientX - rect.left;
          const clickTime = Math.max(0, xToTime(clickX));
          addSegmentToTrack(track.id, clickTime);
        }
      });
      
      // Hint when empty
      if (track.segments.length === 0) {
        const hint = document.createElement('div');
        hint.className = 'track-hint';
        hint.textContent = 'Double-click to add';
        content.appendChild(hint);
      }
      
      trackRow.appendChild(content);
      dynamicTracksContainer.appendChild(trackRow);
      
      // Set up drag events for reordering
      setupTrackDragEvents(trackRow, track);
    }
    
    // Render segments into their tracks
    renderSegments();
  }
  
  // Set up drag events for track reordering
  function setupTrackDragEvents(trackRow, track) {
    trackRow.addEventListener('dragstart', (e) => {
      e.dataTransfer.setData('text/plain', track.id);
      trackRow.classList.add('dragging');
    });
    
    trackRow.addEventListener('dragend', () => {
      trackRow.classList.remove('dragging');
      document.querySelectorAll('.trackRow.drag-over').forEach(el => el.classList.remove('drag-over'));
    });
    
    trackRow.addEventListener('dragover', (e) => {
      e.preventDefault();
      const draggingEl = document.querySelector('.trackRow.dragging');
      if (draggingEl && draggingEl !== trackRow) {
        trackRow.classList.add('drag-over');
      }
    });
    
    trackRow.addEventListener('dragleave', () => {
      trackRow.classList.remove('drag-over');
    });
    
    trackRow.addEventListener('drop', (e) => {
      e.preventDefault();
      trackRow.classList.remove('drag-over');
      const draggedTrackId = e.dataTransfer.getData('text/plain');
      if (draggedTrackId && draggedTrackId !== track.id) {
        reorderTrack(draggedTrackId, track.order);
      }
    });
  }
  
  // Add a segment to a specific track
  // startTime is optional - if not provided, uses current playhead position
  function newClipPlacement(track, fallbackStart, fallbackDuration, maxDuration = Infinity) {
    const loopDuration = loopEnd - loopStart;
    const useLoop = loopEnabled && Number.isFinite(loopDuration) && loopDuration >= 0.25;
    const requestedDuration = useLoop ? loopDuration : fallbackDuration;
    const finiteMax = Number.isFinite(maxDuration) ? Math.max(0, maxDuration) : Infinity;
    const duration = Math.min(Math.max(0.25, requestedDuration), finiteMax);
    return findNearestAvailableSegmentRange(
      track?.segments || [],
      useLoop ? loopStart : fallbackStart,
      duration,
      durationGuess,
    );
  }

  function addSegmentToTrack(trackId, startTime = null) {
    const track = getTrackById(trackId);
    if (!track) return;
    
    const currentTime = startTime !== null ? startTime : getCurrentTime();
    const defaultDuration = track.type === 'scene' ? 15 : track.type === 'shader' ? 15 : track.type === 'media' ? 5 : track.type === 'text' ? 5 : 10;
    const placement = newClipPlacement(track, currentTime, track.type === 'audio' ? 1 : defaultDuration);
    
    // Create segment based on type
    let newSeg;
    if (track.type === 'audio') {
      audioIdCounter++;
      newSeg = {
        id: `audio${audioIdCounter}`,
        name: 'New Audio',
        audioFile: null,
        start: placement.start,
        end: placement.end,
        sourceOffset: 0,
        sourceDuration: 1,
        volume: 1,
        muted: false,
        color: `hsl(${188 + audioIdCounter * 13}, 72%, 48%)`,
      };
    } else if (track.type === 'scene') {
      sceneIdCounter++;
      newSeg = {
        id: `scene${sceneIdCounter}`,
        name: 'New Scene',
        sceneFile: null,
        sceneIndex: -1,
        sceneOverrides: {},
        start: placement.start,
        end: placement.end,
        opacity: 1.0,
        blendMode: 'normal',
        transparentBackground: false,
        color: `hsl(${210 + sceneIdCounter * 18}, 70%, 60%)`,
      };
    } else if (track.type === 'shader') {
      shaderIdCounter++;
      newSeg = {
        id: `shader${shaderIdCounter}`,
        name: 'New Shader',
        shaderFile: null,
        start: placement.start,
        end: placement.end,
        color: `hsl(${270 + shaderIdCounter * 25}, 60%, 55%)`,
        blendMode: 'source-over',
        opacity: 1.0,
        ignorePostFx: false,
        uniforms: {}
      };
    } else if (track.type === 'media') {
      imageIdCounter++;
      newSeg = {
        id: `image${imageIdCounter}`,
        name: 'New Media',
        imageFile: null,
        start: placement.start,
        end: placement.end,
        x: 0.5, y: 0.5, scale: 1.0, opacity: 1.0, rotation: 0,
        loop: true, pingPong: false,
        blendMode: 'source-over',
        ignorePostFx: false,
        audioEnabled: false,
        volume: 0.5,
      };
    } else if (track.type === 'postfx') {
      postfxIdCounter++;
      newSeg = {
        id: `postfx${postfxIdCounter}`,
        name: 'New Effect',
        shaderFile: null,
        start: placement.start,
        end: placement.end,
        color: `hsl(${30 + postfxIdCounter * 15}, 70%, 55%)`,
      };
    } else if (track.type === 'text') {
      textIdCounter++;
      newSeg = {
        id: `text${textIdCounter}`,
        name: 'Text',
        start: placement.start,
        end: placement.end,
        color: `hsl(${180 + textIdCounter * 20}, 65%, 50%)`,
        // Text content
        text: 'Lazy sunday text clip',
        // Typography
        fontFamily: 'Inter',
        fontSize: 48,
        fontWeight: 400,
        fontStyle: 'normal',
        lineHeight: 1.3,
        letterSpacing: 0,
        textAlign: 'center',
        // Colors
        textColor: '#ffffff',
        backgroundColor: 'transparent',
        // Text area positioning (percentages)
        areaX: 50,
        areaY: 50,
        areaWidth: 80,
        areaHeight: 30,
        // Post-FX
        ignorePostFx: false,
        // Effects
        opacity: 1.0,
        padding: 20,
      };
    } else if (track.type === 'lut') {
      lutIdCounter++;
      newSeg = {
        id: `lut${lutIdCounter}`,
        name: 'New LUT',
        lutFile: null,
        start: placement.start,
        end: placement.end,
        color: `hsl(${45 + lutIdCounter * 20}, 65%, 55%)`,
        // Core control
        intensity: 1.0,
        // Lift (shadows) - RGB
        liftR: 0.0,
        liftG: 0.0,
        liftB: 0.0,
        // Gamma (midtones) - RGB
        gammaR: 1.0,
        gammaG: 1.0,
        gammaB: 1.0,
        // Gain (highlights) - RGB
        gainR: 1.0,
        gainG: 1.0,
        gainB: 1.0,
        // Master controls
        liftMaster: 0.0,
        gammaMaster: 1.0,
        gainMaster: 1.0,
      };
    }

    if (!newSeg) return;
    
    saveUndoState();
    track.segments.push(newSeg);
    extendSilentTimelineTo(newSeg.end);
    renderTracks();
    
    // Select the new segment
    selectSegmentInTrack(trackId, track.segments.length - 1);
    scheduleTimelineAutoSave();
    
    // Open file dialog for the new segment
    openFileDialogForSegment(trackId, track.segments.length - 1, true);
  }
  
  // Open appropriate file dialog for a segment
  function openFileDialogForSegment(trackId, segIndex, removeOnCancel = false) {
    const track = getTrackById(trackId);
    if (!track || segIndex >= track.segments.length) return;
    
    if (track.type === 'audio') {
      openAudioFileDialogForTrack(trackId, segIndex, removeOnCancel);
    } else if (track.type === 'scene') {
      openSceneFileDialogForTrack(trackId, segIndex, removeOnCancel);
    } else if (track.type === 'shader') {
      openShaderFileDialogForTrack(trackId, segIndex, removeOnCancel);
    } else if (track.type === 'media') {
      openImageFileDialogForTrack(trackId, segIndex, removeOnCancel);
    } else if (track.type === 'postfx') {
      openPostFxFileDialogForTrack(trackId, segIndex, removeOnCancel);
    } else if (track.type === 'text') {
      // Text segments don't need a file dialog - they're ready to edit immediately
      // Just ensure the params panel is showing the text controls
      populateTextParams(track.segments[segIndex]);
    } else if (track.type === 'lut') {
      openLUTFileDialogForTrack(trackId, segIndex, removeOnCancel);
    }
  }

  function openAudioFileDialogForTrack(trackId, segIndex, removeOnCancel = false) {
    const track = getTrackById(trackId);
    if (!track || track.type !== 'audio') return;
    const pendingSegment = track.segments[segIndex];
    const plannedStart = pendingSegment?.start ?? 0;
    const plannedDuration = Math.max(0.25, (pendingSegment?.end ?? 0) - plannedStart);
    const createdInsideLoop = removeOnCancel && loopEnabled && loopEnd - loopStart >= 0.25;

    openAssetBrowser('audio', async asset => {
      const seg = track.segments[segIndex];
      if (!asset) {
        if (removeOnCancel && seg && !seg.audioFile) {
          track.segments.splice(segIndex, 1);
          renderTracks();
          scheduleTimelineAutoSave();
        }
        return;
      }
      if (!seg) return;

      try {
        const sourceDuration = await readAudioDuration(asset.path);
        saveUndoState();
        seg.audioFile = asset.path;
        seg.name = asset.name.replace(/\.(mp3|wav|ogg|m4a|aac|flac)$/i, '');
        seg.sourceOffset = 0;
        seg.sourceDuration = sourceDuration;
        if (removeOnCancel) {
          const targetDuration = createdInsideLoop
            ? Math.min(plannedDuration, sourceDuration)
            : sourceDuration;
          const placement = findNearestAvailableSegmentRange(
            track.segments.filter(item => item !== seg),
            plannedStart,
            targetDuration,
            durationGuess,
          );
          seg.start = placement.start;
          seg.end = placement.end;
        } else {
          seg.end = seg.start + sourceDuration;
        }
        extendSilentTimelineTo(seg.end);
        refreshPrimaryAudioState();
        loadAudioClipWaveform(asset.path);
        renderTracks();
        selectSegmentInTrack(trackId, segIndex);
        scheduleTimelineAutoSave();
      } catch (error) {
        console.error('Failed to load audio clip:', error);
        alert(`Failed to load audio: ${asset.name}`);
      }
    });
  }

  // Open LUT file dialog for a track segment
  function openLUTFileDialogForTrack(trackId, segIndex, removeOnCancel = false) {
    const track = getTrackById(trackId);
    if (!track || track.type !== 'lut') return;

    openAssetBrowser('lut', async (asset) => {
      if (!asset) {
        if (removeOnCancel && segIndex < track.segments.length && !track.segments[segIndex].lutFile) {
          track.segments.splice(segIndex, 1);
          renderTracks();
          scheduleTimelineAutoSave();
        }
        return;
      }

      const seg = track.segments[segIndex];
      if (!seg) return;

      try {
        await loadLUT(asset.path);
        saveUndoState();
        seg.lutFile = asset.path;
        seg.name = asset.name.replace(/\.cube$/i, '');
        renderTracks();
        selectSegmentInTrack(trackId, segIndex);
        scheduleTimelineAutoSave();
      } catch (err) {
        console.error('Failed to load LUT:', err);
        alert('Failed to load LUT: ' + asset.name);
      }
    });
  }

  function clearTimelineSelection() {
    if (textEditMode) exitTextEditMode();
    selectedSegment = null;
    selectedTimelineTrack = null;
    viewportSelectedText = null;
    selectedSceneObject = null;

    document.querySelectorAll('.seg.selected').forEach(segment => {
      segment.classList.remove('selected');
    });
    document.querySelectorAll('.track-header.selected').forEach(header => {
      header.classList.remove('selected');
    });

    populateObjectList(null, null);
  }

  // Select segment within a track
  function selectSegmentInTrack(trackId, segmentIndex) {
    selectedTimelineTrack = null;
    selectedSegment = { trackId, index: segmentIndex };
    const track = getTrackById(trackId);
    if (track) {
      // Also set legacy track name for compatibility
      // Note: 'media' -> 'images', 'postfx' stays 'postfx', 'text' -> 'texts', others get 's' suffix
      selectedSegment.track = track.type === 'media' ? 'images' :
                              track.type === 'postfx' ? 'postfx' :
                              track.type === 'text' ? 'texts' : track.type + 's';
      const seg = track.segments[segmentIndex];

      // Sync viewport text selection
      if (track.type === 'text' && seg) {
        viewportSelectedText = { trackId, segIndex: segmentIndex, seg };
      } else {
        viewportSelectedText = null;
      }

      renderTracks();
      if (seg) {
        populateObjectList(selectedSegment.track, seg);
      }
    } else {
      viewportSelectedText = null;
      renderTracks();
    }
  }

  function selectTimelineTrack(trackId) {
    const track = getTrackById(trackId);
    if (!track) return;
    selectedTimelineTrack = trackId;
    selectedSegment = null;
    viewportSelectedText = null;
    document.querySelectorAll('.seg.selected').forEach(segment => {
      segment.classList.remove('selected');
    });
    document.querySelectorAll('.track-header').forEach(header => {
      const trackRow = header.closest('.trackRow');
      if (trackRow && trackRow.dataset.trackId === trackId) {
        header.classList.add('selected');
      } else {
        header.classList.remove('selected');
      }
    });
  }

  function toggleTimelineTrackVisibility(trackId) {
    const track = getTrackById(trackId);
    if (!track) return;
    const nextVisible = !trackIsVisible(track);
    const result = setTimelineTrackVisibility(timelineTracks, trackId, nextVisible);
    if (!result.changed) return;
    saveUndoState();
    timelineTracks = result.tracks;
    refreshPrimaryAudioState();
    audioClipPlayback?.sync(timelineTracks, getCurrentTime(), silentPlaying);
    renderTracks();
    scheduleTimelineAutoSave();
    showToast(`${track.name} ${nextVisible ? 'shown' : 'hidden'}`);
  }

  function toggleTrackMinimized(trackId) {
    if (minimizedTracks.has(trackId)) {
      minimizedTracks.delete(trackId);
    } else {
      minimizedTracks.add(trackId);
    }
    const trackRow = document.querySelector(`.trackRow[data-track-id="${trackId}"]`);
    if (trackRow) {
      trackRow.classList.toggle('minimized', minimizedTracks.has(trackId));
    }
    saveMinimizedTracks();
  }

  function saveMinimizedTracks() {
    try {
      localStorage.setItem('demotool_minimized_tracks', JSON.stringify([...minimizedTracks]));
    } catch (e) {
      console.warn('Failed to save minimized tracks:', e);
    }
  }


  function renderSegments() {
    // Clear old segments from all tracks
    document.querySelectorAll('#dynamicTracks .seg').forEach(el => el.remove());
    
    // Render segments for each track
    for (const track of timelineTracks) {
      const trackRow = document.querySelector(`.trackRow[data-track-id="${track.id}"]`);
      if (!trackRow) continue;
      
      const content = trackRow.querySelector('.track-content');
      if (!content) continue;
      
      // Clear hints if there are segments
      if (track.segments.length > 0) {
        const hint = content.querySelector('.track-hint');
        if (hint) hint.style.display = 'none';
      } else {
        const hint = content.querySelector('.track-hint');
        if (hint) hint.style.display = 'block';
      }
      
      // Render each segment
      for (let i = 0; i < track.segments.length; i++) {
        const seg = track.segments[i];
        const el = createSegmentElement(seg, track, i);
        content.appendChild(el);
      }
    }
    scheduleVisibleAudioWaveformRender();
  }

  function segmentDisplayName(segment, trackType, index) {
    return segment.name || (
      trackType === 'scene' ? `Scene ${segment.sceneIndex ?? index}` :
      trackType === 'shader' ? `Shader ${index}` :
      trackType === 'media' ? `Media ${index}` :
      trackType === 'text' ? `Text ${index}` :
      trackType === 'lut' ? `LUT ${index}` :
      trackType === 'audio' ? `Audio ${index + 1}` :
      `PostFX ${index}`
    );
  }

  function createSegmentElement(seg, track, index) {
    // track can be either a track object or a legacy string
    const trackType = typeof track === 'object' ? track.type : track;
    const trackId = typeof track === 'object' ? track.id : null;
    const legacyTrackName = trackType === 'media' ? 'images' : trackType + 's';
    
      const el = document.createElement("div");
      el.className = "seg";
    el.dataset.trackId = trackId || '';
    el.dataset.trackType = trackType;
    el.dataset.index = String(index);

      el.style.left = `${timeToX(seg.start)}px`;
    el.style.width = `${Math.max(1, timeToX(seg.end - seg.start))}px`;
    
    if (trackType === 'scene') {
      // Generate color if not present
      const color = seg.color || `hsl(${210 + (seg.sceneIndex || index) * 18}, 70%, 60%)`;
      el.style.background = `linear-gradient(135deg, ${color}, rgba(255,255,255,.55))`;
    } else if (trackType === 'shader') {
      // Purple gradient for shaders
      const color = seg.color || `hsl(${270 + index * 25}, 60%, 55%)`;
      el.style.background = `linear-gradient(135deg, ${color}, rgba(200,150,255,.4))`;
    } else if (trackType === 'text') {
      // Teal/cyan gradient for text
      const color = seg.color || `hsl(${180 + index * 20}, 65%, 50%)`;
      el.style.background = `linear-gradient(135deg, ${color}, rgba(100,255,220,.4))`;
    } else if (trackType === 'lut') {
      // Orange/amber gradient for LUTs
      const color = seg.color || `hsl(${45 + index * 20}, 65%, 55%)`;
      el.style.background = `linear-gradient(135deg, ${color}, rgba(251,191,36,.4))`;
    } else if (trackType === 'audio') {
      const canvas = document.createElement('canvas');
      canvas.className = 'audio-segment-waveform';
      el.appendChild(canvas);
      if (seg.audioFile && !audioWaveformCache.has(seg.audioFile)) {
        loadAudioClipWaveform(seg.audioFile);
      }
    }

      const title = document.createElement("div");
      title.className = "title";
    title.textContent = segmentDisplayName(seg, trackType, index);
      el.appendChild(title);

      const meta = document.createElement("div");
      meta.className = "meta";
    meta.textContent = trackType === 'audio'
      ? `${fmtTime(seg.start)} → ${fmtTime(seg.end)} · ${Math.round((seg.volume ?? 1) * 100)}%`
      : `${fmtTime(seg.start)} → ${fmtTime(seg.end)}`;
      el.appendChild(meta);

      const hl = document.createElement("div");
      hl.className = "handle left";
      el.appendChild(hl);

      const hr = document.createElement("div");
      hr.className = "handle right";
      el.appendChild(hr);

    el.addEventListener("click", (e) => {
        e.stopPropagation();
      if (trackId) {
        selectSegmentInTrack(trackId, index);
      } else {
        selectSegment(legacyTrackName, index);
      }
    });

    el.addEventListener("pointerdown", (e) => onSegPointerDown(e, el, trackId || legacyTrackName, index));

    // Double-click to change file
      el.addEventListener("dblclick", (e) => {
        e.stopPropagation();
      if (trackId) {
        openFileDialogForSegment(trackId, index, false);
      } else {
        // Legacy paths
        if (trackType === 'postfx') openPostFxFileDialog(index);
        else if (trackType === 'shader') openShaderFileDialog(index);
        else if (trackType === 'media') openImageFileDialog(index);
        else if (trackType === 'scene') openSceneFileDialog(index);
      }
    });
    
    // Set tooltip
    if (trackType === 'scene') {
      const isWorkingCopy = seg.sceneFile && seg.sceneFile.includes('/wc_');
      const originalInfo = seg.originalSource ? ` (from: ${seg.originalSource.split('/').pop()})` : '';
      el.title = isWorkingCopy 
        ? `Working copy${originalInfo}\nDouble-click to change scene file`
        : "Double-click to change scene file";
    } else {
      el.title = "Double-click to change file";
    }

    // Highlight if selected
    // Priority: use trackId match if available, only fall back to legacy if no trackId
    const isSelected = selectedSegment && (
      // Primary: match by trackId (preferred for multi-track system)
      (selectedSegment.trackId && trackId && selectedSegment.trackId === trackId && selectedSegment.index === index) ||
      // Fallback: legacy match only when selectedSegment has no trackId AND no trackId on this element
      (!selectedSegment.trackId && !trackId && selectedSegment.track === legacyTrackName && selectedSegment.index === index)
    );
    if (isSelected) {
      el.classList.add("selected");
    }

    return el;
  }

  // =========================
  // POST-FX MANAGEMENT
  // =========================
  
  // Legacy: Add new postfx segment at playhead position (now handled by track header buttons)
  if (btnAddPostFx) {
  btnAddPostFx.addEventListener("click", () => {
      // Use first postfx track
      const track = getTracksByType('postfx')[0];
      if (track) {
        addSegmentToTrack(track.id);
      }
    });
  }

  // Legacy: Add new image segment at playhead position (now handled by track header buttons)
  if (btnAddImage) {
  btnAddImage.addEventListener("click", () => {
      const track = getTracksByType('media')[0];
      if (track) {
        addSegmentToTrack(track.id);
      }
    });
  }

  // =========================
  // SHADER MANAGEMENT
  // =========================
  
  // Find a gap in shader timeline for a new segment
  
  // Find gap in scene segments for adding a new scene
  
  // Legacy: Add new scene segment at playhead position (now handled by track header buttons)
  if (btnAddScene) {
    btnAddScene.addEventListener("click", () => {
      const track = getTracksByType('scene')[0];
      if (track) {
        addSegmentToTrack(track.id);
      }
    });
  }
  
  // Create a working copy of a scene for this demo
  // Working copies are demo-specific and don't affect the original library scene
  async function createWorkingCopy(originalPath, originalData) {
    if (!editorState.currentDemoId) {
      console.warn('[WorkingCopy] No project ID, cannot create working copy');
      return null;
    }
    
    // Generate unique filename for the working copy
    const originalName = originalPath.split('/').pop().replace('.json', '');
    const workingFilename = `wc_${editorState.currentDemoId}_${originalName}_${Date.now()}.json`;
    
    // Add working copy metadata to the scene data
    const workingCopyData = {
      ...originalData,
      _workingCopy: true,
      _originalSource: originalPath,
      _demoId: editorState.currentDemoId,
      _createdAt: Date.now()
    };
    
    try {
      // Save the working copy to the server
      const saveRes = await api.assets.saveGenerated(
        workingFilename, JSON.stringify(workingCopyData, null, 2), 'scenes'
      );
      
      if (!saveRes.ok) {
        throw new Error('Failed to save working copy');
      }
      
      const result = await saveRes.json();
      console.log(`[WorkingCopy] Created working copy: ${result.path} (from ${originalPath})`);
      
      return {
        path: result.path,
        data: workingCopyData
      };
    } catch (err) {
      console.error('[WorkingCopy] Failed to create working copy:', err);
      return null;
    }
  }
  
  // Open scene file dialog (legacy - uses first scene track)
  function openSceneFileDialog(segIndex, removeOnCancel = false) {
    const originalCallback = assetBrowserCallback;
    const sceneTracks = getTracksByType('scene');
    const track = sceneTracks[0];
    if (!track) return;
    
    openAssetBrowser('scenes', async (asset) => {
      if (!asset || segIndex >= track.segments.length) {
        // User cancelled - remove the segment if it was just created
        if (removeOnCancel && segIndex < track.segments.length) {
          const seg = track.segments[segIndex];
          if (seg.sceneIndex === -1 && !seg.sceneFile) {
            track.segments.splice(segIndex, 1);
            renderTracks();
            scheduleTimelineAutoSave();
          }
        }
        return;
      }
      
      const seg = track.segments[segIndex];
      const originalPath = asset.path;
      const originalName = asset.name.replace(/\.json$/i, '');
      
      try {
        // First, fetch the original scene data
        const res = await fetch('/' + originalPath);
        if (!res.ok) throw new Error('Failed to fetch scene');
        const originalData = await res.json();
        
        // Check if this is already a working copy (e.g., re-adding from same demo)
        const isAlreadyWorkingCopy = originalData._workingCopy === true;
        
        let scenePath = originalPath;
        let sceneData = originalData;
        
        // Create a working copy if this is an original library scene
        if (!isAlreadyWorkingCopy && editorState.currentDemoId) {
          const workingCopy = await createWorkingCopy(originalPath, originalData);
          if (workingCopy) {
            scenePath = workingCopy.path;
            sceneData = workingCopy.data;
            console.log(`[Scene] Using working copy: ${scenePath}`);
          } else {
            // Fall back to using original if working copy creation fails
            console.warn('[Scene] Working copy creation failed, using original');
          }
        }
        
        // Store both the working copy path and original source
        seg.sceneFile = scenePath;
        seg.originalSource = isAlreadyWorkingCopy ? (originalData._originalSource || originalPath) : originalPath;
        seg.name = originalName;
        
        // Load the scene (using the working copy path)
        const sceneIdx = await loadSceneFromFile(scenePath);
        seg.sceneIndex = sceneIdx;
        
        renderTracks();
        selectSegmentInTrack(track.id, segIndex);
        scheduleTimelineAutoSave();
      } catch (err) {
        console.error('Failed to load scene:', err);
        alert('Failed to load scene: ' + asset.name);
      }
    });
  }
  
  // Load a scene from a file path and return its index in loadedScenes
  async function loadSceneFromFile(filePath) {
    try {
      const res = await fetch('/' + filePath);
      if (!res.ok) throw new Error('Failed to fetch scene');
      let sceneData = await res.json();
      
      // Check if this scene is already loaded
      const existingIdx = loadedScenes.findIndex(s => s && s.data?._sourceFile === filePath);
      if (existingIdx >= 0) {
        return existingIdx;
      }
      
      // Mark the source file
      sceneData._sourceFile = filePath;
      
      // Check for localStorage overrides by filename
      const filename = filePath.split('/').pop().replace('.json', '');
      const storageKey = STORAGE_PREFIX + filename;
      try {
        const stored = localStorage.getItem(storageKey);
        if (stored) {
          const wrapped = JSON.parse(stored);
          if (wrapped._version === STORAGE_VERSION && wrapped.data) {
            sceneData = deepMerge(sceneData, wrapped.data);
          }
        }
      } catch (e) {
        console.warn('Failed to load scene overrides from localStorage:', e);
      }
      
      // Load the scene into Three.js using the existing createThreeScene function
      const sceneObj = await createThreeScene(sceneData);
      const newIdx = loadedScenes.length;
      loadedScenes.push(sceneObj);
      
      // Ensure camera aspect ratio is correct for the viewport
      if (typeof resizeRenderer === 'function') {
        resizeRenderer();
      }
      
      return newIdx;
    } catch (err) {
      console.error('Failed to load scene from file:', filePath, err);
      throw err;
    }
  }
  
  // Legacy: Add new shader segment at playhead position (now handled by track header buttons)
  if (btnAddShader) {
  btnAddShader.addEventListener("click", () => {
      const track = getTracksByType('shader')[0];
      if (track) {
        addSegmentToTrack(track.id);
      }
    });
  }
  
  // Open shader file dialog (legacy - uses first shader track)
  function openShaderFileDialog(segIndex, removeOnCancel = false) {
    const shaderTracks = getTracksByType('shader');
    const track = shaderTracks[0];
    if (!track) return;
    
    openAssetBrowser('shader', async (asset) => {
      // If cancelled or no asset, potentially remove the empty segment
      if (!asset) {
        if (removeOnCancel && segIndex < track.segments.length) {
          const seg = track.segments[segIndex];
          // Only remove if no shader file was ever set
          if (!seg.shaderFile) {
            track.segments.splice(segIndex, 1);
            renderTracks();
            scheduleTimelineAutoSave();
            console.log('Removed empty shader segment (user cancelled)');
          }
        }
        return;
      }
      
      const seg = track.segments[segIndex];
      seg.shaderFile = asset.path;
      seg.name = asset.name.replace(/\.(glsl|frag|fs)$/i, '');
      
      // Load the shader and parse its parameters
      await loadShaderEffect(seg.shaderFile);
      
      renderTracks();
      selectSegmentInTrack(track.id, segIndex);
      scheduleTimelineAutoSave();
    });
  }
  
  // Load a shader effect and cache it
  async function loadShaderEffect(filename) {
    if (loadedShaders[filename]) {
      return loadedShaders[filename];
    }
    
    // Try assets/shaders/ first, then direct path
    const paths = filename.startsWith('assets/') 
      ? [filename, '/' + filename]
      : [`assets/shaders/${filename}`, `/${filename}`];
    
    for (const path of paths) {
      try {
        const response = await fetch(path);
        if (response.ok) {
          const source = await response.text();
          const { effectName, params, textureParams } = parseShaderParams(source);
          
          loadedShaders[filename] = {
            source: source,
            material: null, // Will be created when rendering
            effectName: effectName || filename.replace(/\.(glsl|frag|fs)$/i, ''),
            params: params,
            textureParams: textureParams || []
          };
          
          console.log(`Loaded shader: ${filename}`);
          return loadedShaders[filename];
        }
      } catch (e) {
        // Try next path
      }
    }
    
    console.warn('Failed to load shader:', filename);
    return null;
  }

  // Check if a filename is a video file
  function isVideoFile(filename) {
    if (!filename) return false;
    const ext = filename.toLowerCase().split('.').pop();
    return ['mp4', 'webm', 'mov', 'ogg', 'm4v'].includes(ext);
  }

  // Load an image or video and cache it
  function loadMedia(filename) {
    if (loadedImages[filename]) {
      return Promise.resolve(loadedImages[filename]);
    }
    if (mediaLoadPromises.has(filename)) {
      return mediaLoadPromises.get(filename);
    }

    const loadPromise = new Promise((resolve, reject) => {
      
      // Determine the correct path
      // If filename already includes a path (assets/...), use it directly
      // Otherwise, try assets/images/ first, then fallback to images/
      let src;
      if (filename.startsWith('assets/') || filename.startsWith('/')) {
        src = filename.startsWith('/') ? filename : '/' + filename;
      } else {
        // Try assets/images first (new structure)
        src = `assets/images/${filename}`;
      }
      
      if (isVideoFile(filename)) {
        // Load as video with double-buffering for seamless loops
        const createVideoElement = () => {
          const video = document.createElement('video');
          video.muted = true;
          video.playsInline = true;
          video.preload = 'auto';
          video.loop = false; // We handle looping manually with double buffer
          return video;
        };
        
        const videoA = createVideoElement();
        const videoB = createVideoElement();
        const buffer = {
          videoA,
          videoB,
          readyA: false,
          readyB: false,
          activeBuffer: 'A',
          lastLoopCycle: -1,
        };
        videoBuffers[filename] = buffer;
        
        const startSecondaryBuffer = () => {
          if (videoB.src) return;
          videoB.src = videoA.currentSrc || src;
          videoB.load();
        };
        
        videoA.onloadeddata = () => {
          // A visible frame is ready. Do not make the whole clip depend on the
          // optional loop buffer: Chrome can deprioritize the second identical
          // request, which previously left the video absent for the whole run.
          buffer.readyA = true;
          loadedImages[filename] = videoA;
          resolve(videoA);
          startSecondaryBuffer();
        };
        videoB.onloadeddata = () => {
          buffer.readyB = true;
        };
        videoA.onerror = () => {
          delete videoBuffers[filename];
          reject(new Error(`Failed to load video: ${filename}`));
        };
        videoB.onerror = () => {
          console.warn(`Secondary video buffer failed to load: ${filename}`);
        };
        
        videoA.src = src;
        videoA.load();
      } else {
        // Load as image
        const img = new Image();
        img.onload = () => {
          loadedImages[filename] = img;
          resolve(img);
        };
        img.onerror = () => {
          // Fallback to old images/ path
          if (!src.startsWith('/images/') && !src.includes('assets/')) {
            img.src = `images/${filename}`;
          } else {
            reject(new Error(`Failed to load image: ${filename}`));
          }
        };
        img.src = src;
      }
    });

    mediaLoadPromises.set(filename, loadPromise);
    loadPromise.catch(() => {
      if (mediaLoadPromises.get(filename) === loadPromise) {
        mediaLoadPromises.delete(filename);
      }
    });
    return loadPromise;
  }
  
  // Backwards compatibility alias

  // Open file dialog to select image or video (legacy - uses first media track)
  function openImageFileDialog(segIndex, removeOnCancel = false) {
    const mediaTracks = getTracksByType('media');
    const track = mediaTracks[0];
    if (!track) return;
    
    // Open Asset Browser for media selection
    openAssetBrowser('media', async (asset) => {
      // If cancelled or no asset, potentially remove the empty segment
      if (!asset) {
        if (removeOnCancel && segIndex < track.segments.length) {
          const seg = track.segments[segIndex];
          // Only remove if no image file was ever set
          if (!seg.imageFile) {
            track.segments.splice(segIndex, 1);
            renderTracks();
            scheduleTimelineAutoSave();
            console.log('Removed empty media segment (user cancelled)');
          }
        }
        return;
      }
      
      try {
        // Load the media from the assets folder
        await loadMedia(asset.path);
        
        // Update segment
        if (segIndex < track.segments.length) {
          saveUndoState();
          track.segments[segIndex].imageFile = asset.path;
          track.segments[segIndex].isVideo = isVideoFile(asset.path);
          track.segments[segIndex].name = asset.name.replace(/\.(png|jpg|jpeg|webp|gif|mp4|webm|mov|ogg|m4v)$/i, '');
          renderTracks();
          selectSegmentInTrack(track.id, segIndex);
          scheduleTimelineAutoSave();
        }
      } catch (err) {
        console.error('Failed to load media:', err);
        alert('Failed to load media: ' + asset.name);
      }
    });
  }
  
  // Legacy function for file input (keeping for backwards compatibility)

  function activeExportVideoFrameRequests(t) {
    const requests = new Map();

    for (const track of getTracksByType('media')) {
      if (!trackIsVisible(track)) continue;

      for (const seg of track.segments) {
        if (
          t < seg.start
          || t >= seg.end
          || !seg.imageFile
          || !isVideoFile(seg.imageFile)
        ) continue;

        let media = loadedImages[seg.imageFile];
        if (!media || media.tagName !== 'VIDEO') {
          throw new Error(`Video is not ready for export: ${seg.imageFile}`);
        }

        let videoTime = Math.max(0, t - seg.start);
        const videoDuration = Number(media.duration);
        const hasDuration = Number.isFinite(videoDuration) && videoDuration > 0;
        const buffer = videoBuffers[seg.imageFile];

        if (seg.loop && hasDuration) {
          if (seg.pingPong) {
            const cycleDuration = videoDuration * 2;
            const cycleTime = videoTime % cycleDuration;
            videoTime = cycleTime < videoDuration
              ? cycleTime
              : cycleDuration - cycleTime;
          } else {
            const currentCycle = Math.floor(videoTime / videoDuration);
            videoTime %= videoDuration;
            if (buffer) {
              const requestedBuffer = currentCycle % 2 === 0 ? 'A' : 'B';
              const selection = selectPlayableVideoBuffer(buffer, requestedBuffer);
              media = selection.activeVideo;
              buffer.activeBuffer = selection.activeBuffer;
            }
          }
        } else if (hasDuration) {
          videoTime = Math.min(videoTime, videoDuration);
        }

        const existing = requests.get(media);
        if (existing && Math.abs(existing.time - videoTime) > 0.0005) {
          throw new Error(
            `The same video is used at two different source times at ${formatTime(t)}. `
            + 'Duplicate the media file before exporting overlapping instances.',
          );
        }
        requests.set(media, { media, time: videoTime, filename: seg.imageFile });
      }
    }

    return [...requests.values()];
  }

  async function prepareActiveVideoFramesForExport(t) {
    const requests = activeExportVideoFrameRequests(t);
    await Promise.all(requests.map(({ media, time, filename }) => (
      prepareVideoFrameForExport(media, time, { timeoutMs: 15000 })
        .catch(error => {
          throw new Error(
            `Could not prepare ${filename} at ${formatTime(t)}: ${error.message}`,
          );
        })
    )));
  }

  // Find active images/videos at a given time
  function findActiveImages(t) {
    const active = [];
    const isPlaying = silentPlaying;
    const mediaTracks = getTracksByType('media');
    
    // Get all media segments across all media tracks
    const allMediaSegments = mediaTracks
      .filter(trackIsVisible)
      .flatMap(track => track.segments);
    
    // First pass: collect all active video files to avoid pausing videos that are used by another active segment
    const activeVideoFiles = new Set();
    for (const seg of allMediaSegments) {
      if (t >= seg.start && t < seg.end && seg.imageFile) {
        const media = loadedImages[seg.imageFile];
        if (media && media.tagName === 'VIDEO') {
          activeVideoFiles.add(seg.imageFile);
        }
      }
    }
    
    // Second pass: process segments (in track order for proper layering)
    for (const track of mediaTracks) {
      if (!trackIsVisible(track)) {
        for (const seg of track.segments) {
          if (!seg.imageFile || activeVideoFiles.has(seg.imageFile)) continue;
          const media = loadedImages[seg.imageFile];
          if (media?.tagName === 'VIDEO' && !media.paused) media.pause();
          const buffer = videoBuffers[seg.imageFile];
          if (buffer) {
            if (!buffer.videoA.paused) buffer.videoA.pause();
            if (!buffer.videoB.paused) buffer.videoB.pause();
          }
        }
        continue;
      }
      for (const seg of track.segments) {
      if (t >= seg.start && t < seg.end && seg.imageFile) {
        let media = loadedImages[seg.imageFile];
        if (media) {
          // For videos, sync the currentTime to match timeline position
          if (media.tagName === 'VIDEO') {
            let videoTime = t - seg.start; // Time relative to segment start
            const videoDuration = media.duration || Infinity;
            const buffer = videoBuffers[seg.imageFile];
            
            // Handle looping with double-buffered seamless playback
            if (seg.loop && videoDuration > 0 && videoDuration !== Infinity && buffer) {
              if (seg.pingPong) {
                // Ping pong: forward, backward, forward, backward...
                const cycleDuration = videoDuration * 2;
                const cycleTime = videoTime % cycleDuration;
                if (cycleTime < videoDuration) {
                  videoTime = cycleTime;
                } else {
                  videoTime = cycleDuration - cycleTime;
                }
                // For ping-pong, use single buffer with seeking (complex to double-buffer)
                const seekThreshold = 0.05;
                if (Math.abs(media.currentTime - videoTime) > seekThreshold) {
                  media.currentTime = videoTime;
                }
              } else {
                // Simple loop with double-buffering for seamless playback
                const currentCycle = Math.floor((t - seg.start) / videoDuration);
                const cycleTime = videoTime % videoDuration;
                
                // Determine which buffer should be active based on cycle
                const shouldBeActive = currentCycle % 2 === 0 ? 'A' : 'B';
                const {
                  activeBuffer,
                  activeVideo,
                  inactiveVideo,
                } = selectPlayableVideoBuffer(buffer, shouldBeActive);
                
                // Check if we need to swap buffers (new cycle)
                if (buffer.lastLoopCycle !== currentCycle) {
                  buffer.lastLoopCycle = currentCycle;
                  buffer.activeBuffer = activeBuffer;
                  
                  // Pre-seek the inactive buffer to start for next loop
                  if (inactiveVideo.readyState >= 1 && inactiveVideo.currentTime > 0.1) {
                    inactiveVideo.currentTime = 0;
                  }
                }
                
                // Use the active buffer for rendering
                media = activeVideo;
                videoTime = cycleTime;
                
                // Seek active buffer if needed (e.g., after scrubbing)
                const seekThreshold = 0.15;
                if (Math.abs(media.currentTime - videoTime) > seekThreshold) {
                  media.currentTime = videoTime;
                }
                
                // Pre-buffer: when approaching end, ensure inactive buffer is ready at 0
                const preBufferThreshold = 0.3; // seconds before end
                if (cycleTime > videoDuration - preBufferThreshold
                    && inactiveVideo.readyState >= 1) {
                  if (inactiveVideo.currentTime > 0.05) {
                    inactiveVideo.currentTime = 0;
                  }
                  // Start playing inactive buffer so it's ready
                  if (isPlaying && inactiveVideo.paused) {
                    inactiveVideo.play().catch(() => {});
                  }
                }
              }
            } else if (seg.loop && videoDuration > 0 && videoDuration !== Infinity) {
              // Fallback for loop without double buffer (shouldn't happen normally)
                videoTime = videoTime % videoDuration;
              const seekThreshold = 0.1;
              if (Math.abs(media.currentTime - videoTime) > seekThreshold) {
                media.currentTime = videoTime;
              }
            } else {
              // No loop: clamp to video duration
              videoTime = Math.min(videoTime, videoDuration);
              const seekThreshold = 0.1;
            if (Math.abs(media.currentTime - videoTime) > seekThreshold) {
              media.currentTime = videoTime;
              }
            }
            
            // Play/pause video to match audio playback state
            const shouldPlay = seg.loop ? true : (videoTime < videoDuration);
            if (isPlaying && media.paused && shouldPlay) {
              media.play().catch(() => {}); // Silently handle autoplay restrictions
            } else if (!isPlaying && !media.paused) {
              media.pause();
            }
            
            // Apply video audio settings from segment
            // audioEnabled defaults to false (videos are muted by default)
            const shouldMute = !seg.audioEnabled;
            if (media.muted !== shouldMute) {
              media.muted = shouldMute;
            }
            // Apply volume (defaults to 0.5)
            const targetVolume = seg.volume ?? 0.5;
            if (Math.abs(media.volume - targetVolume) > 0.01) {
              media.volume = targetVolume;
            }
            // Also apply to both buffers if using double buffering
            if (buffer) {
              if (buffer.videoA.muted !== shouldMute) buffer.videoA.muted = shouldMute;
              if (buffer.videoB.muted !== shouldMute) buffer.videoB.muted = shouldMute;
              if (Math.abs(buffer.videoA.volume - targetVolume) > 0.01) buffer.videoA.volume = targetVolume;
              if (Math.abs(buffer.videoB.volume - targetVolume) > 0.01) buffer.videoB.volume = targetVolume;
            }
            
            // Also pause the inactive buffer when main audio is paused
            if (buffer && !isPlaying) {
              if (!buffer.videoA.paused) buffer.videoA.pause();
              if (!buffer.videoB.paused) buffer.videoB.pause();
          }
          }
          active.push({ seg, media, trackId: track.id });
        }
      } else {
        // Only pause videos that are NOT used by any other active segment
        if (seg.imageFile && !activeVideoFiles.has(seg.imageFile)) {
        const media = loadedImages[seg.imageFile];
        if (media && media.tagName === 'VIDEO' && !media.paused) {
          media.pause();
          }
          // Also pause both buffers
          const buffer = videoBuffers[seg.imageFile];
          if (buffer) {
            if (!buffer.videoA.paused) buffer.videoA.pause();
            if (!buffer.videoB.paused) buffer.videoB.pause();
          }
        }
        }
      }
    }
    return active;
  }

  // Render image overlays
  // filter: 'all' (default), 'affected' (only post-fx affected), 'ignored' (only ignorePostFx=true)
  // baseImage: optional - if provided, draws this as base layer for blend modes (e.g., WebGL canvas or render target)
  function renderImageOverlays(t, filter = 'all', baseImage = null) {
    const { dpr } = compositionSurfaceMetrics();
    const canvasW = imageOverlayCanvas.width / dpr;
    const canvasH = imageOverlayCanvas.height / dpr;
    
    // Clear the overlay
    imageOverlayCtx.clearRect(0, 0, canvasW, canvasH);
    
    const activeMedia = filterOverlays(findActiveImages(t), filter);
    
    if (activeMedia.length === 0) return { rendered: false, baseDrawn: false };
    
    // Check if any active media uses blend modes (not 'source-over')
    const hasBlendModes = overlaysNeedBase(activeMedia);
    
    // If blend modes are used, we need to draw the base content first
    // so that blend modes affect the underlying content
    if (hasBlendModes) {
      if (baseImage) {
        imageOverlayCtx.drawImage(baseImage, 0, 0, canvasW, canvasH);
      } else {
        // Fall back to the WebGL canvas
        imageOverlayCtx.drawImage(threeCanvas, 0, 0, canvasW, canvasH);
      }
    }
    
    for (const { seg, media } of activeMedia) {
      // Use segment opacity directly (no auto-fade - all animation from keyframes)
      let opacity = seg.opacity ?? 1.0;
      opacity = Math.max(0, Math.min(1, opacity));
      if (opacity <= 0) continue;
      
      imageOverlayCtx.globalAlpha = opacity;
      imageOverlayCtx.globalCompositeOperation = seg.blendMode || 'source-over';
      
      // Get media dimensions (videos use videoWidth/videoHeight, images use width/height)
      const isVideo = media.tagName === 'VIDEO';
      const mediaW = isVideo ? media.videoWidth : media.width;
      const mediaH = isVideo ? media.videoHeight : media.height;
      
      // Skip if dimensions not yet available (video still loading)
      if (!mediaW || !mediaH) continue;
      
      // Calculate position and size - PROPORTIONAL TO CANVAS SIZE
      // Scale is relative: 1.0 means the media height = 50% of canvas height
      // This ensures the media looks the same proportion in viewport vs fullscreen
      const scale = seg.scale ?? 1.0;
      const baseSize = canvasH * 0.5; // Reference: 50% of canvas height at scale=1.0
      const mediaAspect = mediaW / mediaH;
      const drawH = baseSize * scale;
      const drawW = drawH * mediaAspect;
      
      // x, y are 0-1 where 0.5, 0.5 is center
      const centerX = (seg.x ?? 0.5) * canvasW;
      const centerY = (seg.y ?? 0.5) * canvasH;
      
      // Save context for rotation
      imageOverlayCtx.save();
      imageOverlayCtx.translate(centerX, centerY);
      
      // Rotation (in degrees)
      const rotation = (seg.rotation ?? 0) * Math.PI / 180;
      if (rotation !== 0) {
        imageOverlayCtx.rotate(rotation);
      }
      
      // Draw media centered
      imageOverlayCtx.drawImage(media, -drawW / 2, -drawH / 2, drawW, drawH);
      
      imageOverlayCtx.restore();
    }
    
    // Reset context state
    imageOverlayCtx.globalAlpha = 1.0;
    imageOverlayCtx.globalCompositeOperation = 'source-over';
    
    // Return whether the base was drawn (for text blend mode coordination)
    return { rendered: true, baseDrawn: hasBlendModes };
  }

  // Find active text segments at time t
  function findActiveTextSegments(t) {
    const active = [];
    
    for (const track of timelineTracks) {
      if (track.type !== 'text' || !trackIsVisible(track)) continue;
      
      for (const seg of track.segments) {
        if (t >= seg.start && t < seg.end && seg.text) {
          active.push({ seg, trackId: track.id });
        }
      }
    }
    
    return active;
  }

  // Convert mouse event to canvas percentage coordinates (0-100)
  function canvasEventToPercent(e) {
    const rect = canvasContainer.getBoundingClientRect();
    const x = e.clientX - rect.left;
    const y = e.clientY - rect.top;
    const pctX = (x / rect.width) * 100;
    const pctY = (y / rect.height) * 100;
    return { pctX, pctY };
  }

  // Calculate actual rendered text bounds (in pixels) for a segment
  function getTextActualBounds(seg, canvasW, canvasH) {
    // Scale font size proportionally to canvas height (reference: 1080p)
    const scaleFactor = canvasH / 1080;
    const textScale = seg.textScale ?? 1.0;
    const fontSize = Math.round((seg.fontSize || 48) * scaleFactor * textScale);
    const letterSpacing = (seg.letterSpacing || 0) * scaleFactor * textScale;

    // Get font family
    const fontInfo = AVAILABLE_FONTS.find(f => f.name === seg.fontFamily);
    const fontFamily = fontInfo ? fontInfo.family : 'Inter, sans-serif';

    // Build font string
    const fontStyle = seg.fontStyle === 'italic' ? 'italic' : 'normal';
    const fontWeight = seg.fontWeight || 400;
    const font = `${fontStyle} ${fontWeight} ${fontSize}px ${fontFamily}`;

    // Set up context for measuring
    imageOverlayCtx.save();
    imageOverlayCtx.font = font;

    // Calculate text area for wrapping
    const areaW = (seg.areaWidth / 100) * canvasW;
    const padding = Math.round((seg.padding || 20) * scaleFactor);
    const textAreaWidth = areaW - padding * 2;

    // Get wrapped lines
    const lines = wrapText(imageOverlayCtx, seg.text, textAreaWidth);

    // Measure actual width (widest line) and get font metrics
    let maxLineWidth = 0;
    let maxAscent = 0;
    let maxDescent = 0;
    for (const line of lines) {
      const metrics = imageOverlayCtx.measureText(line);
      let lineWidth = metrics.width;
      // Account for letter spacing
      if (letterSpacing !== 0) {
        lineWidth += letterSpacing * (line.length - 1);
      }
      maxLineWidth = Math.max(maxLineWidth, lineWidth);
      // Get actual font metrics (ascent above baseline, descent below)
      maxAscent = Math.max(maxAscent, metrics.actualBoundingBoxAscent || fontSize * 0.8);
      maxDescent = Math.max(maxDescent, metrics.actualBoundingBoxDescent || fontSize * 0.2);
    }

    imageOverlayCtx.restore();

    // Use the same totalTextHeight calculation as renderTextOverlays for consistency
    const lineHeight = fontSize * (seg.lineHeight || 1.3);
    const totalTextHeight = lines.length * lineHeight;
    const actualFontHeight = maxAscent + maxDescent;
    const totalTextWidth = maxLineWidth;

    // Center position in pixels - matches renderTextOverlays calculation
    const areaX = (seg.areaX / 100) * canvasW;
    const areaY = (seg.areaY / 100) * canvasH;

    // The text renders starting at: areaY - totalTextHeight / 2
    // with textBaseline = 'top', so top of first line is at that position
    const textTop = areaY - totalTextHeight / 2;
    const textBottom = textTop + totalTextHeight;

    // Calculate text area bounds for alignment
    const textAreaLeft = areaX - areaW / 2 + padding;

    // Add small padding around actual text for easier selection
    const selPadding = 4;

    // Calculate bounds based on text alignment
    let left, right;
    if (seg.textAlign === 'left') {
      left = textAreaLeft - selPadding;
      right = textAreaLeft + totalTextWidth + selPadding;
    } else if (seg.textAlign === 'right') {
      right = textAreaLeft + textAreaWidth + selPadding;
      left = right - totalTextWidth - selPadding * 2;
    } else {
      // center
      left = areaX - totalTextWidth / 2 - selPadding;
      right = areaX + totalTextWidth / 2 + selPadding;
    }

    const top = textTop - selPadding;
    const bottom = textBottom + selPadding;

    return {
      x: (left + right) / 2,
      y: areaY,
      width: right - left,
      height: bottom - top,
      left: left,
      top: top,
      right: right,
      bottom: bottom,
      actualFontHeight: actualFontHeight,
      ascent: maxAscent,
      descent: maxDescent,
      lineHeight: lineHeight
    };
  }

  // Auto-fit text bounds to match the actual text size
  // Call this after font size or text content changes to resize the paragraph bounds
  function autoFitTextBounds(seg) {
    const dpr = Math.min(2, window.devicePixelRatio);
    const canvasW = imageOverlayCanvas.width / dpr;
    const canvasH = imageOverlayCanvas.height / dpr;

    // Scale font size proportionally to canvas height (reference: 1080p)
    const scaleFactor = canvasH / 1080;
    const textScale = seg.textScale ?? 1.0;
    const fontSize = Math.round((seg.fontSize || 48) * scaleFactor * textScale);
    const letterSpacing = (seg.letterSpacing || 0) * scaleFactor * textScale;
    const padding = Math.round((seg.padding || 20) * scaleFactor);

    // Get font family
    const fontInfo = AVAILABLE_FONTS.find(f => f.name === seg.fontFamily);
    const fontFamily = fontInfo ? fontInfo.family : 'Inter, sans-serif';

    // Build font string (use base format for measurement)
    const fontStyle = seg.fontStyle === 'italic' ? 'italic' : 'normal';
    const fontWeight = seg.fontWeight || 400;
    const font = `${fontStyle} ${fontWeight} ${fontSize}px ${fontFamily}`;

    imageOverlayCtx.save();
    imageOverlayCtx.font = font;

    // Split text by explicit newlines to get natural line structure
    const paragraphs = (seg.text || '').split('\n');
    let maxLineWidth = 0;

    for (const paragraph of paragraphs) {
      const metrics = imageOverlayCtx.measureText(paragraph);
      let lineWidth = metrics.width;
      // Account for letter spacing
      if (letterSpacing !== 0 && paragraph.length > 1) {
        lineWidth += letterSpacing * (paragraph.length - 1);
      }
      // Account for per-character font size scaling if formatRanges exist
      if (seg.formatRanges && seg.formatRanges.length > 0) {
        // Recalculate with per-character formatting
        lineWidth = 0;
        let charIdx = 0;
        for (const para of paragraphs) {
          if (para === paragraph) break;
          charIdx += para.length + 1; // +1 for newline
        }
        for (let i = 0; i < paragraph.length; i++) {
          const format = getCharFormat(seg, charIdx + i);
          const charFont = buildFontString(format, seg.fontSize || 48, scaleFactor * textScale);
          imageOverlayCtx.font = charFont;
          lineWidth += imageOverlayCtx.measureText(paragraph[i]).width;
          if (i < paragraph.length - 1) {
            lineWidth += letterSpacing;
          }
        }
        imageOverlayCtx.font = font; // Reset
      }
      maxLineWidth = Math.max(maxLineWidth, lineWidth);
    }

    imageOverlayCtx.restore();

    // Calculate line height and total height
    const lineHeight = fontSize * (seg.lineHeight || 1.3);
    const totalTextHeight = paragraphs.length * lineHeight;

    // Convert pixel dimensions to percentage with padding
    const newWidthPct = ((maxLineWidth + padding * 2) / canvasW) * 100;
    const newHeightPct = ((totalTextHeight + padding * 2) / canvasH) * 100;

    // Update segment bounds (with minimum sizes)
    seg.areaWidth = Math.max(10, Math.min(100, newWidthPct));
    seg.areaHeight = Math.max(5, Math.min(100, newHeightPct));

    // Update UI sliders if visible
    const widthSlider = document.getElementById('text_area_width');
    const heightSlider = document.getElementById('text_area_height');
    if (widthSlider) {
      widthSlider.value = seg.areaWidth;
      updateSliderThumb(widthSlider);
      const widthNum = document.getElementById('text_area_width_num');
      if (widthNum) widthNum.value = Math.round(seg.areaWidth);
    }
    if (heightSlider) {
      heightSlider.value = seg.areaHeight;
      updateSliderThumb(heightSlider);
      const heightNum = document.getElementById('text_area_height_num');
      if (heightNum) heightNum.value = Math.round(seg.areaHeight);
    }
  }

  // Find text segment at given percentage coordinates
  function findTextSegmentAtPoint(pctX, pctY, t) {
    const activeTexts = findActiveTextSegments(t);
    const dpr = Math.min(2, window.devicePixelRatio);
    const canvasW = imageOverlayCanvas.width / dpr;
    const canvasH = imageOverlayCanvas.height / dpr;

    // Convert percentage to pixels
    const clickX = (pctX / 100) * canvasW;
    const clickY = (pctY / 100) * canvasH;

    // Iterate in reverse order (topmost layer first for z-order)
    for (let i = activeTexts.length - 1; i >= 0; i--) {
      const { seg, trackId } = activeTexts[i];

      // Use paragraph bounds for hit detection (matches the selection marquee)
      const rotation = seg.rotation || 0;
      const centerX = (seg.areaX / 100) * canvasW;
      const centerY = (seg.areaY / 100) * canvasH;
      const halfW = ((seg.areaWidth || 30) / 100) * canvasW / 2;
      const halfH = ((seg.areaHeight || 20) / 100) * canvasH / 2;
      const paraLeft = centerX - halfW;
      const paraRight = centerX + halfW;
      const paraTop = centerY - halfH;
      const paraBottom = centerY + halfH;

      // Transform click point to unrotated space to check against axis-aligned bounds
      const clickUnrotated = rotatePoint(clickX, clickY, centerX, centerY, -rotation);

      if (clickUnrotated.x >= paraLeft && clickUnrotated.x <= paraRight &&
          clickUnrotated.y >= paraTop && clickUnrotated.y <= paraBottom) {
        // Find segment index within the track
        const track = timelineTracks.find(tr => tr.id === trackId);
        const segIndex = track ? track.segments.indexOf(seg) : -1;
        return { trackId, segIndex, seg };
      }
    }
    return null;
  }

  // Render text overlays on the 2D canvas
  // filter: 'all' (default), 'affected' (only post-fx affected), 'ignored' (only ignorePostFx=true)
  // imageResult: result from renderImageOverlays { rendered, baseDrawn }
  function renderTextOverlays(t, filter = 'all', imageResult = null) {
    const { dpr } = compositionSurfaceMetrics();
    const canvasW = imageOverlayCanvas.width / dpr;
    const canvasH = imageOverlayCanvas.height / dpr;
    
    const activeTexts = filterOverlays(findActiveTextSegments(t), filter);
    
    if (activeTexts.length === 0) return false;
    
    // Check if any active text uses blend modes (not 'source-over')
    const hasBlendModes = overlaysNeedBase(activeTexts);
    
    // If text has blend modes, we need the base scene content to blend with
    if (hasBlendModes) {
      const baseAlreadyDrawn = imageResult?.baseDrawn || false;
      const imagesRendered = imageResult?.rendered || false;
      
      if (!baseAlreadyDrawn) {
        if (imagesRendered) {
          // Images are on canvas but base wasn't drawn - draw base BEHIND images
          imageOverlayCtx.globalCompositeOperation = 'destination-over';
          imageOverlayCtx.drawImage(threeCanvas, 0, 0, canvasW, canvasH);
          imageOverlayCtx.globalCompositeOperation = 'source-over';
        } else {
          // No images - just draw base normally
          imageOverlayCtx.drawImage(threeCanvas, 0, 0, canvasW, canvasH);
        }
      }
      // If baseAlreadyDrawn, the canvas already has: base + images, perfect for blending
    }
    
    for (const { seg } of activeTexts) {
      // Get opacity (can be keyframed)
      let opacity = seg.opacity ?? 1.0;
      opacity = Math.max(0, Math.min(1, opacity));
      if (opacity <= 0) continue;
      
      // Calculate text area position and size
      const areaX = (seg.areaX / 100) * canvasW;
      const areaY = (seg.areaY / 100) * canvasH;
      const areaW = (seg.areaWidth / 100) * canvasW;
      const areaH = (seg.areaHeight / 100) * canvasH;
      
      // Scale font size proportionally to canvas height (reference: 1080p)
      // textScale is a multiplier for the entire text block (default 1.0)
      const scaleFactor = canvasH / 1080;
      const textScale = seg.textScale ?? 1.0;
      const fontSize = Math.round((seg.fontSize || 48) * scaleFactor * textScale);
      const padding = Math.round((seg.padding || 20) * scaleFactor);
      const letterSpacing = (seg.letterSpacing || 0) * scaleFactor * textScale;
      
      // Get font family
      const fontInfo = AVAILABLE_FONTS.find(f => f.name === seg.fontFamily);
      const fontFamily = fontInfo ? fontInfo.family : 'Inter, sans-serif';
      
      // Build font string
      const fontStyle = seg.fontStyle === 'italic' ? 'italic' : 'normal';
      const fontWeight = seg.fontWeight || 400;
      const font = `${fontStyle} ${fontWeight} ${fontSize}px ${fontFamily}`;
      
      imageOverlayCtx.save();
      imageOverlayCtx.globalAlpha = opacity;
      imageOverlayCtx.globalCompositeOperation = seg.blendMode || 'source-over';

      // Apply rotation if set
      const rotation = seg.rotation || 0;
      if (rotation !== 0) {
        // Rotate around the center of the text area
        imageOverlayCtx.translate(areaX, areaY);
        imageOverlayCtx.rotate(rotation * Math.PI / 180);
        imageOverlayCtx.translate(-areaX, -areaY);
      }

      // Set text styles first (needed for measurements)
      imageOverlayCtx.font = font;
      imageOverlayCtx.textAlign = seg.textAlign || 'center';
      imageOverlayCtx.textBaseline = 'top';

      // Calculate text area bounds (with padding)
      const textAreaLeft = areaX - areaW / 2 + padding;
      const textAreaTop = areaY - areaH / 2 + padding;
      const textAreaWidth = areaW - padding * 2;
      const textAreaHeight = areaH - padding * 2;

      // Get text X based on alignment
      let textX;
      if (seg.textAlign === 'left') {
        textX = textAreaLeft;
      } else if (seg.textAlign === 'right') {
        textX = textAreaLeft + textAreaWidth;
      } else {
        textX = areaX; // center
      }

      // Word wrap text
      const lineHeight = fontSize * (seg.lineHeight || 1.3);
      const lines = wrapText(imageOverlayCtx, seg.text, textAreaWidth);

      // Calculate actual text dimensions
      const totalTextHeight = lines.length * lineHeight;
      let maxLineWidth = 0;
      for (const line of lines) {
        let lineWidth = imageOverlayCtx.measureText(line).width;
        if (letterSpacing !== 0) {
          lineWidth += letterSpacing * (line.length - 1);
        }
        maxLineWidth = Math.max(maxLineWidth, lineWidth);
      }

      // Calculate starting Y to vertically center text block
      let textY = areaY - totalTextHeight / 2;

      // Draw background if enabled - using actual text bounds
      if (seg.backgroundColor && seg.backgroundColor !== 'transparent') {
        const bgPadding = 12; // Padding around actual text
        // Calculate background X based on text alignment
        let bgX;
        if (seg.textAlign === 'left') {
          bgX = textX - bgPadding;
        } else if (seg.textAlign === 'right') {
          bgX = textX - maxLineWidth - bgPadding;
        } else {
          bgX = areaX - maxLineWidth / 2 - bgPadding;
        }
        imageOverlayCtx.fillStyle = seg.backgroundColor;
        imageOverlayCtx.fillRect(
          bgX,
          textY - bgPadding,
          maxLineWidth + bgPadding * 2,
          totalTextHeight + bgPadding * 2
        );
      }

      // Render each line
      imageOverlayCtx.fillStyle = seg.textColor || '#ffffff';
      let charOffset = 0; // Track character offset for kerning
      for (const line of lines) {
        // Apply letter spacing, kerning, and rich text formatting
        const hasKerning = seg.kerning && seg.kerning.length > 0;
        const hasRichText = seg.formatRanges && seg.formatRanges.length > 0;
        if (letterSpacing !== 0 || hasKerning || hasRichText) {
          drawTextWithLetterSpacing(imageOverlayCtx, line, textX, textY, letterSpacing, seg.kerning, fontSize, charOffset, seg);
        } else {
          imageOverlayCtx.fillText(line, textX, textY);
        }
        charOffset += line.length + 1; // +1 for newline character
        textY += lineHeight;
      }

      imageOverlayCtx.restore();
    }

    // Draw selection bounding box if a text layer is selected in viewport
    if (compositionOutputSource === 'live' && viewportSelectedText && viewportSelectedText.seg) {
      const selSeg = viewportSelectedText.seg;
      // Verify segment is still active at current time
      const isActive = activeTexts.some(({ seg }) => seg === selSeg);

      if (isActive) {
        const isEditing = textEditMode && viewportSelectedText.seg === selSeg;

        // Calculate paragraph bounds (used for marquee, corner handles, and edge handles)
        const centerX = (selSeg.areaX / 100) * canvasW;
        const centerY = (selSeg.areaY / 100) * canvasH;
        const halfW = ((selSeg.areaWidth || 30) / 100) * canvasW / 2;
        const halfH = ((selSeg.areaHeight || 20) / 100) * canvasH / 2;
        const paraLeft = centerX - halfW;
        const paraRight = centerX + halfW;
        const paraTop = centerY - halfH;
        const paraBottom = centerY + halfH;
        const paraWidth = paraRight - paraLeft;
        const paraHeight = paraBottom - paraTop;

        imageOverlayCtx.save();

        // Apply rotation if set
        const rotation = selSeg.rotation || 0;
        if (rotation !== 0) {
          // Rotate around the center of the text area
          imageOverlayCtx.translate(centerX, centerY);
          imageOverlayCtx.rotate(rotation * Math.PI / 180);
          imageOverlayCtx.translate(-centerX, -centerY);
        }

        if (isEditing) {
          // Edit mode: solid white border, no handles
          imageOverlayCtx.strokeStyle = 'rgba(255, 255, 255, 0.8)';
          imageOverlayCtx.lineWidth = 1;
          imageOverlayCtx.setLineDash([]);
        } else {
          // Select mode: dashed blue border
          imageOverlayCtx.strokeStyle = 'rgba(120, 180, 255, 0.9)';
          imageOverlayCtx.lineWidth = 2;
          imageOverlayCtx.setLineDash([6, 4]);
        }

        // Draw marquee using paragraph bounds
        imageOverlayCtx.strokeRect(paraLeft, paraTop, paraWidth, paraHeight);

        if (!isEditing) {
          // Draw corner handles for resizing (only when not editing)
          imageOverlayCtx.setLineDash([]);
          imageOverlayCtx.fillStyle = 'rgba(120, 180, 255, 1)';
          const handleSize = 10;
          const corners = [
            [paraLeft, paraTop],
            [paraRight, paraTop],
            [paraLeft, paraBottom],
            [paraRight, paraBottom],
          ];
          for (const [cx, cy] of corners) {
            imageOverlayCtx.fillRect(cx - handleSize / 2, cy - handleSize / 2, handleSize, handleSize);
          }

          // Draw edge handles for paragraph bounds resize
          const edgeHandleLength = 16;
          const edgeHandleThickness = 4;
          const edges = [
            // North edge (top center)
            { x: centerX - edgeHandleLength / 2, y: paraTop - edgeHandleThickness / 2, w: edgeHandleLength, h: edgeHandleThickness },
            // South edge (bottom center)
            { x: centerX - edgeHandleLength / 2, y: paraBottom - edgeHandleThickness / 2, w: edgeHandleLength, h: edgeHandleThickness },
            // West edge (left center)
            { x: paraLeft - edgeHandleThickness / 2, y: centerY - edgeHandleLength / 2, w: edgeHandleThickness, h: edgeHandleLength },
            // East edge (right center)
            { x: paraRight - edgeHandleThickness / 2, y: centerY - edgeHandleLength / 2, w: edgeHandleThickness, h: edgeHandleLength },
          ];
          for (const edge of edges) {
            imageOverlayCtx.fillRect(edge.x, edge.y, edge.w, edge.h);
          }
        }

        // Draw text selection highlight and cursor when in edit mode
        if (isEditing) {
          const scaleFactor = canvasH / 1080;
          const fontSize = Math.round((selSeg.fontSize || 48) * scaleFactor);

          // Draw selection highlight if there's a selection
          if (textEditSelection && textEditSelection.start !== textEditSelection.end) {
            imageOverlayCtx.fillStyle = 'rgba(100, 150, 255, 0.35)';
            const startIdx = Math.min(textEditSelection.start, textEditSelection.end);
            const endIdx = Math.max(textEditSelection.start, textEditSelection.end);

            // Find character positions within selection
            for (const pos of textEditCharPositions) {
              if (pos.charIndex >= startIdx && pos.charIndex < endIdx) {
                imageOverlayCtx.fillRect(pos.x, pos.y, pos.width || 8, pos.height);
              }
            }
          }

          // Draw cursor (blinking)
          if (textEditBlinkVisible) {
            const cursorPos = getCursorRenderPosition();
            if (cursorPos) {
              imageOverlayCtx.fillStyle = selSeg.textColor || '#ffffff';
              imageOverlayCtx.fillRect(cursorPos.x - 1, cursorPos.y, 2, cursorPos.height);
            }
          }
        }

        imageOverlayCtx.restore();
      } else {
        // Text is no longer visible, clear viewport selection and exit edit mode
        if (textEditMode) exitTextEditMode();
        viewportSelectedText = null;
      }
    }

    return true;
  }

  // Helper function to wrap text
  function wrapText(ctx, text, maxWidth) {
    const lines = [];
    const paragraphs = text.split('\n');
    
    for (const paragraph of paragraphs) {
      const words = paragraph.split(' ');
      let currentLine = '';
      
      for (const word of words) {
        const testLine = currentLine ? currentLine + ' ' + word : word;
        const metrics = ctx.measureText(testLine);
        
        if (metrics.width > maxWidth && currentLine) {
          lines.push(currentLine);
          currentLine = word;
        } else {
          currentLine = testLine;
        }
      }
      
      if (currentLine) {
        lines.push(currentLine);
      }
    }
    
    return lines.length > 0 ? lines : [''];
  }
  
  // Helper function to draw text with letter spacing
  // Draw text with letter spacing, optional per-character kerning, and rich text formatting
  // spacing: uniform letter spacing in pixels
  // kerning: array of kerning values in 1/1000 em units (optional)
  // fontSize: font size in pixels (required if kerning is provided)
  // charOffset: starting character index for kerning array lookup (for wrapped text)
  // seg: text segment for rich text formatting (optional)
  function drawTextWithLetterSpacing(ctx, text, x, y, spacing, kerning = null, fontSize = 0, charOffset = 0, seg = null) {
    const chars = text.split('');
    let currentX = x;
    const savedAlign = ctx.textAlign;
    const savedFont = ctx.font;

    // Helper to get character width with specific format
    const getCharWidthWithFormat = (char, charIdx) => {
      if (seg && seg.formatRanges && seg.formatRanges.length > 0) {
        const format = getCharFormat(seg, charIdx);
        ctx.font = buildFontString(format, fontSize);
      }
      return ctx.measureText(char).width;
    };

    // Calculate total width including kerning and per-character fonts
    const calcTotalWidth = () => {
      let w = 0;
      for (let i = 0; i < chars.length; i++) {
        const charIdx = charOffset + i;
        w += getCharWidthWithFormat(chars[i], charIdx);
        if (i < chars.length - 1) {
          w += spacing;
          // Add kerning if provided
          if (kerning && fontSize > 0) {
            const kernValue = kerning[charIdx] || 0;
            w += (kernValue / 1000) * fontSize;
          }
        }
      }
      // Restore font after measuring
      ctx.font = savedFont;
      return w;
    };

    // Adjust starting position based on alignment
    if (ctx.textAlign === 'center') {
      currentX = x - calcTotalWidth() / 2;
      ctx.textAlign = 'left';
    } else if (ctx.textAlign === 'right') {
      currentX = x - calcTotalWidth();
      ctx.textAlign = 'left';
    }

    // Draw characters one by one
    let lastFormat = null;
    for (let i = 0; i < chars.length; i++) {
      const charIdx = charOffset + i;

      // Check if we need to change font for this character (rich text)
      if (seg && seg.formatRanges && seg.formatRanges.length > 0) {
        const format = getCharFormat(seg, charIdx);
        if (!formatEquals(format, lastFormat)) {
          ctx.font = buildFontString(format, fontSize);
          lastFormat = format;
        }
      }

      ctx.fillText(chars[i], currentX, y);
      currentX += ctx.measureText(chars[i]).width;

      // Add spacing between characters (not after last)
      if (i < chars.length - 1) {
        currentX += spacing;
        // Add kerning adjustment
        if (kerning && fontSize > 0) {
          const kernValue = kerning[charIdx] || 0;
          currentX += (kernValue / 1000) * fontSize;
        }
      }
    }

    // Restore text align and font
    ctx.textAlign = savedAlign;
    ctx.font = savedFont;
  }

  // Parse shader parameter definitions from comments
  // Format: // @param uniformName "Display Name" default min max step [group]
  // Format: // @texture uniformName "Display Name" - for texture selectors
  // Format: // @name "Effect Display Name"
  function parseShaderParams(shaderSource) {
    return parseShaderDirectives(shaderSource);
  }

  // Open file dialog to select a GLSL shader
  function openPostFxFileDialog(segIndex, removeOnCancel = false) {
    const postfxTracks = getTracksByType('postfx');
    const track = postfxTracks[0];
    if (!track) return;
    
    // Open Asset Browser for shader selection
    openAssetBrowser('postfx', async (asset) => {
      // If cancelled or no asset, potentially remove the empty segment
      if (!asset) {
        if (removeOnCancel && segIndex < track.segments.length) {
          const seg = track.segments[segIndex];
          // Only remove if no shader file was ever set
          if (!seg.shaderFile) {
            track.segments.splice(segIndex, 1);
            renderTracks();
            scheduleTimelineAutoSave();
            console.log('Removed empty post-fx segment (user cancelled)');
          }
        }
        return;
      }
      
      try {
        // Load the shader from assets folder
        const response = await fetch('/' + asset.path);
        const shaderSource = await response.text();
        const filename = asset.name;
        
        // Parse shader parameters from comments
        const { effectName, params } = parseShaderParams(shaderSource);
        
        // Store the shader with its source and parsed parameters
        loadedPostFxShaders[filename] = {
          source: shaderSource,
          threeMaterial: null,
          effectName: effectName || filename.replace(/\.(glsl|frag|fs)$/i, ''),
          params: params,
        };
        
        // Update segment
        if (segIndex < track.segments.length) {
          saveUndoState();
          track.segments[segIndex].shaderFile = filename;
          track.segments[segIndex].name = filename.replace(/\.(glsl|frag|fs)$/i, '');
          renderTracks();
          scheduleTimelineAutoSave();
          console.log(`Loaded post-fx shader: ${filename}`);
        }
      } catch (err) {
        console.error('Failed to load shader:', err);
        alert('Failed to load shader: ' + asset.name);
      }
    });
  }
  
  // Legacy function for file input (keeping for backwards compatibility)

  // =========================
  // TRACK-SPECIFIC FILE DIALOGS
  // =========================
  
  function openSceneFileDialogForTrack(trackId, segIndex, removeOnCancel = false) {
    const track = getTrackById(trackId);
    if (!track || track.type !== 'scene') return;
    
    // Store pending segment info for "New Scene" button to use
    if (removeOnCancel) {
      pendingNewSceneSegment = { trackId, segIndex };
    } else {
      pendingNewSceneSegment = null;
    }
    
    openAssetBrowser('scenes', async (asset) => {
      // Clear pending segment when callback is invoked
      pendingNewSceneSegment = null;
      
      if (!asset) {
        if (removeOnCancel && segIndex < track.segments.length && !track.segments[segIndex].sceneFile) {
          track.segments.splice(segIndex, 1);
          renderTracks();
          scheduleTimelineAutoSave();
        }
        return;
      }
      
      const seg = track.segments[segIndex];
      if (!seg) return;
      
      try {
        // Release old scene instance if exists
        releaseSegmentSceneInstance(trackId, segIndex);
        
        // Load scene from file to loadedScenes (for legacy compatibility)
        const newIdx = await loadSceneFromFile(asset.path);
        
        saveUndoState();
        seg.sceneFile = asset.path;
        seg.sceneIndex = newIdx;
        seg.name = asset.name.replace(/\.json$/i, '');
        
        // Create a unique scene instance for this segment
        await getOrCreateSegmentSceneInstance(trackId, segIndex, seg);
        
        renderTracks();
        selectSegmentInTrack(trackId, segIndex);
        scheduleTimelineAutoSave();
      } catch (err) {
        console.error('Failed to load scene:', err);
        alert('Failed to load scene: ' + asset.name);
      }
    });
  }
  
  function openShaderFileDialogForTrack(trackId, segIndex, removeOnCancel = false) {
    const track = getTrackById(trackId);
    if (!track || track.type !== 'shader') return;
    
    openAssetBrowser('shader', async (asset) => {
      if (!asset) {
        if (removeOnCancel && segIndex < track.segments.length && !track.segments[segIndex].shaderFile) {
          track.segments.splice(segIndex, 1);
          renderTracks();
          scheduleTimelineAutoSave();
        }
        return;
      }
      
      const seg = track.segments[segIndex];
      if (!seg) return;
      
      seg.shaderFile = asset.path;
      seg.name = asset.name.replace(/\.(glsl|frag|fs)$/i, '');
      await loadShaderEffect(seg.shaderFile);
      renderTracks();
      selectSegmentInTrack(trackId, segIndex);
      scheduleTimelineAutoSave();
    });
  }
  
  function openImageFileDialogForTrack(trackId, segIndex, removeOnCancel = false) {
    const track = getTrackById(trackId);
    if (!track || track.type !== 'media') return;
    
    openAssetBrowser('media', async (asset) => {
      if (!asset) {
        if (removeOnCancel && segIndex < track.segments.length && !track.segments[segIndex].imageFile) {
          track.segments.splice(segIndex, 1);
          renderTracks();
          scheduleTimelineAutoSave();
        }
        return;
      }
      
      const seg = track.segments[segIndex];
      if (!seg) return;
      
      try {
        await loadMedia(asset.path);
        saveUndoState();
        seg.imageFile = asset.path;
        seg.isVideo = isVideoFile(asset.path);
        seg.name = asset.name.replace(/\.(png|jpg|jpeg|webp|gif|mp4|webm|mov|ogg|m4v)$/i, '');
        renderTracks();
        selectSegmentInTrack(trackId, segIndex);
        scheduleTimelineAutoSave();
      } catch (err) {
        console.error('Failed to load media:', err);
        alert('Failed to load media: ' + asset.name);
      }
    });
  }
  
  function openPostFxFileDialogForTrack(trackId, segIndex, removeOnCancel = false) {
    const track = getTrackById(trackId);
    if (!track || track.type !== 'postfx') return;
    
    openAssetBrowser('postfx', async (asset) => {
      if (!asset) {
        if (removeOnCancel && segIndex < track.segments.length && !track.segments[segIndex].shaderFile) {
          track.segments.splice(segIndex, 1);
          renderTracks();
          scheduleTimelineAutoSave();
        }
        return;
      }
      
      const seg = track.segments[segIndex];
      if (!seg) return;
      
      try {
        const response = await fetch('/' + asset.path);
        const shaderSource = await response.text();
        const filename = asset.name;
        
        const { effectName, params } = parseShaderParams(shaderSource);
        
        loadedPostFxShaders[filename] = {
          source: shaderSource,
          threeMaterial: null,
          effectName: effectName || filename.replace(/\.(glsl|frag|fs)$/i, ''),
          params: params,
        };
        
        saveUndoState();
        seg.shaderFile = filename;
        seg.name = filename.replace(/\.(glsl|frag|fs)$/i, '');
        renderTracks();
        selectSegmentInTrack(trackId, segIndex);
        scheduleTimelineAutoSave();
      } catch (err) {
        console.error('Failed to load shader:', err);
        alert('Failed to load shader: ' + asset.name);
      }
    });
  }

  // Find active shader effects at time t (across all shader tracks, in track order)
  function findActiveShaders(t) {
    const active = [];
    const records = activeTrackSegments(getSortedTracks(), t, {
      includeTypes: ['shader'], requiredFields: { shader: 'shaderFile' },
    });
    for (const { track, segment: seg } of records) {
      const shader = loadedShaders[seg.shaderFile];
      if (shader) {
        active.push({ seg, shader, trackId: track.id });
      }
    }
    return active;
  }
  
  // =========================
  // UNIFIED LAYER SYSTEM
  // =========================
  // All renderable layers (scenes, shaders, media) sorted by track order
  // This enables proper compositing order across all layer types
  
  /**
   * Get all active layers at time t, sorted by track order (ascending)
   * Lower order = rendered first = appears at bottom of visual stack
   * Higher order = rendered last = appears on top
   * 
   * @param {number} t - Current timeline time
   * @returns {Array<{type: string, trackId: string, order: number, seg: object, data: object}>}
   */
  function getActiveLayers(t) {
    const layers = [];
    
    const records = activeTrackSegments(getSortedTracks(), t, {
      excludeTypes: ['postfx', 'lut'],
    });
    for (const { track, segment: seg, segmentIndex: i } of records) {
        
        // Build layer object based on type
        const layer = {
          type: track.type,
          trackId: track.id,
          order: track.order,
          seg: seg,
          segIndex: i,
          data: null
        };
        
        // Attach type-specific data
        if (track.type === 'scene') {
          // Scene layer - use segment-specific instance
          const key = getSegmentKey(track.id, i);
          const sceneInstance = segmentSceneInstances.get(key);
          
          if (sceneInstance) {
            layer.data = {
              sceneIndex: seg.sceneIndex, // Keep for compatibility
              sceneObj: sceneInstance
            };
            layers.push(layer);
          } else {
            // Fallback to legacy loadedScenes if segment instance not yet created
            const sceneIdx = seg.sceneIndex;
            if (sceneIdx >= 0 && sceneIdx < loadedScenes.length && loadedScenes[sceneIdx] !== null) {
              layer.data = {
                sceneIndex: sceneIdx,
                sceneObj: loadedScenes[sceneIdx]
              };
              layers.push(layer);
              
              // Trigger async creation of segment instance for next frame
              ensureSegmentSceneInstance(track.id, i, seg);
            }
          }
        } else if (track.type === 'shader') {
          // Shader layer
          if (seg.shaderFile) {
            const shader = loadedShaders[seg.shaderFile];
            if (shader) {
              layer.data = { shader };
              layers.push(layer);
            }
          }
        } else if (track.type === 'media') {
          // Media (image/video) layer
          if (seg.imageFile) {
            const media = loadedImages[seg.imageFile];
            if (media) {
              layer.data = { media };
              layers.push(layer);
            }
          }
        }
    }
    
    // Sort by order (should already be sorted, but ensure it)
    layers.sort((a, b) => a.order - b.order);
    
    return layers;
  }
  
  /**
   * Find all active scene layers at time t
   * @param {number} t - Current timeline time
   * @returns {Array} Active scene layers sorted by order
   */
  
  /**
   * Render a scene to its render target
   * @param {object} layer - Scene layer from getActiveLayers
   * @param {number} t - Current timeline time
   * @returns {THREE.WebGLRenderTarget} The render target containing the scene
   */
  function renderSceneToTarget(layer, t, isFirstScene = false) {
    if (layer.type !== 'scene' || !layer.data?.sceneObj) return null;
    
    const { sceneObj, sceneIndex } = layer.data;
    // Additional null check for safety (sceneObj might be null if scene wasn't loaded)
    if (!sceneObj || !sceneObj.threeScene || !sceneObj.camera) return null;
    
    const { threeScene, camera, data, grassMesh } = sceneObj;
    
    // Get or create render target for this scene track
    const renderTarget = getSceneRenderTarget(layer.trackId);
    
    // Calculate scene-local time
    const sceneTime = t - layer.seg.start;
    
    // Animate scene objects
    animateScene(threeScene, data, sceneTime, t);
    
    // Animate camera
    if (data.camera?.animation) {
      animateCamera(camera, data.camera, sceneTime, t);
    }
    
    // Animate lights
    if (sceneObj.animatedLights) {
      animateLights(sceneObj.animatedLights, sceneTime, t);
      
      // Sync animated light positions to voxel shader
      if (sceneObj.voxelMesh?.material?.uniforms) {
        const vu = sceneObj.voxelMesh.material.uniforms;
        for (let li = 0; li < sceneObj.animatedLights.length && li < 2; li++) {
          const light = sceneObj.animatedLights[li];
          const idx = li + 1;
          if (vu[`uPointLight${idx}Pos`]) {
            vu[`uPointLight${idx}Pos`].value.copy(light.position);
          }
          if (vu[`uPointLight${idx}Color`]) {
            vu[`uPointLight${idx}Color`].value.copy(light.color);
          }
          if (vu[`uPointLight${idx}Intensity`]) {
            vu[`uPointLight${idx}Intensity`].value = light.intensity;
          }
          if (vu[`uPointLight${idx}Distance`]) {
            vu[`uPointLight${idx}Distance`].value = light.distance || 50;
          }
        }
      }
    }
    
    animateSceneSystems(sceneObj, t, {
      sceneTime,
      segmentStart: layer.seg.start,
      segmentEnd: layer.seg.end,
      viewportHeight: renderTarget.height,
      audioData: window.currentAudioData,
    });
    
    // Update displacement materials and sync to grass
    let tunnelDispMat = null;
    threeScene.traverse((obj) => {
      if (obj.userData?.displacementMaterial?.uniforms?.uTime) {
        obj.userData.displacementMaterial.uniforms.uTime.value = t;
        tunnelDispMat = obj.userData.displacementMaterial;
      }
      if (obj.material?.uniforms?.uTime && obj.material?.uniforms?.uDisplacementStrength) {
        obj.material.uniforms.uTime.value = t;
        tunnelDispMat = obj.material;
      }
    });
    
    if (tunnelDispMat && grassMesh?.material?.uniforms?.uDispStrength) {
      const gu = grassMesh.material.uniforms;
      const tu = tunnelDispMat.uniforms;
      gu.uDispStrength.value = tu.uDisplacementStrength?.value || 0;
      gu.uDispFrequency.value = tu.uDisplacementFrequency?.value || 2.0;
      gu.uDispSpeed.value = tu.uDisplacementSpeed?.value || 0.5;
      gu.uDispNoiseScale.value = tu.uNoiseScale?.value || 1.0;
    }
    
    // Later scene layers are transparent automatically. The explicit per-clip
    // toggle also lets the first/only scene sit cleanly over a shader layer.
    const needsTransparency = sceneBackgroundIsTransparent(layer.seg, isFirstScene);
    
    // Temporarily remove background for transparency if needed
    const originalBackground = threeScene.background;
    if (needsTransparency && originalBackground) {
      threeScene.background = null;
    }
    
    // Render scene to its render target
    renderer.setRenderTarget(renderTarget);
    renderer.setClearColor(0x000000, 0); // Clear with transparent black for alpha compositing
    renderer.clear();
    renderer.render(threeScene, camera);
    
    // Restore background
    if (needsTransparency && originalBackground) {
      threeScene.background = originalBackground;
    }
    
    return renderTarget;
  }
  
  /**
   * Render a shader layer to the target
   * @param {object} layer - Shader layer from getActiveLayers
   * @param {number} t - Current timeline time  
   * @param {THREE.WebGLRenderTarget|null} targetRT - Target to render to (null = screen)
   */
  function renderShaderLayer(layer, t, targetRT = null) {
    if (layer.type !== 'shader' || !layer.data?.shader) return;
    
    const { shader } = layer.data;
    const seg = layer.seg;
    
    const material = createShaderMaterial(shader, seg);
    if (!material) return;
    
    const w = targetRT ? targetRT.width : threeCanvas.width;
    const h = targetRT ? targetRT.height : threeCanvas.height;
    
    updateShaderMaterial(material, seg, shader, t, w, h);
    
    window.shaderOverlayQuad.material = material;
    
    const autoClear = renderer.autoClear;
    renderer.autoClear = false;
    renderer.setRenderTarget(targetRT);
    renderer.render(window.shaderOverlayScene, window.shaderOverlayCamera);
    renderer.autoClear = autoClear;
  }
  
  /**
   * Render all layers in unified order (scenes, shaders interleaved by track order)
   * Media is still handled via 2D canvas overlay for blend mode support
   * 
   * @param {number} t - Current timeline time
   * @param {THREE.WebGLRenderTarget|null} targetRT - Render target (null = screen)
   * @returns {boolean} True if any content was rendered
   */
  function renderUnifiedLayers(t, targetRT = null, options = {}) {
    const activeLayers = getActiveLayers(t);
    const shaderLayers = splitPostFxShaderLayers(activeLayers);
    const layers = options.shaderPass === 'affected'
      ? shaderLayers.affected
      : options.shaderPass === 'ignored'
        ? shaderLayers.ignored
        : activeLayers;
    const result = renderWebGlLayerPlan(layers, {
      clear: () => {
        renderer.setRenderTarget(targetRT);
        renderer.setClearColor(0x000000, 1);
        renderer.clear();
      },
      renderScene: (layer, isFirstScene) => renderSceneToTarget(layer, t, isFirstScene)?.texture || null,
      compositeScene: (texture, opacity, blendMode) => {
        compositeSceneTexture(texture, opacity, blendMode, targetRT);
      },
      renderShader: layer => {
        ensureShaderOverlayScene();
        renderShaderLayer(layer, t, targetRT);
      },
    });
    if (!result.rendered) return false;

    if (result.sceneLayers.length > 0 && helpersEnabled && !fullscreenController.isFullscreen()) {
      updateBoxHelpers();
    }
    currentSceneIndex = result.sceneIndex;
    return true;
  }

  function renderIgnoredShaderLayers(t) {
    const { ignored } = splitPostFxShaderLayers(getActiveLayers(t));
    for (const layer of ignored) {
      ensureShaderOverlayScene();
      renderShaderLayer(layer, t, null);
    }
    return ignored.length;
  }

  /**
   * Apply post-fx chain to render target A, output to screen or target
   * @param {number} t - Current timeline time
   * @param {Array} activePostFx - Active post-fx effects
   */

  // =========================
  // LUT RENDERING
  // =========================

  // LUT shader material (created once, uniforms updated per segment)
  let lutShaderMaterial = null;

  function createLUTMaterial() {
    if (lutShaderMaterial) return lutShaderMaterial;

    lutShaderMaterial = new THREE.ShaderMaterial({
      uniforms: {
        uTexture: { value: null },        // Input frame
        uLUT: { value: null },            // 3D LUT texture
        uLUTSize: { value: 17.0 },        // LUT dimension
        uIntensity: { value: 1.0 },       // Blend with original
        // Lift (shadows) - RGB offsets
        uLiftR: { value: 0.0 },
        uLiftG: { value: 0.0 },
        uLiftB: { value: 0.0 },
        // Gamma (midtones) - RGB power
        uGammaR: { value: 1.0 },
        uGammaG: { value: 1.0 },
        uGammaB: { value: 1.0 },
        // Gain (highlights) - RGB multiply
        uGainR: { value: 1.0 },
        uGainG: { value: 1.0 },
        uGainB: { value: 1.0 },
      },
      vertexShader: `
        varying vec2 vUv;
        void main() {
          vUv = uv;
          gl_Position = vec4(position, 1.0);
        }
      `,
      fragmentShader: `
        precision highp float;
        precision highp sampler3D;

        uniform sampler2D uTexture;
        uniform sampler3D uLUT;
        uniform float uLUTSize;
        uniform float uIntensity;
        uniform float uLiftR, uLiftG, uLiftB;
        uniform float uGammaR, uGammaG, uGammaB;
        uniform float uGainR, uGainG, uGainB;

        varying vec2 vUv;

        // Apply LUT lookup with trilinear interpolation
        vec3 applyLUT(vec3 color, sampler3D lut, float size) {
          // Clamp and scale to LUT coordinates
          vec3 scale = vec3((size - 1.0) / size);
          vec3 offset = vec3(0.5 / size);
          return texture(lut, clamp(color, 0.0, 1.0) * scale + offset).rgb;
        }

        // Apply Lift/Gamma/Gain color correction
        vec3 applyLiftGammaGain(vec3 color) {
          // Lift (add to shadows)
          vec3 lift = vec3(uLiftR, uLiftG, uLiftB);
          color = color + lift * (1.0 - color);

          // Gamma (power curve for midtones)
          vec3 gamma = vec3(1.0 / max(0.01, uGammaR), 1.0 / max(0.01, uGammaG), 1.0 / max(0.01, uGammaB));
          color = pow(max(color, vec3(0.0)), gamma);

          // Gain (multiply highlights)
          vec3 gain = vec3(uGainR, uGainG, uGainB);
          color = color * gain;

          return clamp(color, 0.0, 1.0);
        }

        void main() {
          vec4 original = texture2D(uTexture, vUv);

          // Apply LUT
          vec3 graded = applyLUT(original.rgb, uLUT, uLUTSize);

          // Apply Lift/Gamma/Gain on top
          graded = applyLiftGammaGain(graded);

          // Blend with original based on intensity
          vec3 result = mix(original.rgb, graded, uIntensity);

          gl_FragColor = vec4(result, original.a);
        }
      `,
    });

    return lutShaderMaterial;
  }

  /**
   * Find active LUT segments at time t
   */

  /**
   * Apply active LUTs to the current frame
   * Called after post-fx chain, before "ignored" media
   */

  // =========================
  // UNIFIED EFFECTS CHAIN
  // =========================
  // Combines PostFX and LUTs into a single ordered chain based on track order

  /**
   * Find all active effects (PostFX and LUTs) at time t, sorted by track order
   * @param {number} t - Current timeline time
   * @returns {Array<{type: 'postfx'|'lut', seg: object, shader?: object, lut?: object, trackId: string, order: number}>}
   */
  function findActiveEffects(t) {
    const records = activeTrackSegments(getSortedTracks(), t, {
      includeTypes: ['postfx', 'lut'],
      requiredFields: { postfx: 'shaderFile', lut: 'lutFile' },
    });
    return discoverEffects(records, loadedPostFxShaders, loadedLUTs);
  }

  /**
   * Apply a single PostFX effect
   * @param {object} effect - Effect object with seg and shader
   * @param {number} t - Current time
   * @param {THREE.WebGLRenderTarget} sourceTarget - Source render target
   * @param {THREE.WebGLRenderTarget} destTarget - Destination render target
   * @param {boolean} isLast - Whether this is the last effect in the chain
   * @returns {Array} [newSource, newDest] after ping-pong swap
   */
  function applySinglePostFx(effect, t, sourceTarget, destTarget, isLast) {
    const { seg, shader } = effect;

    const material = createPostFxMaterial(shader, seg);
    if (!material) return [sourceTarget, destTarget];

    configurePostFxMaterial(
      material,
      { ...effect, sourceTexture: sourceTarget.texture },
      t,
      renderTargetA.width,
      renderTargetA.height
    );

    postFxQuad.material = material;
    renderer.setRenderTarget(isLast ? null : destTarget);
    renderer.render(postFxScene, postFxCamera);

    return [destTarget, sourceTarget]; // Ping-pong swap
  }

  /**
   * Apply a single LUT effect
   * @param {object} effect - Effect object with seg and lut
   * @param {number} t - Current time
   * @param {THREE.WebGLRenderTarget} sourceTarget - Source render target
   * @param {THREE.WebGLRenderTarget} destTarget - Destination render target
   * @param {boolean} isLast - Whether this is the last effect in the chain
   * @returns {Array} [newSource, newDest] after ping-pong swap
   */
  function applySingleLUT(effect, t, sourceTarget, destTarget, isLast) {
    const material = createLUTMaterial();
    if (!material) return [sourceTarget, destTarget];

    configureLutMaterial(material, { ...effect, sourceTexture: sourceTarget.texture });

    postFxQuad.material = material;
    renderer.setRenderTarget(isLast ? null : destTarget);
    renderer.render(postFxScene, postFxCamera);

    return [destTarget, sourceTarget]; // Ping-pong swap
  }

  /**
   * Apply unified effects chain (PostFX and LUTs in track order)
   * @param {number} t - Current timeline time
   * @param {Array} effects - Array of effects from findActiveEffects()
   */
  function applyEffectsChain(t, effects) {
    if (!effects || effects.length === 0) return;
    if (!renderTargetA) return;

    runEffectChain(effects, renderTargetA, renderTargetB, (effect, sourceTarget, destTarget, isLast) => {
      if (effect.type === 'postfx') {
        return applySinglePostFx(effect, t, sourceTarget, destTarget, isLast);
      }
      return applySingleLUT(effect, t, sourceTarget, destTarget, isLast);
    });
  }

  // Create or get a Three.js material for a shader effect
  function createShaderMaterial(shader, seg) {
    if (!shader || !shader.source) return null;
    
    // If material already cached, just load textures for this segment and return
    if (shader.material) {
      // Material already exists, but still load textures for this specific segment
      if (seg && seg.textures && Object.keys(seg.textures).length > 0) {
        setTimeout(() => loadShaderSegmentTextures(seg), 0);
      }
      return shader.material;
    }
    
    // Create new material
    // Build the fragment shader with Shadertoy compatibility
    const fragmentShader = `
      precision highp float;
      
      uniform float iTime;
      uniform float iSceneTime;
      uniform vec3 iResolution;
      uniform float iSceneStart;
      uniform float iSceneEnd;
      uniform sampler2D iChannel0; // Previous frame / scene texture
      uniform float uOpacity; // Master opacity control
      
      // Audio-reactive uniforms
      uniform float iBass;
      uniform float iMid;
      uniform float iTreble;
      uniform float iVolume;
      
      // Shader-specific uniforms
      ${(shader.params || []).map(p => `uniform float ${p.uniform};`).join('\n')}
      
      // Texture uniforms
      ${(shader.textureParams || []).map(p => `uniform sampler2D ${p.uniform};`).join('\n')}
      
      varying vec2 vUv;
      
      ${shader.source}
      
      void main() {
        vec2 fragCoord = vUv * iResolution.xy;
        vec4 outColor;
        mainImage(outColor, fragCoord);
        outColor.a *= uOpacity;
        gl_FragColor = outColor;
      }
    `;
    
    const vertexShader = `
      varying vec2 vUv;
      void main() {
        vUv = uv;
        gl_Position = vec4(position, 1.0);
      }
    `;
    
    // Build uniforms object
    const uniforms = {
      iTime: { value: 0 },
      iSceneTime: { value: 0 },
      iResolution: { value: new THREE.Vector3() },
      iSceneStart: { value: 0 },
      iSceneEnd: { value: 10 },
      iChannel0: { value: null },
      uOpacity: { value: 1.0 },
      // Audio-reactive uniforms
      iBass: { value: 0 },
      iMid: { value: 0 },
      iTreble: { value: 0 },
      iVolume: { value: 0 }
    };
    
    // Add shader-specific uniforms
    for (const param of shader.params || []) {
      uniforms[param.uniform] = { value: param.default };
    }
    
    // Add texture uniforms with a default 1x1 white texture (so sampling never fails)
    if (!window._defaultWhiteTexture) {
      const canvas = document.createElement('canvas');
      canvas.width = 1;
      canvas.height = 1;
      const ctx = canvas.getContext('2d');
      ctx.fillStyle = 'white';
      ctx.fillRect(0, 0, 1, 1);
      window._defaultWhiteTexture = new THREE.CanvasTexture(canvas);
    }
    for (const texParam of shader.textureParams || []) {
      uniforms[texParam.uniform] = { value: window._defaultWhiteTexture };
    }
    
    try {
      shader.material = new THREE.ShaderMaterial({
        vertexShader,
        fragmentShader,
        uniforms,
        transparent: true,
        depthTest: false,
        depthWrite: false
      });
      console.log('Created shader material for:', shader.effectName || 'unknown');
      
      // Load any saved textures for this segment (async, non-blocking)
      if (seg && seg.textures && Object.keys(seg.textures).length > 0) {
        setTimeout(() => loadShaderSegmentTextures(seg), 0);
      }
    } catch (e) {
      console.error('Failed to create shader material:', e);
      console.error('Fragment shader:', fragmentShader.substring(0, 500));
      return null;
    }
    
    return shader.material;
  }
  
  // Initialize shader overlay scene (shared between both render functions)
  function ensureShaderOverlayScene() {
    if (!window.shaderOverlayScene) {
      window.shaderOverlayScene = new THREE.Scene();
      window.shaderOverlayCamera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
      window.shaderOverlayQuad = new THREE.Mesh(
        new THREE.PlaneGeometry(2, 2),
        null
      );
      window.shaderOverlayScene.add(window.shaderOverlayQuad);
      console.log('Shader overlay scene initialized');
    }
  }
  
  // Update shader material with current uniforms
  function updateShaderMaterial(material, seg, shader, t, w, h) {
      const segmentTime = t - seg.start;
      
      material.uniforms.iTime.value = t;
    material.uniforms.iSceneTime.value = segmentTime;
    material.uniforms.iResolution.value.set(w, h, 1);
      material.uniforms.iSceneStart.value = seg.start;
      material.uniforms.iSceneEnd.value = seg.end;
    
    // Audio-reactive uniforms
    const audioData = window.currentAudioData || { bass: 0, mid: 0, treble: 0, volume: 0 };
    material.uniforms.iBass.value = audioData.bass || 0;
    material.uniforms.iMid.value = audioData.mid || 0;
    material.uniforms.iTreble.value = audioData.treble || 0;
    material.uniforms.iVolume.value = audioData.volume || 0;
      
      // Update shader-specific uniforms from segment
      const u = seg.uniforms || {};
      for (const param of shader.params || []) {
        if (material.uniforms[param.uniform]) {
          material.uniforms[param.uniform].value = u[param.uniform] ?? param.default;
        }
      }
      
      // Update texture uniforms from segment (textures are loaded separately)
      // The texture values are set by applyShaderTexture when the user selects them
      
    // Set up material for blending
    material.transparent = true;
    const opacity = seg.opacity ?? 1.0;
    material.opacity = opacity;
    if (material.uniforms.uOpacity) {
      material.uniforms.uOpacity.value = opacity;
    }
    material.depthTest = false;
    material.depthWrite = false;
    
    // Map blend modes to WebGL blend functions
    // All blend modes should respect opacity through alpha blending
    const blendMode = seg.blendMode || 'source-over';
    if (blendMode === 'screen') {
      // Screen blend: result = 1 - (1-dst) * (1-src), faded by opacity
      // Using SrcAlpha allows opacity to control the blend amount
      material.blending = THREE.CustomBlending;
      material.blendSrc = THREE.SrcAlphaFactor;
      material.blendDst = THREE.OneFactor;
      material.blendSrcAlpha = THREE.OneFactor;
      material.blendDstAlpha = THREE.OneMinusSrcAlphaFactor;
      material.blendEquation = THREE.AddEquation;
    } else if (blendMode === 'multiply') {
      // Multiply blend faded by opacity
      material.blending = THREE.CustomBlending;
      material.blendSrc = THREE.DstColorFactor;
      material.blendDst = THREE.OneMinusSrcAlphaFactor;
      material.blendSrcAlpha = THREE.OneFactor;
      material.blendDstAlpha = THREE.OneMinusSrcAlphaFactor;
      material.blendEquation = THREE.AddEquation;
    } else if (blendMode === 'lighten' || blendMode === 'lighter') {
      // Additive blending already respects alpha
      material.blending = THREE.AdditiveBlending;
    } else if (blendMode === 'overlay') {
      // Overlay is complex - approximate with additive at half strength
      material.blending = THREE.CustomBlending;
      material.blendSrc = THREE.SrcAlphaFactor;
      material.blendDst = THREE.OneFactor;
      material.blendSrcAlpha = THREE.OneFactor;
      material.blendDstAlpha = THREE.OneMinusSrcAlphaFactor;
      material.blendEquation = THREE.AddEquation;
    } else {
      // Normal blending (source-over) - respects opacity by default
      material.blending = THREE.NormalBlending;
    }
  }
  
  // Render shader effects to a render target (used when post-fx is active)
  
  // Render shader effects directly to screen (used when no post-fx)

  function selectSegment(track, index) {
    selectedSegment = { track, index };
    
    // Try to find and set the trackId for new system compatibility
    const trackType = track === 'scenes' ? 'scene' : track === 'shaders' ? 'shader' : track === 'images' ? 'media' : track === 'texts' ? 'text' : 'postfx';
    const tracks = getTracksByType(trackType);
    if (tracks.length > 0) {
      // Find which track contains this segment at the given index
      let segmentCount = 0;
      for (const t of tracks) {
        if (index < segmentCount + t.segments.length) {
          selectedSegment.trackId = t.id;
          selectedSegment.index = index - segmentCount;
          break;
        }
        segmentCount += t.segments.length;
      }
    }
    
    const segs = getSegmentsForTrack(track);
    const seg = segs[index];

    if (seg) {
      selName.textContent = `${seg.name} (${track})`;
      selStart.value = seg.start.toFixed(2);
      selEnd.value = seg.end.toFixed(2);

      if (track === 'scenes') {
        selScene.value = `scene:${seg.sceneIndex}`;
      } else if (track === 'shaders') {
        selScene.value = seg.shaderFile ? `file:${seg.shaderFile}` : '';
      } else if (track === 'images') {
        selScene.value = seg.imageFile ? `file:${seg.imageFile}` : '';
      } else {
        // For postfx, show the shader file name or placeholder
        selScene.value = seg.shaderFile ? `file:${seg.shaderFile}` : '';
      }
      
      renderTracks();
      
      // Update the object list panel
      populateObjectList(track, seg);
    } else {
      renderTracks();
    }
  }

  // =========================
  // OBJECT LIST PANEL
  // =========================
  function mediaReplacementTarget(segment) {
    for (const track of getTracksByType('media')) {
      const index = track.segments.findIndex(item => item === segment);
      if (index >= 0) {
        return {
          trackId: track.id,
          segmentId: segment.id || null,
          index,
        };
      }
    }
    return null;
  }

  function resolveMediaReplacementTarget(target) {
    const track = target ? getTrackById(target.trackId) : null;
    if (!track || track.type !== 'media') return null;
    let index = target.index;
    if (target.segmentId) {
      const matchingIndex = track.segments.findIndex(
        segment => segment.id === target.segmentId,
      );
      if (matchingIndex >= 0) index = matchingIndex;
    }
    const segment = track.segments[index];
    return segment ? { track, segment, index } : null;
  }

  async function replaceMediaClipAsset(target, asset) {
    if (!asset?.path) return false;
    try {
      await loadMedia(asset.path);
    } catch (error) {
      console.error('Failed to load replacement media:', error);
      showToast(`Could not load ${asset.name || 'replacement media'}`);
      return false;
    }

    const record = resolveMediaReplacementTarget(target);
    if (!record) {
      showToast('The media clip is no longer available');
      return false;
    }
    const replacement = replaceMediaSegmentSource(
      record.segment,
      asset.path,
      isVideoFile(asset.path),
    );
    if (!replacement.changed) {
      showToast('That media file is already in use');
      return false;
    }

    saveUndoState();
    record.track.segments[record.index] = replacement.segment;
    renderTracks();
    selectSegmentInTrack(record.track.id, record.index);
    scheduleTimelineAutoSave();
    showToast(`Replaced media with ${asset.name || asset.path.split('/').pop()}`);
    return true;
  }

  function replaceMediaFromLibrary(segment) {
    const target = mediaReplacementTarget(segment);
    if (!target) return;
    openAssetBrowser('media', asset => {
      if (asset) replaceMediaClipAsset(target, asset);
    });
  }

  function replaceMediaByUpload(segment) {
    const target = mediaReplacementTarget(segment);
    if (!target || !mediaReplaceFileInput) return;
    pendingMediaReplacementTarget = target;
    mediaReplaceFileInput.value = '';
    mediaReplaceFileInput.click();
  }

  function libraryReplacementTarget(segment, assetType) {
    const trackType = getTrackTypeForAsset(assetType);
    if (!trackType || trackType === 'media' || trackType === 'text') return null;
    for (const track of getTracksByType(trackType)) {
      const index = track.segments.findIndex(item => item === segment);
      if (index >= 0) {
        return {
          assetType,
          trackId: track.id,
          segmentId: segment.id || null,
          index,
        };
      }
    }
    return null;
  }

  function resolveLibraryReplacementTarget(target) {
    const track = target ? getTrackById(target.trackId) : null;
    const expectedType = target ? getTrackTypeForAsset(target.assetType) : null;
    if (!track || track.type !== expectedType) return null;
    let index = target.index;
    if (target.segmentId) {
      const matchingIndex = track.segments.findIndex(
        segment => segment.id === target.segmentId,
      );
      if (matchingIndex >= 0) index = matchingIndex;
    }
    const segment = track.segments[index];
    return segment ? { track, segment, index } : null;
  }

  async function prepareLibraryPresetSegment(assetType, asset, start) {
    const preset = await createClipForAsset(assetType, asset, start);
    if (!preset) throw new Error(`Unsupported preset type: ${assetType}`);

    if (assetType === 'scenes') {
      preset.sceneIndex = await loadSceneFromFile(asset.path);
    } else if (assetType === 'shader') {
      await loadShaderEffect(asset.path);
    } else if (assetType === 'postfx') {
      const response = await fetch(asset.path.startsWith('/') ? asset.path : `/${asset.path}`);
      if (!response.ok) throw new Error(`Could not load post effect (${response.status})`);
      const code = await response.text();
      const { effectName, params, textureParams } = parseShaderParams(code);
      loadedPostFxShaders[asset.path] = {
        source: code,
        threeMaterial: null,
        effectName: effectName || asset.name.replace(/\.(glsl|frag|fs)$/i, ''),
        params,
        textureParams: textureParams || [],
      };
    } else if (assetType === 'lut') {
      await loadLUT(asset.path);
    }

    return preset;
  }

  async function replaceLibraryClipAsset(target, asset) {
    if (!asset?.path) return false;
    const record = resolveLibraryReplacementTarget(target);
    if (!record) {
      showToast('The clip is no longer available');
      return false;
    }

    let preset;
    try {
      preset = await prepareLibraryPresetSegment(
        target.assetType,
        asset,
        record.segment.start,
      );
    } catch (error) {
      console.error('Preset replacement failed:', error);
      showToast(`Could not load ${asset.name || 'replacement preset'}`);
      return false;
    }

    const replacement = replaceLibrarySegmentPreset(record.segment, preset);
    if (!replacement.changed) return false;

    saveUndoState();
    if (record.track.type === 'scene') {
      releaseSegmentSceneInstance(record.track.id, record.index);
    }
    clearSegmentState(
      [keyframeData, exposedParams, exposedParamsMeta, curveTypes, lfoData],
      legacyTrackName(record.track.type),
      record.track.id,
      record.segment,
      record.index,
    );
    record.track.segments[record.index] = replacement.segment;
    selectedKeyframe = null;
    selectedKeyframes = [];
    selectedTrack = null;

    if (record.track.type === 'scene') {
      await getOrCreateSegmentSceneInstance(
        record.track.id,
        record.index,
        replacement.segment,
      );
    } else if (record.track.type === 'audio') {
      loadAudioClipWaveform(asset.path);
      refreshPrimaryAudioState();
      audioClipPlayback?.sync(timelineTracks, getCurrentTime(), silentPlaying);
    }

    selectSegmentInTrack(record.track.id, record.index);
    if (editorMode === 'keyframe') renderKeyframeEditor();
    scheduleTimelineAutoSave();
    showToast(`Replaced clip with ${asset.name || asset.path.split('/').pop()}`);
    return true;
  }

  function replaceLibraryClipFromLibrary(segment, assetType) {
    const target = libraryReplacementTarget(segment, assetType);
    if (!target) return;
    openAssetBrowser(assetType, asset => {
      if (asset) replaceLibraryClipAsset(target, asset);
    });
  }

  function libraryReplaceActionMarkup() {
    return `
      <div class="clip-replace-actions">
        <button type="button" id="replaceClipLibraryBtn" class="btn-small">
          Replace from library
        </button>
      </div>
    `;
  }

  function bindLibraryReplaceAction(segment, assetType) {
    document.getElementById('replaceClipLibraryBtn')?.addEventListener(
      'click',
      () => replaceLibraryClipFromLibrary(segment, assetType),
    );
  }

  function populateObjectList(track, seg) {
    selectedSceneObject = null;
    paramContent.innerHTML = '<div class="panel-empty">Select an object to edit</div>';
    
    // Guard against undefined segment
    if (!seg) {
      objectListContent.innerHTML = '<div class="panel-empty">No segment selected</div>';
      // Hide add/helper buttons
      const btnAdd = document.getElementById('btnAddSceneObject');
      const btnHelpers = document.getElementById('btnToggleHelpers');
      if (btnAdd) btnAdd.style.display = 'none';
      if (btnHelpers) btnHelpers.style.display = 'none';
      return;
    }
    
    if (track !== 'scenes') {
      currentEditingSceneIdx = -1;
    }
    
    // Show/hide the "+ Add" and "Helpers" buttons based on track
    const btnAdd = document.getElementById('btnAddSceneObject');
    const btnHelpers = document.getElementById('btnToggleHelpers');
    const isSceneTrack = track === 'scenes' && seg.sceneIndex >= 0;
    
    if (btnAdd) {
      btnAdd.style.display = isSceneTrack ? 'inline-flex' : 'none';
    }
    if (btnHelpers) {
      btnHelpers.style.display = isSceneTrack ? 'inline-flex' : 'none';
      // Update button state to reflect current helpers state
      if (isSceneTrack) {
        btnHelpers.textContent = helpersEnabled ? '👁 Helpers On' : '👁 Helpers';
        btnHelpers.style.background = helpersEnabled ? 'rgba(100, 200, 100, 0.3)' : '';
      }
    }
    
    if (track === 'images') {
      const mediaLabel = isVideoFile(seg.imageFile) ? 'Video Clip' : 'Image Overlay';
      objectListContent.innerHTML = `
        <div class="obj-group">
          <div class="obj-group-header">${mediaLabel}</div>
          <div class="obj-item selected" data-type="image" data-id="overlay">
            <span class="icon">${isVideoFile(seg.imageFile) ? '🎬' : '🖼️'}</span>
            <span class="name">${escapeHtml(seg.imageFile || 'No media')}</span>
          </div>
          <div class="media-replace-actions">
            <button type="button" id="replaceMediaLibraryBtn" class="btn-small">
              Replace from library
            </button>
            <button type="button" id="replaceMediaUploadBtn" class="btn-small">
              Replace by upload
            </button>
          </div>
        </div>
      `;
      document.getElementById('replaceMediaLibraryBtn')?.addEventListener(
        'click',
        () => replaceMediaFromLibrary(seg),
      );
      document.getElementById('replaceMediaUploadBtn')?.addEventListener(
        'click',
        () => replaceMediaByUpload(seg),
      );
      // Auto-select for params
      selectedSceneObject = { type: 'image', seg };
      populateImageParams(seg);
      return;
    }

    if (track === 'audios') {
      const waveform = audioWaveformCache.get(seg.audioFile);
      const detailSources = detailedWaveformSources(timelineTracks);
      const detailReadyCount = detailSources.filter(source => {
        const sourceWaveform = audioWaveformCache.get(source.audioFile);
        return sourceWaveform?.detailed && sourceWaveform?.peaks;
      }).length;
      const detailLoading = !!detailedWaveformBatch?.promise;
      const detailReady = detailSources.length > 0 && detailReadyCount === detailSources.length;
      const failedDetailSource = !detailLoading
        ? detailSources.find(source => audioWaveformCache.get(source.audioFile)?.detailError)
        : null;
      const detailFailed = !!failedDetailSource;
      const detailFailureMessage = failedDetailSource
        ? audioWaveformCache.get(failedDetailSource.audioFile)?.detailError?.message
        : '';
      const sampleCount = Number(waveform?.sampleCount || 0);
      const sourceDuration = Math.max(0.001, Number(seg.sourceDuration || 0.001));
      const resolutionMs = sampleCount
        ? Math.max(0.1, (sourceDuration / sampleCount) * 1000)
        : null;
      const detailStatus = detailLoading
        ? `Rendering audio source ${Math.min(
          detailReadyCount + detailedWaveformBatch.current,
          detailedWaveformBatch.total,
        )} of ${detailedWaveformBatch.total}…`
        : detailReady
          ? `${detailSources.length} source${detailSources.length === 1 ? '' : 's'} ready · selected source ${sampleCount.toLocaleString()} points · about ${resolutionMs.toFixed(1)} ms`
          : detailFailed
            ? `${detailReadyCount}/${detailSources.length} sources ready. ${escapeHtml(
              detailFailureMessage || 'The standard waveform remains available.',
            )}`
            : `Renders all ${detailSources.length} audio source${detailSources.length === 1 ? '' : 's'} for precise high-zoom syncing.`;
      const detailButtonLabel = detailLoading
        ? 'Rendering detailed waveforms…'
        : detailReady
          ? 'Detailed waveforms ready ✓'
          : detailFailed
            ? 'Try missing waveforms again'
            : `Render detailed waveform${detailSources.length === 1 ? '' : 's'}`;
      objectListContent.innerHTML = `
        <div class="obj-group">
          <div class="obj-group-header">Audio Clip</div>
          <div class="obj-item selected">
            <span class="icon">🎵</span>
            <span class="name">${escapeHtml(seg.name || seg.audioFile || 'No audio')}</span>
          </div>
          ${libraryReplaceActionMarkup()}
          <div class="audio-waveform-detail">
            <button
              type="button"
              id="renderDetailedWaveformBtn"
              class="btn-small${detailReady ? ' is-ready' : ''}"
              ${detailLoading || detailReady || !seg.audioFile ? 'disabled' : ''}
            >
              ${detailButtonLabel}
            </button>
            <div
              id="detailedWaveformStatus"
              class="audio-waveform-detail-status${detailFailed ? ' is-error' : ''}"
              role="status"
            >${detailStatus}</div>
          </div>
        </div>
      `;
      document.getElementById('renderDetailedWaveformBtn')?.addEventListener(
        'click',
        renderDetailedAudioWaveforms,
      );
      bindLibraryReplaceAction(seg, 'audio');
      populateAudioParams(seg);
      return;
    }
    
    if (track === 'postfx') {
      // For post-fx, show shader info
      const shaderPath = seg.shaderFile || 'No shader';
      const hasShader = !!seg.shaderFile;
      objectListContent.innerHTML = `
        <div class="obj-group">
          <div class="obj-group-header">Post Effect</div>
          <div class="obj-item selected" data-type="postfx" data-id="shader">
            <span class="icon">🎨</span>
            <span class="name">${shaderPath}</span>
          </div>
          ${libraryReplaceActionMarkup()}
          ${hasShader ? `
          <button id="refinePostFxBtn" class="btn" style="width:100%;margin-top:8px;background:rgba(99, 102, 241, 0.15);color:#a5b4fc;padding:8px 12px;font-size:12px;border:1px solid rgba(99, 102, 241, 0.3);border-radius:6px;cursor:pointer;display:flex;align-items:center;justify-content:center;">
            Refine with AI
          </button>
          ` : ''}
        </div>
      `;
      // Auto-select for params
      if (seg.shaderFile) {
        selectedSceneObject = { type: 'postfx', seg };
        populatePostFxParams(seg);
      }
      bindLibraryReplaceAction(seg, 'postfx');
      
      // Add refine button handler
      const refineBtn = document.getElementById('refinePostFxBtn');
      if (refineBtn && seg.shaderFile) {
        refineBtn.addEventListener('click', () => {
          openAiRefiner(seg.shaderFile, 'postfx');
        });
      }
      return;
    }
    
    if (track === 'shaders') {
      // For shaders track, show shader info
      const shaderPath = seg.shaderFile || 'No shader';
      const hasShader = !!seg.shaderFile;
      objectListContent.innerHTML = `
        <div class="obj-group">
          <div class="obj-group-header">Shader Effect</div>
          <div class="obj-item selected" data-type="shader" data-id="effect">
            <span class="icon">✨</span>
            <span class="name">${shaderPath}</span>
          </div>
          ${libraryReplaceActionMarkup()}
          ${hasShader ? `
          <button id="refineShaderBtn" class="btn" style="width:100%;margin-top:8px;background:rgba(99, 102, 241, 0.15);color:#a5b4fc;padding:8px 12px;font-size:12px;border:1px solid rgba(99, 102, 241, 0.3);border-radius:6px;cursor:pointer;display:flex;align-items:center;justify-content:center;">
            Refine with AI
          </button>
          ` : ''}
        </div>
      `;
      // Auto-select for params
      selectedSceneObject = { type: 'shader', seg };
      populateShaderParams(seg);
      bindLibraryReplaceAction(seg, 'shader');
      
      // Add refine button handler
      const refineBtn = document.getElementById('refineShaderBtn');
      if (refineBtn && seg.shaderFile) {
        refineBtn.addEventListener('click', () => {
          openAiRefiner(seg.shaderFile, 'shader');
        });
      }
      return;
    }
    
    if (track === 'texts') {
      // For text track, show text overlay info
      objectListContent.innerHTML = `
        <div class="obj-group">
          <div class="obj-group-header">Text Overlay</div>
          <div class="obj-item selected" data-type="text" data-id="overlay">
            <span class="icon">📝</span>
            <span class="name">${seg.text?.substring(0, 30) || 'Empty text'}${seg.text?.length > 30 ? '...' : ''}</span>
          </div>
        </div>
      `;
      // Auto-select for params
      selectedSceneObject = { type: 'text', seg };
      populateTextParams(seg);
      return;
    }

    if (track === 'luts') {
      // For LUT track, show LUT info
      const lutPath = seg.lutFile || 'No LUT';
      const hasLUT = !!seg.lutFile;
      objectListContent.innerHTML = `
        <div class="obj-group">
          <div class="obj-group-header">Color LUT</div>
          <div class="obj-item selected" data-type="lut" data-id="grading">
            <span class="icon">🎨</span>
            <span class="name">${lutPath}</span>
          </div>
          ${libraryReplaceActionMarkup()}
          ${hasLUT ? `
          <button id="refineLUTBtn" class="btn" style="width:100%;margin-top:8px;background:rgba(245, 158, 11, 0.15);color:#fbbf24;padding:8px 12px;font-size:12px;border:1px solid rgba(245, 158, 11, 0.3);border-radius:6px;cursor:pointer;display:flex;align-items:center;justify-content:center;">
            Refine with AI
          </button>
          ` : ''}
        </div>
      `;
      // Auto-select for params
      selectedSceneObject = { type: 'lut', seg };
      populateLUTParams(seg);
      bindLibraryReplaceAction(seg, 'lut');

      // Add refine button handler
      const refineBtn = document.getElementById('refineLUTBtn');
      if (refineBtn && seg.lutFile) {
        refineBtn.addEventListener('click', () => {
          openAiRefiner(seg.lutFile, 'lut');
        });
      }
      return;
    }

    // For scenes, list all objects
    // Try to get segment-specific scene instance first
    const sceneIdx = seg.sceneIndex;
    const scenePresetMarkup = `
      <div class="obj-group">
        <div class="obj-group-header">Scene Preset</div>
        <div class="clip-preset-name">${escapeHtml(seg.name || seg.sceneFile || 'No scene')}</div>
        ${libraryReplaceActionMarkup()}
      </div>
    `;
    let sceneObj = null;
    
    if (selectedSegment?.trackId) {
      const key = getSegmentKey(selectedSegment.trackId, selectedSegment.index);
      sceneObj = segmentSceneInstances.get(key);
    }
    
    // Fallback to legacy loadedScenes
    if (!sceneObj && sceneIdx >= 0 && sceneIdx < loadedScenes.length) {
      sceneObj = getSceneByIdx(sceneIdx);
    }
    
    if (!sceneObj || !sceneObj.data) {
      objectListContent.innerHTML = `${scenePresetMarkup}<div class="panel-empty">Scene not loaded</div>`;
      bindLibraryReplaceAction(seg, 'scenes');
      return;
    }
    
    const sceneData = sceneObj.data;
    let html = scenePresetMarkup;
    
    // Scene Settings (topmost)
    html += `
      <div class="obj-group">
        <div class="obj-group-header">Scene</div>
        <div class="obj-item" data-type="scene" data-id="settings">
          <span class="icon">⚙️</span>
          <span class="name">Scene Settings</span>
        </div>
      </div>
    `;
    
    // Camera
    if (sceneData.camera) {
      html += `
        <div class="obj-group">
          <div class="obj-group-header">Camera</div>
          <div class="obj-item" data-type="camera" data-id="camera">
            <span class="icon">📷</span>
            <span class="name">${sceneData.camera.type || 'perspective'}</span>
          </div>
        </div>
      `;
    }
    
    // Lights
    if (sceneData.lights?.length) {
      html += `<div class="obj-group"><div class="obj-group-header">Lights (${sceneData.lights.length})</div>`;
      sceneData.lights.forEach((light, i) => {
        const icon = light.type === 'directional' ? '☀️' : 
                     light.type === 'point' ? '💡' : 
                     light.type === 'spot' ? '🔦' : 
                     light.type === 'hemisphere' ? '🌤️' :
                     light.type === 'rectarea' ? '🔲' : '💡';
        // Check visibility from Three.js light object using helper
        const threeLight = findThreeLightByIndex(sceneObj, i);
        const isVisible = threeLight ? threeLight.visible !== false : (light.visible !== false);
        const visIcon = isVisible ? '👁' : '👁‍🗨';
        html += `
          <div class="obj-item" data-type="light" data-id="${i}">
            <span class="icon">${icon}</span>
            <span class="name">${light.name || light.type || 'ambient'}</span>
            <button class="obj-visibility-btn ${isVisible ? '' : 'hidden'}" data-vis-type="light" data-vis-id="${i}" title="${isVisible ? 'Hide light' : 'Show light'}">${visIcon}</button>
            <button class="obj-delete-btn" data-delete-type="light" data-delete-id="${i}" title="Remove light">✕</button>
          </div>
        `;
      });
      html += '</div>';
    }
    
    // Objects section - includes groups as folders and ungrouped objects
    const groups = sceneData.groups || [];
    const allObjects = sceneData.objects || [];
    const ungroupedObjects = allObjects.filter((obj, i) => !obj.groupId);
    
    if (groups.length || allObjects.length) {
      const totalCount = allObjects.length;
      const groupCount = groups.length;
      html += `<div class="obj-group"><div class="obj-group-header">Objects (${totalCount}${groupCount ? ` • ${groupCount} group${groupCount > 1 ? 's' : ''}` : ''})</div>`;
      
      // Helper to get object icon
      const getObjectIcon = (obj) => {
        return obj.geometry === 'gltf' ? '🗿' :
                     obj.geometry === 'sphere' ? '🔵' :
                     obj.geometry === 'box' ? '📦' :
                     obj.geometry === 'plane' ? '⬜' :
                     obj.geometry === 'cylinder' ? '🥫' :
                     obj.geometry === 'cone' ? '🔺' :
                     obj.geometry === 'torus' ? '⭕' :
                     obj.geometry === 'capsule' ? '💊' : '🔷';
      };
      
      // Helper to render an object item
      const renderObjectItem = (obj, i) => {
        const icon = getObjectIcon(obj);
        const mesh = findThreeMeshByIndex(sceneObj, i);
        const isVisible = mesh ? mesh.visible !== false : (obj.visible !== false);
        const visIcon = isVisible ? '👁' : '👁‍🗨';
        return `
          <div class="obj-item" data-type="object" data-id="${i}">
            <span class="icon">${icon}</span>
            <span class="name">${obj.name || obj.geometry || 'Object'}</span>
            <button class="obj-visibility-btn ${isVisible ? '' : 'hidden'}" data-vis-type="object" data-vis-id="${i}" title="${isVisible ? 'Hide object' : 'Show object'}">${visIcon}</button>
            <button class="obj-delete-btn" data-delete-type="object" data-delete-id="${i}" title="Remove object">✕</button>
          </div>
        `;
      };
      
      // Render groups as folders first
      groups.forEach((group) => {
        const threeGroup = sceneObj.threeGroups?.[group.id];
        const isVisible = threeGroup ? threeGroup.visible !== false : true;
        const visIcon = isVisible ? '👁' : '👁‍🗨';
        const isExpanded = expandedGroups[`${sceneIdx}_${group.id}`] || false;
        
        // Find objects belonging to this group
        const groupObjects = allObjects
          .map((obj, i) => ({ obj, index: i }))
          .filter(item => item.obj.groupId === group.id);
        
        html += `
          <div class="obj-group-folder ${isExpanded ? 'expanded' : ''}" data-group-id="${group.id}">
            <div class="obj-group-folder-header" data-type="group" data-id="${group.id}">
              <span class="folder-toggle">▶</span>
              <span class="icon">📁</span>
              <span class="name">${group.name || 'Group'}</span>
              <span class="group-count">${groupObjects.length}</span>
              <button class="obj-visibility-btn ${isVisible ? '' : 'hidden'}" data-vis-type="group" data-vis-id="${group.id}" title="${isVisible ? 'Hide group' : 'Show group'}">${visIcon}</button>
              <button class="obj-delete-btn" data-delete-type="group" data-delete-id="${group.id}" title="Ungroup">✕</button>
            </div>
            <div class="obj-group-folder-contents">
              ${groupObjects.map(item => renderObjectItem(item.obj, item.index)).join('')}
            </div>
          </div>
        `;
      });
      
      // Render ungrouped objects
      allObjects.forEach((obj, i) => {
        if (!obj.groupId) {
          html += renderObjectItem(obj, i);
        }
      });
      
      html += '</div>';
    }
    
    // Special Effects (Grass Field, Voxel Tunnel, Sphere Cloner, Particle System, Displaced Sphere, IFS Fractals, etc.) - at the bottom
    const hasGrass = sceneData.grassField?.enabled;
    const hasVoxels = sceneData.voxelTunnel?.enabled;
    const hasSphereCloner = sceneData.sphereCloner?.enabled;
    const hasHeartField = sceneData.heartField?.enabled;
    const hasLandscape = sceneData.landscapeField?.enabled;
    const hasCloth = sceneData.clothSimulation?.enabled;
    const hasParticles = sceneData.particleSystem?.enabled;
    const hasDisplacedSphere = sceneData.displacedSphere?.enabled;
    const hasFractalTree = sceneData.fractalTree?.enabled;
    const hasSierpinski = sceneData.sierpinskiPyramid?.enabled;
    const hasMengerSponge = sceneData.mengerSponge?.enabled;
    const hasCoralGrowth = sceneData.coralGrowth?.enabled;
    const hasCrystalLattice = sceneData.crystalLattice?.enabled;
    const hasStarCrawl = sceneData.starCrawl?.enabled;
    const hasFireworks = sceneData.fireworksDisplay?.enabled;
    
    if (hasGrass || hasVoxels || hasSphereCloner || hasHeartField || hasLandscape || hasCloth || hasParticles || hasDisplacedSphere || hasFractalTree || hasSierpinski || hasMengerSponge || hasCoralGrowth || hasCrystalLattice || hasStarCrawl || hasFireworks) {
      html += `<div class="obj-group"><div class="obj-group-header">Special Effects</div>`;
      
      if (hasGrass) {
        html += `
          <div class="obj-item" data-type="grassField" data-id="grass">
            <span class="icon">🌿</span>
            <span class="name">Grass Field (${(sceneData.grassField.count/1000).toFixed(0)}k)</span>
          </div>
        `;
      }
      
      if (hasVoxels) {
        html += `
          <div class="obj-item" data-type="voxelTunnel" data-id="voxels">
            <span class="icon">🧊</span>
            <span class="name">Voxel Tunnel (${(sceneData.voxelTunnel.count/1000).toFixed(0)}k)</span>
          </div>
        `;
      }
      
      if (hasSphereCloner) {
        html += `
          <div class="obj-item" data-type="sphereCloner" data-id="cloner">
            <span class="icon">🔮</span>
            <span class="name">Sphere Cloner (${(sceneData.sphereCloner.count/1000).toFixed(0)}k)</span>
          </div>
        `;
      }

      if (hasHeartField) {
        html += `
          <div class="obj-item" data-type="heartField" data-id="hearts">
            <span class="icon">💗</span>
            <span class="name">Heart Field (${sceneData.heartField.count || 600})</span>
          </div>
        `;
      }

      if (hasLandscape) {
        const landscapeMode = {
          voxel: 'Voxel',
          tunnel: 'Tunnel',
          surface: 'Surface',
        }[sceneData.landscapeField.mode || 'surface'];
        html += `
          <div class="obj-item" data-type="landscapeField" data-id="landscape">
            <span class="icon">🏔️</span>
            <span class="name">${landscapeMode} Landscape</span>
          </div>
        `;
      }

      if (hasCloth) {
        const cloth = sceneData.clothSimulation;
        html += `
          <div class="obj-item" data-type="clothSimulation" data-id="cloth">
            <span class="icon">🧵</span>
            <span class="name">Cloth Simulation (${cloth.segmentsX || 24}×${cloth.segmentsY || 18})</span>
          </div>
        `;
      }
      
      if (hasParticles) {
        const particleCount = sceneData.particleSystem.count || 0;
        const particleCountLabel = particleCount >= 1000
          ? `${(particleCount / 1000).toFixed(particleCount >= 10000 ? 0 : 1)}k`
          : particleCount.toLocaleString();
        html += `
          <div class="obj-item" data-type="particleSystem" data-id="particles">
            <span class="icon">✨</span>
            <span class="name">Particle System (${particleCountLabel})</span>
          </div>
        `;
      }
      
      if (hasDisplacedSphere) {
        const effectName = sceneData.displacedSphere.effectType || 'noise';
        const effectLabel = {
          'noise': 'Liquid',
          'spiky': 'Spiky',
          'molten': 'Molten',
          'organic': 'Organic',
          'fractal': 'Fractal'
        }[effectName] || effectName;
        html += `
          <div class="obj-item" data-type="displacedSphere" data-id="dsphere">
            <span class="icon">🌐</span>
            <span class="name">Displaced Sphere (${effectLabel})</span>
          </div>
        `;
      }
      
      if (hasFractalTree) {
        html += `
          <div class="obj-item" data-type="fractalTree" data-id="tree">
            <span class="icon">🌳</span>
            <span class="name">Fractal Tree (depth ${sceneData.fractalTree.maxDepth || 7})</span>
          </div>
        `;
      }
      
      if (hasSierpinski) {
        html += `
          <div class="obj-item" data-type="sierpinskiPyramid" data-id="sierpinski">
            <span class="icon">🔺</span>
            <span class="name">Sierpiński Pyramid (depth ${sceneData.sierpinskiPyramid.maxDepth || 5})</span>
          </div>
        `;
      }
      
      if (hasMengerSponge) {
        html += `
          <div class="obj-item" data-type="mengerSponge" data-id="menger">
            <span class="icon">🧱</span>
            <span class="name">Menger Sponge (depth ${sceneData.mengerSponge.maxDepth || 3})</span>
          </div>
        `;
      }
      
      if (hasCoralGrowth) {
        html += `
          <div class="obj-item" data-type="coralGrowth" data-id="coral">
            <span class="icon">🪸</span>
            <span class="name">Coral Growth (depth ${sceneData.coralGrowth.maxDepth || 6})</span>
          </div>
        `;
      }
      
      if (hasCrystalLattice) {
        html += `
          <div class="obj-item" data-type="crystalLattice" data-id="crystal">
            <span class="icon">💎</span>
            <span class="name">Crystal Lattice (depth ${sceneData.crystalLattice.maxDepth || 4})</span>
          </div>
        `;
      }

      if (hasStarCrawl) {
        html += `
          <div class="obj-item" data-type="starCrawl" data-id="crawl">
            <span class="icon">🌠</span>
            <span class="name">3D Perspective Crawl</span>
          </div>
        `;
      }

      if (hasFireworks) {
        const fireworks = sceneData.fireworksDisplay;
        html += `
          <div class="obj-item" data-type="fireworksDisplay" data-id="fireworks">
            <span class="icon">🎆</span>
            <span class="name">Fireworks Display (${fireworks.shells || 12} shells)</span>
          </div>
        `;
      }
      
      html += '</div>';
    }
    
    objectListContent.innerHTML = html || '<div class="panel-empty">Empty scene</div>';
    bindLibraryReplaceAction(seg, 'scenes');
    
    // Clear multi-selection when scene changes
    selectedSceneObjects = [];
    lastClickedObjectItem = null;
    
    // Get all selectable items for multi-select (includes regular items and group folder headers)
    const allItems = Array.from(objectListContent.querySelectorAll('.obj-item, .obj-group-folder-header'));
    
    // Add double-click handlers for group folders to toggle expand/collapse
    objectListContent.querySelectorAll('.obj-group-folder').forEach(folder => {
      const header = folder.querySelector('.obj-group-folder-header');
      const groupId = folder.dataset.groupId;
      const stateKey = `${sceneIdx}_${groupId}`;
      
      // Double-click to toggle expand/collapse
      header.addEventListener('dblclick', (e) => {
        if (e.target.closest('.obj-delete-btn') || e.target.closest('.obj-visibility-btn')) return;
        
        expandedGroups[stateKey] = !expandedGroups[stateKey];
        folder.classList.toggle('expanded', expandedGroups[stateKey]);
      });
      
      // Click on toggle arrow to expand/collapse
      const toggle = header.querySelector('.folder-toggle');
      if (toggle) {
        toggle.addEventListener('click', (e) => {
          e.stopPropagation();
          expandedGroups[stateKey] = !expandedGroups[stateKey];
          folder.classList.toggle('expanded', expandedGroups[stateKey]);
        });
      }
    });
    
    // Helper to check if an item is multi-selectable (objects and lights only)
    const isMultiSelectable = (type) => ['object', 'light'].includes(type);
    
    // Helper to get item key for comparison
    const getItemKey = (item) => `${item.dataset.type}_${item.dataset.id}`;
    
    // Update visual selection state
    const updateSelectionVisuals = () => {
      allItems.forEach(item => {
        const type = item.dataset.type;
        const id = item.dataset.id;
        const isInMultiSelect = selectedSceneObjects.some(s => s.type === type && String(s.id) === String(id));
        item.classList.toggle('multi-selected', isInMultiSelect);
      });
      updateSelectionActionsBar(sceneIdx);
    };
    
    // Add click handlers for items (including folder headers)
    allItems.forEach(item => {
      item.addEventListener('click', (e) => {
        // Ignore if clicking on delete, visibility button, or folder toggle
        if (e.target.closest('.obj-delete-btn') || e.target.closest('.obj-visibility-btn') || e.target.closest('.folder-toggle')) return;
        
        const type = item.dataset.type;
        const id = item.dataset.id;
        const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0;
        const modKey = isMac ? e.metaKey : e.ctrlKey;
        
        if (isMultiSelectable(type)) {
          if (e.shiftKey && lastClickedObjectItem) {
            // Shift-click: range selection
            const startIdx = allItems.indexOf(lastClickedObjectItem);
            const endIdx = allItems.indexOf(item);
            const [from, to] = startIdx < endIdx ? [startIdx, endIdx] : [endIdx, startIdx];
            
            // Clear if not holding cmd/ctrl
            if (!modKey) {
              selectedSceneObjects = [];
            }
            
            // Select range
            for (let i = from; i <= to; i++) {
              const rangeItem = allItems[i];
              const rangeType = rangeItem.dataset.type;
              const rangeId = rangeItem.dataset.id;
              if (isMultiSelectable(rangeType)) {
                const exists = selectedSceneObjects.some(s => s.type === rangeType && String(s.id) === String(rangeId));
                if (!exists) {
                  selectedSceneObjects.push({ type: rangeType, id: rangeId, sceneIdx });
                }
              }
            }
          } else if (modKey) {
            // Cmd/Ctrl-click: toggle selection
            const idx = selectedSceneObjects.findIndex(s => s.type === type && String(s.id) === String(id));
            if (idx >= 0) {
              selectedSceneObjects.splice(idx, 1);
            } else {
              selectedSceneObjects.push({ type, id, sceneIdx });
            }
            lastClickedObjectItem = item;
          } else {
            // Normal click: single selection
            selectedSceneObjects = [{ type, id, sceneIdx }];
            lastClickedObjectItem = item;
          }
          
          updateSelectionVisuals();
        } else if (type === 'group') {
          // Group folder header clicked - clear multi-selection but allow group selection
          selectedSceneObjects = [];
          lastClickedObjectItem = null;
          updateSelectionVisuals();
        } else {
          // Non-multi-selectable items (camera, special effects, scene settings)
          selectedSceneObjects = [];
          lastClickedObjectItem = null;
          updateSelectionVisuals();
        }
        
        // Update primary selection for params panel
        allItems.forEach(i => i.classList.remove('selected'));
        item.classList.add('selected');
        selectSceneObject(sceneIdx, type, id);
      });
    });
    
    // Add click handlers for visibility buttons
    objectListContent.querySelectorAll('.obj-visibility-btn').forEach(btn => {
      btn.addEventListener('click', (e) => {
        e.stopPropagation();
        const visType = btn.dataset.visType;
        // Group IDs are strings, others are integers
        const visId = visType === 'group' ? btn.dataset.visId : parseInt(btn.dataset.visId);
        
        // If multiple selected, toggle all
        if (selectedSceneObjects.length > 1 && selectedSceneObjects.some(s => s.type === visType && String(s.id) === String(visId))) {
          toggleMultipleVisibility(sceneIdx);
        } else {
          toggleObjectVisibility(sceneIdx, visType, visId, btn);
        }
      });
    });
    
    // Add click handlers for delete buttons
    objectListContent.querySelectorAll('.obj-delete-btn').forEach(btn => {
      btn.addEventListener('click', (e) => {
        e.stopPropagation();
        const deleteType = btn.dataset.deleteType;
        const deleteId = btn.dataset.deleteId;
        
        // If multiple selected, delete all
        if (selectedSceneObjects.length > 1 && selectedSceneObjects.some(s => s.type === deleteType && String(s.id) === String(deleteId))) {
          deleteMultipleObjects(sceneIdx);
        } else {
          removeObjectFromScene(sceneIdx, deleteType, deleteId);
        }
      });
    });
  }
  
  // Selection actions bar for multi-select
  function updateSelectionActionsBar(sceneIdx) {
    // Remove existing bar
    const existingBar = objectListContent.querySelector('.selection-actions');
    if (existingBar) existingBar.remove();
    
    if (selectedSceneObjects.length <= 1) return;
    
    const bar = document.createElement('div');
    bar.className = 'selection-actions';
    bar.innerHTML = `
      <span><span class="count">${selectedSceneObjects.length}</span> selected</span>
      <button id="btnMultiToggleVis" title="Toggle visibility">👁</button>
      <button id="btnMultiGroup" title="Group (${navigator.platform.toUpperCase().indexOf('MAC') >= 0 ? '⌘' : 'Ctrl'}+G)">📦 Group</button>
      <button id="btnMultiDelete" class="danger" title="Delete selected">✕ Delete</button>
    `;
    objectListContent.insertBefore(bar, objectListContent.firstChild);
    
    bar.querySelector('#btnMultiToggleVis').addEventListener('click', () => toggleMultipleVisibility(sceneIdx));
    bar.querySelector('#btnMultiGroup').addEventListener('click', () => groupSelectedObjects(sceneIdx));
    bar.querySelector('#btnMultiDelete').addEventListener('click', () => deleteMultipleObjects(sceneIdx));
  }
  
  // Toggle visibility for multiple selected objects
  function toggleMultipleVisibility(sceneIdx) {
    if (selectedSceneObjects.length === 0) return;
    
    const sceneObj = getSceneByIdx(sceneIdx);
    if (!sceneObj) return;
    
    // Determine target visibility (toggle based on first selected)
    const first = selectedSceneObjects[0];
    let firstVisible = true;
    if (first.type === 'object') {
      const mesh = findThreeMeshByIndex(sceneObj, parseInt(first.id));
      firstVisible = mesh?.visible !== false;
    } else if (first.type === 'light') {
      const light = findThreeLightByIndex(sceneObj, parseInt(first.id));
      firstVisible = light?.visible !== false;
    }
    const newVisibility = !firstVisible;
    
    // Apply to all selected
    selectedSceneObjects.forEach(sel => {
      const idx = parseInt(sel.id);
      if (sel.type === 'object') {
        const mesh = findThreeMeshByIndex(sceneObj, idx);
        if (mesh) {
          mesh.visible = newVisibility;
          const objData = sceneObj.data.objects?.[idx];
          if (objData) objData.visible = newVisibility;
        }
      } else if (sel.type === 'light') {
        const light = findThreeLightByIndex(sceneObj, idx);
        if (light) {
          light.visible = newVisibility;
          const lightData = sceneObj.data.lights?.[idx];
          if (lightData) lightData.visible = newVisibility;
        }
      }
    });
    
    // Refresh list to update icons
    if (selectedSegment?.track === 'scenes') {
      populateObjectList('scenes', getCurrentSegment());
    }
    scheduleAutoSave(sceneIdx);
  }
  
  // Delete multiple selected objects
  function deleteMultipleObjects(sceneIdx) {
    if (selectedSceneObjects.length === 0) return;
    if (!confirm(`Delete ${selectedSceneObjects.length} selected items?`)) return;
    
    saveUndoState();
    
    // Sort by id descending so we delete from end first (avoids index shifting issues)
    const sorted = [...selectedSceneObjects].sort((a, b) => parseInt(b.id) - parseInt(a.id));
    
    // Group by type
    const objects = sorted.filter(s => s.type === 'object');
    const lights = sorted.filter(s => s.type === 'light');
    
    const sceneObj = getSceneByIdx(sceneIdx);
    if (!sceneObj) return;
    
    // Delete objects
    objects.forEach(sel => {
      const objIdx = parseInt(sel.id);
      const threeMesh = findThreeMeshByIndex(sceneObj, objIdx);
      if (threeMesh) {
        sceneObj.threeScene.remove(threeMesh);
        threeMesh.geometry?.dispose();
        threeMesh.material?.dispose?.();
      }
      if (sceneObj.data.objects?.[objIdx]) {
        sceneObj.data.objects.splice(objIdx, 1);
      }
    });
    
    // Delete lights
    lights.forEach(sel => {
      const lightIdx = parseInt(sel.id);
      const threeLight = findThreeLightByIndex(sceneObj, lightIdx);
      if (threeLight) {
        sceneObj.threeScene.remove(threeLight);
        threeLight.dispose?.();
      }
      if (sceneObj.data.lights?.[lightIdx]) {
        sceneObj.data.lights.splice(lightIdx, 1);
      }
    });
    
    // Re-index remaining objects
    let objIndex = 0;
    sceneObj.threeScene.traverse(obj => {
      if (obj.isMesh && !obj.isGrass && !obj.isVoxel && obj.userData?.objIndex !== undefined) {
        obj.userData.objIndex = objIndex++;
      }
    });
    
    let lightIndex = 0;
    sceneObj.threeScene.traverse(obj => {
      if (obj.isLight && obj.userData?.lightIndex !== undefined) {
        obj.userData.lightIndex = lightIndex++;
      }
    });
    
    // Clear selection
    selectedSceneObjects = [];
    selectedSceneObject = null;
    paramContent.innerHTML = '<div class="panel-empty">Select an object to edit</div>';
    
    // Refresh
    if (selectedSegment?.track === 'scenes') {
      populateObjectList('scenes', getCurrentSegment());
    }
    
    if (helpersEnabled) {
      createSceneHelpers(sceneIdx);
      updateHelpersVisibility(true);
    }
    
    scheduleAutoSave(sceneIdx);
  }
  
  // Group selected objects
  function groupSelectedObjects(sceneIdx) {
    if (selectedSceneObjects.length < 2) {
      alert('Select at least 2 objects to group');
      return;
    }
    
    const sceneObj = getSceneByIdx(sceneIdx);
    if (!sceneObj) return;
    
    saveUndoState();
    
    // Initialize groups array if needed
    if (!sceneObj.data.groups) {
      sceneObj.data.groups = [];
    }
    
    // Generate group ID
    const groupId = `group_${Date.now()}`;
    const groupName = `Group ${sceneObj.data.groups.length + 1}`;
    
    // Calculate center of selected objects for group pivot
    let centerX = 0, centerY = 0, centerZ = 0;
    let count = 0;
    
    selectedSceneObjects.forEach(sel => {
      let pos = null;
      if (sel.type === 'object') {
        const mesh = findThreeMeshByIndex(sceneObj, parseInt(sel.id));
        if (mesh) pos = mesh.position;
      } else if (sel.type === 'light') {
        const light = findThreeLightByIndex(sceneObj, parseInt(sel.id));
        if (light) pos = light.position;
      }
      if (pos) {
        centerX += pos.x;
        centerY += pos.y;
        centerZ += pos.z;
        count++;
      }
    });
    
    if (count > 0) {
      centerX /= count;
      centerY /= count;
      centerZ /= count;
    }
    
    // Create Three.js group
    const threeGroup = new THREE.Group();
    threeGroup.position.set(centerX, centerY, centerZ);
    threeGroup.userData.groupId = groupId;
    threeGroup.userData.isSceneGroup = true;
    sceneObj.threeScene.add(threeGroup);
    
    // Store group data
    const groupData = {
      id: groupId,
      name: groupName,
      position: { x: centerX, y: centerY, z: centerZ },
      rotation: { x: 0, y: 0, z: 0 },
      scale: { x: 1, y: 1, z: 1 },
      children: []
    };
    
    // Add children to group
    selectedSceneObjects.forEach(sel => {
      const childIdx = parseInt(sel.id);
      groupData.children.push({ type: sel.type, id: childIdx });
      
      // Move Three.js object into group
      let threeObj = null;
      if (sel.type === 'object') {
        threeObj = findThreeMeshByIndex(sceneObj, childIdx);
        // Update data
        const objData = sceneObj.data.objects?.[childIdx];
        if (objData) objData.groupId = groupId;
      } else if (sel.type === 'light') {
        threeObj = findThreeLightByIndex(sceneObj, childIdx);
        // Update data
        const lightData = sceneObj.data.lights?.[childIdx];
        if (lightData) lightData.groupId = groupId;
      }
      
      if (threeObj) {
        // Convert world position to local (relative to group center)
        const worldPos = threeObj.getWorldPosition(new THREE.Vector3());
        sceneObj.threeScene.remove(threeObj);
        threeGroup.add(threeObj);
        // Offset position to be relative to group center
        threeObj.position.set(
          worldPos.x - centerX,
          worldPos.y - centerY,
          worldPos.z - centerZ
        );
      }
    });
    
    sceneObj.data.groups.push(groupData);
    
    // Store reference to Three.js group
    if (!sceneObj.threeGroups) sceneObj.threeGroups = {};
    sceneObj.threeGroups[groupId] = threeGroup;
    
    // Clear selection and refresh
    selectedSceneObjects = [];
    if (selectedSegment?.track === 'scenes') {
      populateObjectList('scenes', getCurrentSegment());
    }
    
    scheduleAutoSave(sceneIdx);
    console.log(`Created group "${groupName}" with ${groupData.children.length} items`);
  }
  
  // Ungroup a group
  function ungroupObjects(sceneIdx, groupId) {
    const sceneObj = getSceneByIdx(sceneIdx);
    if (!sceneObj?.data?.groups) return;
    
    const groupIdx = sceneObj.data.groups.findIndex(g => g.id === groupId);
    if (groupIdx < 0) return;
    
    saveUndoState();
    
    const groupData = sceneObj.data.groups[groupIdx];
    const threeGroup = sceneObj.threeGroups?.[groupId];
    
    if (threeGroup) {
      // Move children back to scene root
      const children = [...threeGroup.children];
      children.forEach(child => {
        // Get world position before removing from group
        const worldPos = child.getWorldPosition(new THREE.Vector3());
        const worldQuat = new THREE.Quaternion();
        child.getWorldQuaternion(worldQuat);
        const worldRot = new THREE.Euler().setFromQuaternion(worldQuat);
        
        threeGroup.remove(child);
        sceneObj.threeScene.add(child);
        
        // Apply world transform
        child.position.copy(worldPos);
        child.rotation.copy(worldRot);
        
        // Update data position
        if (child.userData?.objIndex !== undefined) {
          const objData = sceneObj.data.objects?.[child.userData.objIndex];
          if (objData) {
            objData.position = { x: worldPos.x, y: worldPos.y, z: worldPos.z };
          }
        } else if (child.userData?.lightIndex !== undefined) {
          const lightData = sceneObj.data.lights?.[child.userData.lightIndex];
          if (lightData) {
            lightData.position = { x: worldPos.x, y: worldPos.y, z: worldPos.z };
          }
        }
      });
      
      // Remove group from scene
      sceneObj.threeScene.remove(threeGroup);
      delete sceneObj.threeGroups[groupId];
    }
    
    // Update data - remove groupId from children
    groupData.children.forEach(child => {
      if (child.type === 'object') {
        const objData = sceneObj.data.objects?.[child.id];
        if (objData) delete objData.groupId;
      } else if (child.type === 'light') {
        const lightData = sceneObj.data.lights?.[child.id];
        if (lightData) delete lightData.groupId;
      }
    });
    
    // Remove group from data
    sceneObj.data.groups.splice(groupIdx, 1);
    
    // Clear selection and refresh
    selectedSceneObjects = [];
    selectedSceneObject = null;
    paramContent.innerHTML = '<div class="panel-empty">Select an object to edit</div>';
    
    if (selectedSegment?.track === 'scenes') {
      populateObjectList('scenes', getCurrentSegment());
    }
    
    scheduleAutoSave(sceneIdx);
    console.log(`Ungrouped "${groupData.name}"`);
  }
  
  // Handle group/ungroup keyboard shortcut
  function handleGroupShortcut(sceneIdx) {
    // If a group is selected, ungroup it
    if (selectedSceneObject?.type === 'group') {
      ungroupObjects(sceneIdx, selectedSceneObject.id);
      return;
    }
    
    // If multiple objects selected, group them
    if (selectedSceneObjects.length >= 2) {
      groupSelectedObjects(sceneIdx);
    }
  }

  const parameterDomainController = createParameterDomainController({
    handlers: {
      camera: populateCameraParams,
      light: populateLightParams,
      object: populateObjectParams,
      group: populateGroupParams,
      grassField: populateGrassParams,
      voxelTunnel: populateVoxelParams,
      sphereCloner: populateSphereClonersParams,
      heartField: populateHeartFieldParams,
      landscapeField: populateLandscapeFieldParams,
      clothSimulation: populateClothSimulationParams,
      particleSystem: populateParticleSystemParams,
      displacedSphere: populateDisplacedSphereParams,
      fractalTree: populateFractalTreeParams,
      sierpinskiPyramid: populateSierpinskiParams,
      mengerSponge: populateMengerSpongeParams,
      coralGrowth: populateCoralGrowthParams,
      crystalLattice: populateCrystalLatticeParams,
      starCrawl: populateStarCrawlParams,
      fireworksDisplay: populateFireworksDisplayParams,
      scene: populateSceneParams,
    },
  });
  
  function selectSceneObject(sceneIdx, type, id) {
    const sceneObjForSelect = getSceneByIdx(sceneIdx);
    const sceneData = sceneObjForSelect?.data;
    if (!sceneData) return;
    
    selectedSceneObject = { sceneIdx, type, id };
    currentEditingSceneIdx = sceneIdx; // Track which scene is being edited

    parameterDomainController.show({ sceneData, sceneIdx, type, id });
  }

  // =========================
  // PARAMETER PANEL BUILDERS
  // =========================
  const {
    createColorPicker,
    createParamRow,
    createRotationXYZ,
    createScaleXYZ,
    createSlider,
    createTextureSelect,
    createXYZ,
    formatSliderValue,
  } = createParameterBuilders({
    isExposed: paramId => {
      const segId = selectedSegment ? getSegmentId(selectedSegment) : null;
      return !!(segId && isParamExposed(segId, paramId));
    },
    normalizeColor: color => color.startsWith('#') ? color : `#${new THREE.Color(color).getHexString()}`,
    isVideoFile,
  });
  
  function updateSliderThumb(slider) {
    if (!slider) return;
    const thumb = document.getElementById(slider.id + '_thumb');
    if (!thumb) return;
    
    const min = parseFloat(slider.min);
    const max = parseFloat(slider.max);
    const value = parseFloat(slider.value);
    const step = parseFloat(slider.step) || 0.1;
    
    // Calculate percentage position
    const percent = Math.max(0, Math.min(1, (value - min) / (max - min)));
    
    // Get slider width - if 0, defer update
    const sliderWidth = slider.offsetWidth;
    if (sliderWidth === 0) {
      // Defer until next frame when layout is complete
      requestAnimationFrame(() => updateSliderThumb(slider));
      return;
    }
    
    // Offset to center the thumb (accounting for thumb width)
    const thumbWidth = Math.min(32, sliderWidth * 0.4); // Cap thumb width for narrow sliders
    const offset = thumbWidth / 2;
    const position = offset + percent * (sliderWidth - thumbWidth);
    
    thumb.style.left = position + 'px';
    thumb.textContent = formatSliderValue(value, step);
  }
  
  
  function setupXYZSlider(idPrefix) {
    ['x', 'y', 'z'].forEach(axis => {
      const sliderId = `${idPrefix}_${axis}`;
      const slider = document.getElementById(sliderId);
      const minInput = document.getElementById(`${sliderId}_min`);
      const maxInput = document.getElementById(`${sliderId}_max`);
      
      if (!slider || !minInput || !maxInput) return;
      
      // Helper to update metadata range
      const updateMetaRange = () => {
        if (selectedSegment) {
          const segId = getSegmentId(selectedSegment);
          if (exposedParamsMeta[segId]?.[sliderId]) {
            exposedParamsMeta[segId][sliderId].min = parseFloat(slider.min);
            exposedParamsMeta[segId][sliderId].max = parseFloat(slider.max);
            scheduleTimelineAutoSave();
          }
        }
      };
      
      // Update slider range when min/max changes
      const updateMin = () => {
        const newMin = parseFloat(minInput.value);
        if (isNaN(newMin)) return;
        slider.min = newMin;
        slider.setAttribute('min', newMin);
        // Clamp current value if needed
        if (parseFloat(slider.value) < newMin) {
          slider.value = newMin;
          slider.dispatchEvent(new Event('input'));
        }
        // Update metadata
        updateMetaRange();
        // Redraw keyframe tracks if in keyframe mode
        if (editorMode === 'keyframe' && selectedSegment) {
          drawAllKeyframeTracks(getSegmentId(selectedSegment));
        }
        // Update thumb position after range change
        updateSliderThumb(slider);
      };
      minInput.addEventListener('input', updateMin);
      minInput.addEventListener('change', updateMin);
      
      const updateMax = () => {
        const newMax = parseFloat(maxInput.value);
        if (isNaN(newMax)) return;
        slider.max = newMax;
        slider.setAttribute('max', newMax);
        // Clamp current value if needed
        if (parseFloat(slider.value) > newMax) {
          slider.value = newMax;
          slider.dispatchEvent(new Event('input'));
        }
        // Update metadata
        updateMetaRange();
        // Redraw keyframe tracks if in keyframe mode
        if (editorMode === 'keyframe' && selectedSegment) {
          drawAllKeyframeTracks(getSegmentId(selectedSegment));
        }
        // Update thumb position after range change
        updateSliderThumb(slider);
      };
      maxInput.addEventListener('input', updateMax);
      maxInput.addEventListener('change', updateMax);
      
      // Initialize slider thumb
      updateSliderThumb(slider);
      slider.addEventListener('input', () => updateSliderThumb(slider));
    });
  }
  
  function setupScaleXYZ(idPrefix, onChange) {
    const lockCheckbox = document.getElementById(`${idPrefix}_lock`);
    const sliders = {
      x: document.getElementById(`${idPrefix}_x`),
      y: document.getElementById(`${idPrefix}_y`),
      z: document.getElementById(`${idPrefix}_z`)
    };
    
    // Setup min/max editing
    setupXYZSlider(idPrefix);
    
    // Uniform scaling logic with undo support
    ['x', 'y', 'z'].forEach(axis => {
      const slider = sliders[axis];
      if (!slider) return;
      
      // Undo support
      slider.addEventListener('mousedown', beginParamEdit);
      slider.addEventListener('focus', beginParamEdit);
      slider.addEventListener('mouseup', () => setTimeout(endParamEdit, 100));
      slider.addEventListener('blur', endParamEdit);
      
      slider.addEventListener('input', () => {
        if (lockCheckbox?.checked) {
          // Apply same value to all axes
          const val = slider.value;
          Object.values(sliders).forEach(s => {
            if (s && s !== slider) {
              s.value = val;
              updateSliderThumb(s);
            }
          });
        }
        updateSliderThumb(slider);
        if (onChange) onChange();
      });
    });
    
    // When uniform checkbox is toggled ON, sync Y and Z to X value
    if (lockCheckbox) {
      lockCheckbox.addEventListener('change', () => {
        beginParamEdit();
        if (lockCheckbox.checked && sliders.x) {
          // Sync Y and Z to X value
          const xVal = sliders.x.value;
          if (sliders.y) {
            sliders.y.value = xVal;
            updateSliderThumb(sliders.y);
          }
          if (sliders.z) {
            sliders.z.value = xVal;
            updateSliderThumb(sliders.z);
          }
          if (onChange) onChange();
        }
        setTimeout(endParamEdit, 100);
      });
    }
  }
  
  function linkSliderToNumber(sliderId) {
    const slider = document.getElementById(sliderId);
    const minInput = document.getElementById(sliderId + '_min');
    const maxInput = document.getElementById(sliderId + '_max');
    
    if (!slider) return;
    
    // Helper to update metadata range and save bounds to segment
    const updateMetaRange = () => {
      if (selectedSegment) {
        const segId = getSegmentId(selectedSegment);
        if (exposedParamsMeta[segId]?.[sliderId]) {
          exposedParamsMeta[segId][sliderId].min = parseFloat(slider.min);
          exposedParamsMeta[segId][sliderId].max = parseFloat(slider.max);
        }
        // Also save bounds to segment for persistence across UI rebuilds
        const seg = getSelectedSegmentData();
        if (seg) {
          if (!seg.sliderBounds) seg.sliderBounds = {};
          seg.sliderBounds[sliderId] = {
            min: parseFloat(slider.min),
            max: parseFloat(slider.max)
          };
        }
        scheduleTimelineAutoSave();
      }
    };

    // Helper to get the actual segment data object
    function getSelectedSegmentData() {
      if (!selectedSegment) return null;
      if (selectedSegment.trackId) {
        const track = getTrackById(selectedSegment.trackId);
        return track?.segments?.[selectedSegment.index];
      }
      return null;
    }
    
    // Link min input to slider min attribute
    if (minInput) {
      const updateMin = () => {
        const newMin = parseFloat(minInput.value);
        if (isNaN(newMin)) return;
        // Update both property and attribute
        slider.min = newMin;
        slider.setAttribute('min', newMin);
        // Clamp slider value if below new min
        if (parseFloat(slider.value) < newMin) {
          slider.value = newMin;
          slider.dispatchEvent(new Event('input'));
        }
        // Update metadata
        updateMetaRange();
        // Redraw keyframe tracks if in keyframe mode
        if (editorMode === 'keyframe' && selectedSegment) {
          drawAllKeyframeTracks(getSegmentId(selectedSegment));
        }
        // Update thumb position after range change
        updateSliderThumb(slider);
      };
      minInput.addEventListener('input', updateMin);
      minInput.addEventListener('change', updateMin);
    }
    
    // Link max input to slider max attribute
    if (maxInput) {
      const updateMax = () => {
        const newMax = parseFloat(maxInput.value);
        if (isNaN(newMax)) return;
        // Update both property and attribute
        slider.max = newMax;
        slider.setAttribute('max', newMax);
        // Clamp slider value if above new max
        if (parseFloat(slider.value) > newMax) {
          slider.value = newMax;
          slider.dispatchEvent(new Event('input'));
        }
        // Update metadata
        updateMetaRange();
        // Redraw keyframe tracks if in keyframe mode
        if (editorMode === 'keyframe' && selectedSegment) {
          drawAllKeyframeTracks(getSegmentId(selectedSegment));
        }
        // Update thumb position after range change
        updateSliderThumb(slider);
      };
      maxInput.addEventListener('input', updateMax);
      maxInput.addEventListener('change', updateMax);
    }
    
    // Initialize slider thumb if present
    updateSliderThumb(slider);
    slider.addEventListener('input', () => updateSliderThumb(slider));
  }
  
  function populateCameraParams(cam, sceneIdx) {
    const pos = cam.position || {};
    const x = pos.x ?? pos[0] ?? 0;
    const y = pos.y ?? pos[1] ?? 5;
    const z = pos.z ?? pos[2] ?? 10;
    
    // Get rotation (in degrees for UI display)
    const rot = cam.rotation || {};
    const rotX = rot.x ?? 0;
    const rotY = rot.y ?? 0;
    const rotZ = rot.z ?? 0;
    
    // Check if camera has animation that overrides position
    const hasAnimatedPosition = cam.animation?.orbit || cam.animation?.flyover || cam.animation?.tunnel;
    
    // Calculate sensible ranges
    const posRange = Math.max(50, Math.abs(x) * 2, Math.abs(y) * 2, Math.abs(z) * 2);
    
    let html = `<div class="param-group"><div class="param-group-title">Camera</div>`;
    html += createParamRow('FOV', createSlider('cam_fov', cam.fov || 60, 20, 120, 1), 'cam_fov');
    
    // Only show position controls if no animation is overriding them
    if (!hasAnimatedPosition) {
      html += createParamRow('Position', createXYZ('cam_pos', x, y, z, -posRange, posRange, 0.1, true), null);
      html += createParamRow('Rotation', createXYZ('cam_rot', rotX, rotY, rotZ, -180, 180, 1, true), null);
    }
    html += `</div>`;
    
    if (cam.animation?.orbit) {
      const orb = cam.animation.orbit;
      const target = orb.target || {};
      html += `<div class="param-group"><div class="param-group-title">Orbit Animation</div>`;
      html += createParamRow('Speed', createSlider('cam_orbit_speed', orb.speed ?? 0.5, 0, 2, 0.01), 'cam_orbit_speed');
      html += createParamRow('Radius', createSlider('cam_orbit_radius', orb.radius ?? 10, 1, 50, 0.5), 'cam_orbit_radius');
      html += createParamRow('Height', createSlider('cam_orbit_height', orb.height ?? 5, -20, 30, 0.5), 'cam_orbit_height');
      html += createParamRow('Target', createXYZ('cam_orbit_target', target.x ?? 0, target.y ?? 0, target.z ?? 0, -30, 30, 0.5, true), null);
      if (orb.verticalOscillation) {
        html += createParamRow('V. Osc Amp', createSlider('cam_orbit_vosc_amp', orb.verticalOscillation.amplitude ?? 0, 0, 20, 0.5), 'cam_orbit_vosc_amp');
        html += createParamRow('V. Osc Speed', createSlider('cam_orbit_vosc_spd', orb.verticalOscillation.speed ?? 0.3, 0, 2, 0.05), 'cam_orbit_vosc_spd');
      }
      html += `</div>`;
    }
    
    if (cam.animation?.flyover) {
      const fo = cam.animation.flyover;
      html += `<div class="param-group"><div class="param-group-title">Flyover Animation</div>`;
      html += createParamRow('Speed', createSlider('cam_fly_speed', fo.speed ?? 3, 0, 20, 0.5), 'cam_fly_speed');
      html += createParamRow('Height', createSlider('cam_fly_height', fo.height ?? 2.5, 0.5, 20, 0.5), 'cam_fly_height');
      html += createParamRow('Sway', createSlider('cam_fly_sway', fo.sway ?? 1.5, 0, 10, 0.5), 'cam_fly_sway');
      html += `</div>`;
    }
    
    if (cam.animation?.tunnel) {
      const tn = cam.animation.tunnel;
      html += `<div class="param-group"><div class="param-group-title">Tunnel Animation</div>`;
      html += createParamRow('Speed', createSlider('cam_tunnel_speed', tn.speed ?? 5, 0, 30, 0.5), 'cam_tunnel_speed');
      html += createParamRow('Wobble', createSlider('cam_tunnel_wobble', tn.wobble ?? 0.5, 0, 3, 0.1), 'cam_tunnel_wobble');
      html += createParamRow('Wobble Speed', createSlider('cam_tunnel_wobble_speed', tn.wobbleSpeed ?? 0.3, 0, 2, 0.05), 'cam_tunnel_wobble_speed');
      html += `</div>`;
    }
    
    paramContent.innerHTML = html;
    if (!hasAnimatedPosition) {
      setupXYZSlider('cam_pos');
      setupXYZSlider('cam_rot');
    }
    if (cam.animation?.orbit) {
      setupXYZSlider('cam_orbit_target');
    }
    
    // Link sliders
    ['cam_fov', 'cam_orbit_speed', 'cam_orbit_radius', 'cam_orbit_height', 
     'cam_orbit_vosc_amp', 'cam_orbit_vosc_spd',
     'cam_fly_speed', 'cam_fly_height', 'cam_fly_sway', 
     'cam_tunnel_speed', 'cam_tunnel_wobble', 'cam_tunnel_wobble_speed'].forEach(linkSliderToNumber);
    
    // Add change handlers
    addCameraParamHandlers(sceneIdx);
  }
  
  function addCameraParamHandlers(sceneIdx) {
    const handleChange = () => {
      // Get the scene object fresh each time (important for segment-specific scenes)
      const sceneObj = getSceneByIdx(sceneIdx);
      if (!sceneObj) return;
      
      const fov = parseFloat(document.getElementById('cam_fov')?.value);
      if (fov && sceneObj.camera.isPerspectiveCamera) {
        sceneObj.camera.fov = fov;
        sceneObj.camera.updateProjectionMatrix();
        sceneObj.data.camera.fov = fov;
      }
      
      // Handle camera position
      const posX = parseFloat(document.getElementById('cam_pos_x')?.value);
      const posY = parseFloat(document.getElementById('cam_pos_y')?.value);
      const posZ = parseFloat(document.getElementById('cam_pos_z')?.value);
      
      if (!isNaN(posX)) {
        sceneObj.camera.position.x = posX;
        if (sceneObj.data.camera.position) sceneObj.data.camera.position.x = posX;
      }
      if (!isNaN(posY)) {
        sceneObj.camera.position.y = posY;
        if (sceneObj.data.camera.position) sceneObj.data.camera.position.y = posY;
      }
      if (!isNaN(posZ)) {
        sceneObj.camera.position.z = posZ;
        if (sceneObj.data.camera.position) sceneObj.data.camera.position.z = posZ;
      }
      
      // Handle camera rotation (UI is in degrees, Three.js uses radians)
      const rotX = parseFloat(document.getElementById('cam_rot_x')?.value);
      const rotY = parseFloat(document.getElementById('cam_rot_y')?.value);
      const rotZ = parseFloat(document.getElementById('cam_rot_z')?.value);
      
      if (!isNaN(rotX)) {
        sceneObj.camera.rotation.x = THREE.MathUtils.degToRad(rotX);
        sceneObj.data.camera.rotation = sceneObj.data.camera.rotation || {};
        sceneObj.data.camera.rotation.x = rotX;
      }
      if (!isNaN(rotY)) {
        sceneObj.camera.rotation.y = THREE.MathUtils.degToRad(rotY);
        sceneObj.data.camera.rotation = sceneObj.data.camera.rotation || {};
        sceneObj.data.camera.rotation.y = rotY;
      }
      if (!isNaN(rotZ)) {
        sceneObj.camera.rotation.z = THREE.MathUtils.degToRad(rotZ);
        sceneObj.data.camera.rotation = sceneObj.data.camera.rotation || {};
        sceneObj.data.camera.rotation.z = rotZ;
      }
      
      // Handle orbit animation parameters
      if (sceneObj.data.camera.animation?.orbit) {
        const orbit = sceneObj.data.camera.animation.orbit;
        const orbitSpeed = parseFloat(document.getElementById('cam_orbit_speed')?.value);
        const orbitRadius = parseFloat(document.getElementById('cam_orbit_radius')?.value);
        const orbitHeight = parseFloat(document.getElementById('cam_orbit_height')?.value);
        const targetX = parseFloat(document.getElementById('cam_orbit_target_x')?.value);
        const targetY = parseFloat(document.getElementById('cam_orbit_target_y')?.value);
        const targetZ = parseFloat(document.getElementById('cam_orbit_target_z')?.value);
        const voscAmp = parseFloat(document.getElementById('cam_orbit_vosc_amp')?.value);
        const voscSpd = parseFloat(document.getElementById('cam_orbit_vosc_spd')?.value);
        
        if (!isNaN(orbitSpeed)) orbit.speed = orbitSpeed;
        if (!isNaN(orbitRadius)) orbit.radius = orbitRadius;
        if (!isNaN(orbitHeight)) orbit.height = orbitHeight;
        
        if (!isNaN(targetX) || !isNaN(targetY) || !isNaN(targetZ)) {
          orbit.target = orbit.target || {};
          if (!isNaN(targetX)) orbit.target.x = targetX;
          if (!isNaN(targetY)) orbit.target.y = targetY;
          if (!isNaN(targetZ)) orbit.target.z = targetZ;
        }
        
        if (!isNaN(voscAmp) || !isNaN(voscSpd)) {
          orbit.verticalOscillation = orbit.verticalOscillation || {};
          if (!isNaN(voscAmp)) orbit.verticalOscillation.amplitude = voscAmp;
          if (!isNaN(voscSpd)) orbit.verticalOscillation.speed = voscSpd;
        }
      }
      
      // Handle flyover animation parameters
      if (sceneObj.data.camera.animation?.flyover) {
        const flySpeed = parseFloat(document.getElementById('cam_fly_speed')?.value);
        const flyHeight = parseFloat(document.getElementById('cam_fly_height')?.value);
        const flySway = parseFloat(document.getElementById('cam_fly_sway')?.value);
        
        if (!isNaN(flySpeed)) sceneObj.data.camera.animation.flyover.speed = flySpeed;
        if (!isNaN(flyHeight)) sceneObj.data.camera.animation.flyover.height = flyHeight;
        if (!isNaN(flySway)) sceneObj.data.camera.animation.flyover.sway = flySway;
      }
      
      // Handle tunnel animation parameters
      if (sceneObj.data.camera.animation?.tunnel) {
        const tunnelSpeed = parseFloat(document.getElementById('cam_tunnel_speed')?.value);
        const tunnelWobble = parseFloat(document.getElementById('cam_tunnel_wobble')?.value);
        const tunnelWobbleSpeed = parseFloat(document.getElementById('cam_tunnel_wobble_speed')?.value);
        
        if (!isNaN(tunnelSpeed)) sceneObj.data.camera.animation.tunnel.speed = tunnelSpeed;
        if (!isNaN(tunnelWobble)) sceneObj.data.camera.animation.tunnel.wobble = tunnelWobble;
        if (!isNaN(tunnelWobbleSpeed)) sceneObj.data.camera.animation.tunnel.wobbleSpeed = tunnelWobbleSpeed;
      }
      
      // Auto-save to localStorage
      scheduleAutoSave(sceneIdx);
    };
    
    const inputs = [
      'cam_fov', 'cam_fov_num',
      'cam_pos_x', 'cam_pos_y', 'cam_pos_z',
      'cam_pos_x_num', 'cam_pos_y_num', 'cam_pos_z_num',
      'cam_rot_x', 'cam_rot_y', 'cam_rot_z',
      'cam_rot_x_num', 'cam_rot_y_num', 'cam_rot_z_num',
      'cam_orbit_speed', 'cam_orbit_speed_num',
      'cam_orbit_radius', 'cam_orbit_radius_num',
      'cam_orbit_height', 'cam_orbit_height_num',
      'cam_orbit_target_x', 'cam_orbit_target_y', 'cam_orbit_target_z',
      'cam_orbit_target_x_num', 'cam_orbit_target_y_num', 'cam_orbit_target_z_num',
      'cam_orbit_vosc_amp', 'cam_orbit_vosc_amp_num',
      'cam_orbit_vosc_spd', 'cam_orbit_vosc_spd_num',
      'cam_fly_speed', 'cam_fly_speed_num',
      'cam_fly_height', 'cam_fly_height_num',
      'cam_fly_sway', 'cam_fly_sway_num',
      'cam_tunnel_speed', 'cam_tunnel_speed_num',
      'cam_tunnel_wobble', 'cam_tunnel_wobble_num',
      'cam_tunnel_wobble_speed', 'cam_tunnel_wobble_speed_num'
    ];
    inputs.forEach(id => {
      const el = document.getElementById(id);
      if (el) {
        el.addEventListener('focus', beginParamEdit);
        el.addEventListener('mousedown', beginParamEdit);
        el.addEventListener('input', handleChange);
        el.addEventListener('blur', endParamEdit);
        el.addEventListener('mouseup', () => setTimeout(endParamEdit, 100));
      }
    });
    
    // Update slider states based on existing keyframes
    updateAllSliderKeyframeStates();
  }
  
  function populateLightParams(light, sceneIdx, lightIdx) {
    const pos = light.position || {};
    const x = pos.x ?? pos[0] ?? 0;
    const y = pos.y ?? pos[1] ?? 5;
    const z = pos.z ?? pos[2] ?? 0;
    
    // Get actual Three.js light to read current values
    const sceneObj = getSceneByIdx(sceneIdx);
    const threeLight = sceneObj ? findThreeLightByIndex(sceneObj, lightIdx) : null;
    
    // Calculate sensible ranges
    const posRange = Math.max(30, Math.abs(x) * 2, Math.abs(y) * 2, Math.abs(z) * 2);
    
    let html = `<div class="param-group"><div class="param-group-title">${light.name || light.type || 'Light'}</div>`;
    html += createParamRow('Color', createColorPicker('light_color', light.color || '#ffffff'), null);
    html += createParamRow('Intensity', createSlider('light_intensity', light.intensity ?? 1, 0, 10, 0.1), 'light_intensity');
    
    if (light.type !== 'ambient' && light.type !== 'hemisphere') {
      html += createParamRow('Position', createXYZ('light_pos', x, y, z, -posRange, posRange, 0.5, true), null);
    }
    
    if (light.type === 'hemisphere') {
      html += createParamRow('Sky', createColorPicker('light_sky', light.skyColor || '#87CEEB'), null);
      html += createParamRow('Ground', createColorPicker('light_ground', light.groundColor || '#3a5a2a'), null);
    }
    html += `</div>`;
    
    // Point light specific parameters
    if (light.type === 'point') {
      html += `<div class="param-group"><div class="param-group-title">Point Light Settings</div>`;
      html += createParamRow('Distance', createSlider('light_distance', light.distance ?? 0, 0, 100, 1), 'light_distance');
      html += createParamRow('Decay', createSlider('light_decay', light.decay ?? 2, 0, 5, 0.1), 'light_decay');
      html += `</div>`;
    }
    
    // Spot light specific parameters
    if (light.type === 'spot') {
      html += `<div class="param-group"><div class="param-group-title">Spot Light Settings</div>`;
      html += createParamRow('Distance', createSlider('light_distance', light.distance ?? 0, 0, 100, 1), 'light_distance');
      html += createParamRow('Decay', createSlider('light_decay', light.decay ?? 2, 0, 5, 0.1), 'light_decay');
      html += createParamRow('Angle', createSlider('light_angle', (light.angle ?? 0.5) * (180/Math.PI), 5, 90, 1), 'light_angle');
      html += createParamRow('Penumbra', createSlider('light_penumbra', light.penumbra ?? 0, 0, 1, 0.05), 'light_penumbra');
      html += `</div>`;
    }
    
    // RectArea light specific parameters
    if (light.type === 'rectarea') {
      html += `<div class="param-group"><div class="param-group-title">Area Light Settings</div>`;
      html += createParamRow('Width', createSlider('light_width', light.width ?? 10, 0.1, 50, 0.5), 'light_width');
      html += createParamRow('Height', createSlider('light_height', light.height ?? 10, 0.1, 50, 0.5), 'light_height');
      html += `</div>`;
    }
    
    // Directional light target
    if (light.type === 'directional' || light.type === 'spot') {
      const target = light.target || { x: 0, y: 0, z: 0 };
      const tx = target.x ?? 0;
      const ty = target.y ?? 0;
      const tz = target.z ?? 0;
      html += `<div class="param-group"><div class="param-group-title">Target</div>`;
      html += createParamRow('Target', createXYZ('light_target', tx, ty, tz, -posRange, posRange, 0.5, false), null);
      html += `</div>`;
    }
    
    // Shadow parameters for directional, point, and spot lights
    if (light.type === 'directional' || light.type === 'point' || light.type === 'spot') {
      const hasShadow = threeLight?.castShadow ?? light.castShadow ?? false;
      html += `<div class="param-group"><div class="param-group-title">Shadows</div>`;
      html += createParamRow('Cast Shadow', `
        <label style="display:flex;align-items:center;gap:8px;cursor:pointer;">
          <input type="checkbox" id="light_shadow" ${hasShadow ? 'checked' : ''} 
                 style="width:16px;height:16px;cursor:pointer;">
          <span style="color:var(--muted);font-size:11px;">${hasShadow ? 'On' : 'Off'}</span>
        </label>
      `);
      if (light.type === 'directional') {
        html += createParamRow('Shadow Size', createSlider('light_shadow_size', light.shadowCameraSize ?? 20, 5, 100, 1), 'light_shadow_size');
      }
      html += createParamRow('Shadow Bias', createSlider('light_shadow_bias', (light.shadowBias ?? -0.001) * 10000, -50, 50, 1), 'light_shadow_bias');
      html += createParamRow('Shadow Radius', createSlider('light_shadow_radius', light.shadowRadius ?? 1, 0, 10, 0.5), 'light_shadow_radius');
      html += `</div>`;
    }
    
    paramContent.innerHTML = html;
    
    // Link sliders
    const sliders = ['light_intensity'];
    if (light.type === 'point' || light.type === 'spot') {
      sliders.push('light_distance', 'light_decay');
    }
    if (light.type === 'spot') {
      sliders.push('light_angle', 'light_penumbra');
    }
    if (light.type === 'rectarea') {
      sliders.push('light_width', 'light_height');
    }
    if (light.type === 'directional' || light.type === 'point' || light.type === 'spot') {
      sliders.push('light_shadow_size', 'light_shadow_bias', 'light_shadow_radius');
    }
    sliders.forEach(linkSliderToNumber);
    
    if (light.type !== 'ambient' && light.type !== 'hemisphere') {
      setupXYZSlider('light_pos');
    }
    if (light.type === 'directional' || light.type === 'spot') {
      setupXYZSlider('light_target');
    }
    
    addLightParamHandlers(sceneIdx, lightIdx);
  }
  
  function addLightParamHandlers(sceneIdx, lightIdx) {
    const handleChange = () => {
      // Get the scene object fresh each time (important for segment-specific scenes)
      const sceneObj = getSceneByIdx(sceneIdx);
      if (!sceneObj) return;
      
      const threeLight = findThreeLightByIndex(sceneObj, lightIdx);
      const lightData = sceneObj.data.lights?.[lightIdx];
      
      if (!threeLight) return;
      
      const color = document.getElementById('light_color')?.value;
      const intensity = parseFloat(document.getElementById('light_intensity')?.value);
      
      if (color) threeLight.color.set(color);
      if (!isNaN(intensity)) threeLight.intensity = intensity;
      
      // Sync color and intensity to lightData for persistence
      if (lightData) {
        if (color) lightData.color = color;
        if (!isNaN(intensity)) lightData.intensity = intensity;
      }
      
      // Update position if applicable
      const posX = parseFloat(document.getElementById('light_pos_x')?.value);
      const posY = parseFloat(document.getElementById('light_pos_y')?.value);
      const posZ = parseFloat(document.getElementById('light_pos_z')?.value);
      if (!isNaN(posX) && threeLight.position) threeLight.position.x = posX;
      if (!isNaN(posY) && threeLight.position) threeLight.position.y = posY;
      if (!isNaN(posZ) && threeLight.position) threeLight.position.z = posZ;
      
      // Sync position to lightData for persistence
      if (lightData && (!isNaN(posX) || !isNaN(posY) || !isNaN(posZ))) {
        if (!lightData.position || typeof lightData.position !== 'object') {
          lightData.position = {};
        }
        if (!isNaN(posX)) lightData.position.x = posX;
        if (!isNaN(posY)) lightData.position.y = posY;
        if (!isNaN(posZ)) lightData.position.z = posZ;
      }
      
      // Update hemisphere light colors
      const skyColor = document.getElementById('light_sky')?.value;
      const groundColor = document.getElementById('light_ground')?.value;
      if (skyColor && threeLight.skyColor) {
        threeLight.skyColor.set(skyColor);
        if (lightData) lightData.skyColor = skyColor;
      }
      if (groundColor && threeLight.groundColor) {
        threeLight.groundColor.set(groundColor);
        if (lightData) lightData.groundColor = groundColor;
      }
      
      // Point/Spot light parameters
      const distance = parseFloat(document.getElementById('light_distance')?.value);
      const decay = parseFloat(document.getElementById('light_decay')?.value);
      if (!isNaN(distance) && threeLight.distance !== undefined) {
        threeLight.distance = distance;
        if (lightData) lightData.distance = distance;
      }
      if (!isNaN(decay) && threeLight.decay !== undefined) {
        threeLight.decay = decay;
        if (lightData) lightData.decay = decay;
      }
      
      // Spot light parameters
      const angle = parseFloat(document.getElementById('light_angle')?.value);
      const penumbra = parseFloat(document.getElementById('light_penumbra')?.value);
      if (!isNaN(angle) && threeLight.angle !== undefined) {
        threeLight.angle = angle * (Math.PI / 180);
        if (lightData) lightData.angle = threeLight.angle;
      }
      if (!isNaN(penumbra) && threeLight.penumbra !== undefined) {
        threeLight.penumbra = penumbra;
        if (lightData) lightData.penumbra = penumbra;
      }
      
      // RectArea light parameters
      const width = parseFloat(document.getElementById('light_width')?.value);
      const height = parseFloat(document.getElementById('light_height')?.value);
      if (!isNaN(width) && threeLight.width !== undefined) {
        threeLight.width = width;
        if (lightData) lightData.width = width;
      }
      if (!isNaN(height) && threeLight.height !== undefined) {
        threeLight.height = height;
        if (lightData) lightData.height = height;
      }
      
      // Target position (for directional and spot lights)
      const targetX = parseFloat(document.getElementById('light_target_x')?.value);
      const targetY = parseFloat(document.getElementById('light_target_y')?.value);
      const targetZ = parseFloat(document.getElementById('light_target_z')?.value);
      if (threeLight.target) {
        if (!isNaN(targetX)) threeLight.target.position.x = targetX;
        if (!isNaN(targetY)) threeLight.target.position.y = targetY;
        if (!isNaN(targetZ)) threeLight.target.position.z = targetZ;
        threeLight.target.updateMatrixWorld();
        if (lightData) {
          if (!lightData.target) lightData.target = {};
          if (!isNaN(targetX)) lightData.target.x = targetX;
          if (!isNaN(targetY)) lightData.target.y = targetY;
          if (!isNaN(targetZ)) lightData.target.z = targetZ;
        }
      }
      
      // Shadow parameters
      const shadowBias = parseFloat(document.getElementById('light_shadow_bias')?.value);
      const shadowRadius = parseFloat(document.getElementById('light_shadow_radius')?.value);
      const shadowSize = parseFloat(document.getElementById('light_shadow_size')?.value);
      
      if (threeLight.shadow) {
        if (!isNaN(shadowBias)) {
          threeLight.shadow.bias = shadowBias / 10000;
          if (lightData) lightData.shadowBias = threeLight.shadow.bias;
        }
        if (!isNaN(shadowRadius)) {
          threeLight.shadow.radius = shadowRadius;
          if (lightData) lightData.shadowRadius = shadowRadius;
        }
        if (!isNaN(shadowSize) && threeLight.shadow.camera) {
          // For directional lights, adjust shadow camera size
          if (threeLight.isDirectionalLight) {
            threeLight.shadow.camera.left = -shadowSize;
            threeLight.shadow.camera.right = shadowSize;
            threeLight.shadow.camera.top = shadowSize;
            threeLight.shadow.camera.bottom = -shadowSize;
            threeLight.shadow.camera.updateProjectionMatrix();
            if (lightData) lightData.shadowCameraSize = shadowSize;
          }
        }
      }
      
      // Sync ambient light to grass shader (since grass uses custom shader)
      if (threeLight.isAmbientLight && sceneObj.grassMesh?.material?.uniforms) {
        const gu = sceneObj.grassMesh.material.uniforms;
        // Map intensity (0-2) to ambient (0-1)
        if (!isNaN(intensity) && gu.uAmbient) {
          gu.uAmbient.value = Math.min(1.0, intensity * 0.5);
        }
      }
      
      // Sync directional/point lights to grass shader light direction
      if ((threeLight.isDirectionalLight || threeLight.isPointLight) && sceneObj.grassMesh?.material?.uniforms?.uLightDir) {
        if (!isNaN(posX) && !isNaN(posY) && !isNaN(posZ)) {
          sceneObj.grassMesh.material.uniforms.uLightDir.value.set(posX, posY, posZ).normalize();
        }
      }
      
      // Auto-save to localStorage
      scheduleAutoSave(sceneIdx);
    };
    
    // Shadow toggle handler
    const shadowCheckbox = document.getElementById('light_shadow');
    if (shadowCheckbox) {
      shadowCheckbox.addEventListener('change', () => {
        const sceneObj = getSceneByIdx(sceneIdx);
        if (!sceneObj) return;
        
        const threeLight = findThreeLightByIndex(sceneObj, lightIdx);
        const lightData = sceneObj.data.lights?.[lightIdx];
        if (!threeLight) return;
        
        const enabled = shadowCheckbox.checked;
        threeLight.castShadow = enabled;
        if (lightData) lightData.castShadow = enabled;
        
        // Update label text
        const label = shadowCheckbox.parentElement?.querySelector('span');
        if (label) label.textContent = enabled ? 'On' : 'Off';
        
        // If enabling shadows for the first time, set up shadow map
        if (enabled && threeLight.shadow) {
          if (threeLight.isDirectionalLight) {
            threeLight.shadow.mapSize.width = 2048;
            threeLight.shadow.mapSize.height = 2048;
            threeLight.shadow.camera.near = 0.5;
            threeLight.shadow.camera.far = 500;
            const size = parseFloat(document.getElementById('light_shadow_size')?.value) || 20;
            threeLight.shadow.camera.left = -size;
            threeLight.shadow.camera.right = size;
            threeLight.shadow.camera.top = size;
            threeLight.shadow.camera.bottom = -size;
            threeLight.shadow.camera.updateProjectionMatrix();
          } else if (threeLight.isPointLight) {
            threeLight.shadow.mapSize.width = 1024;
            threeLight.shadow.mapSize.height = 1024;
            threeLight.shadow.camera.near = 0.5;
            threeLight.shadow.camera.far = threeLight.distance || 50;
          } else if (threeLight.isSpotLight) {
            threeLight.shadow.mapSize.width = 1024;
            threeLight.shadow.mapSize.height = 1024;
          }
          threeLight.shadow.bias = -0.001;
          threeLight.shadow.radius = 1;
        }
        
        scheduleAutoSave(sceneIdx);
      });
    }
    
    const inputs = ['light_color', 'light_intensity', 'light_intensity_num', 
      'light_pos_x', 'light_pos_y', 'light_pos_z',
      'light_pos_x_num', 'light_pos_y_num', 'light_pos_z_num',
      'light_target_x', 'light_target_y', 'light_target_z',
      'light_target_x_num', 'light_target_y_num', 'light_target_z_num',
      'light_sky', 'light_ground',
      'light_distance', 'light_distance_num',
      'light_decay', 'light_decay_num',
      'light_angle', 'light_angle_num',
      'light_penumbra', 'light_penumbra_num',
      'light_width', 'light_width_num',
      'light_height', 'light_height_num',
      'light_shadow_size', 'light_shadow_size_num',
      'light_shadow_bias', 'light_shadow_bias_num',
      'light_shadow_radius', 'light_shadow_radius_num'];
    inputs.forEach(id => {
      const el = document.getElementById(id);
      if (el) {
        el.addEventListener('focus', beginParamEdit);
        el.addEventListener('mousedown', beginParamEdit);
        el.addEventListener('input', handleChange);
        
        // Color pickers need special handling - their native dialog steals focus
        if (el.type === 'color') {
          el.addEventListener('change', endParamEdit);
        } else {
        el.addEventListener('blur', endParamEdit);
        el.addEventListener('mouseup', () => setTimeout(endParamEdit, 100));
        }
      }
    });
    
    // Update slider states based on existing keyframes
    updateAllSliderKeyframeStates();
  }
  
  // Populate parameters for a group
  function populateGroupParams(group, sceneIdx, groupId) {
    if (!group) {
      paramContent.innerHTML = '<div class="panel-empty">Group not found</div>';
      return;
    }
    
    const pos = group.position || { x: 0, y: 0, z: 0 };
    const rot = group.rotation || { x: 0, y: 0, z: 0 };
    const scale = group.scale || { x: 1, y: 1, z: 1 };
    
    // Calculate sensible ranges
    const posRange = Math.max(50, Math.abs(pos.x) * 2, Math.abs(pos.y) * 2, Math.abs(pos.z) * 2);
    const scaleMax = Math.max(5, scale.x * 3, scale.y * 3, scale.z * 3);
    
    let html = `
      <div class="param-group">
        <div class="param-group-title">Group: ${group.name}</div>
        <div class="param-row" style="margin-bottom:8px;">
          <span class="param-label" style="color:#c8a0ff;">${group.children.length} items</span>
          <button id="btnUngroupGroup" style="padding:4px 10px;font-size:10px;background:rgba(180,120,255,0.2);border:1px solid rgba(180,120,255,0.3);border-radius:4px;color:#c8a0ff;cursor:pointer;">
            Ungroup
          </button>
        </div>
      </div>
      <div class="param-group">
        <div class="param-group-title">Transform</div>
        ${createParamRow('Position', createXYZ('group_pos', pos.x, pos.y, pos.z, -posRange, posRange, 0.1, true), null)}
        ${createParamRow('Rotation', createXYZ('group_rot', rot.x, rot.y, rot.z, -180, 180, 1, true), null)}
        ${createParamRow('Scale', createScaleXYZ('group_scale', scale.x, scale.y, scale.z, 0.01, scaleMax, 0.01, false), null)}
      </div>
    `;
    
    paramContent.innerHTML = html;
    
    setupXYZSlider('group_pos');
    setupXYZSlider('group_rot');
    // Note: setupScaleXYZ is called in addGroupParamHandlers with the proper change callback
    
    addGroupParamHandlers(sceneIdx, groupId);
  }
  
  function addGroupParamHandlers(sceneIdx, groupId) {
    const sceneObj = getSceneByIdx(sceneIdx);
    if (!sceneObj) return;
    
    const groupData = sceneObj.data.groups?.find(g => g.id === groupId);
    const threeGroup = sceneObj.threeGroups?.[groupId];
    
    if (!threeGroup) {
      console.warn(`[GroupParams] Could not find Three.js group for ${groupId}`);
      return;
    }
    
    // Scale change handler (called by setupScaleXYZ)
    const handleScaleChange = () => {
      const scaleX = parseFloat(document.getElementById('group_scale_x')?.value);
      const scaleY = parseFloat(document.getElementById('group_scale_y')?.value);
      const scaleZ = parseFloat(document.getElementById('group_scale_z')?.value);
      
      if (!isNaN(scaleX)) {
        threeGroup.scale.x = scaleX;
        if (groupData) groupData.scale.x = scaleX;
      }
      if (!isNaN(scaleY)) {
        threeGroup.scale.y = scaleY;
        if (groupData) groupData.scale.y = scaleY;
      }
      if (!isNaN(scaleZ)) {
        threeGroup.scale.z = scaleZ;
        if (groupData) groupData.scale.z = scaleZ;
      }
      
      scheduleAutoSave(sceneIdx);
    };
    
    // Re-setup scale with proper change handler (now that we have access to threeGroup)
    setupScaleXYZ('group_scale', handleScaleChange);
    
    // Position and rotation change handler
    const handlePosRotChange = () => {
      // Position
      const posX = parseFloat(document.getElementById('group_pos_x')?.value);
      const posY = parseFloat(document.getElementById('group_pos_y')?.value);
      const posZ = parseFloat(document.getElementById('group_pos_z')?.value);
      
      if (!isNaN(posX)) {
        threeGroup.position.x = posX;
        if (groupData) groupData.position.x = posX;
      }
      if (!isNaN(posY)) {
        threeGroup.position.y = posY;
        if (groupData) groupData.position.y = posY;
      }
      if (!isNaN(posZ)) {
        threeGroup.position.z = posZ;
        if (groupData) groupData.position.z = posZ;
      }
      
      // Rotation (convert degrees to radians)
      const rotX = parseFloat(document.getElementById('group_rot_x')?.value);
      const rotY = parseFloat(document.getElementById('group_rot_y')?.value);
      const rotZ = parseFloat(document.getElementById('group_rot_z')?.value);
      
      if (!isNaN(rotX)) {
        threeGroup.rotation.x = rotX * (Math.PI / 180);
        if (groupData) groupData.rotation.x = rotX;
      }
      if (!isNaN(rotY)) {
        threeGroup.rotation.y = rotY * (Math.PI / 180);
        if (groupData) groupData.rotation.y = rotY;
      }
      if (!isNaN(rotZ)) {
        threeGroup.rotation.z = rotZ * (Math.PI / 180);
        if (groupData) groupData.rotation.z = rotZ;
      }
      
      scheduleAutoSave(sceneIdx);
    };
    
    // Ungroup button handler
    document.getElementById('btnUngroupGroup')?.addEventListener('click', () => {
      ungroupObjects(sceneIdx, groupId);
    });
    
    // Add handlers to position and rotation inputs (scale is handled by setupScaleXYZ)
    const posRotInputs = [
      'group_pos_x', 'group_pos_y', 'group_pos_z',
      'group_pos_x_num', 'group_pos_y_num', 'group_pos_z_num',
      'group_rot_x', 'group_rot_y', 'group_rot_z',
      'group_rot_x_num', 'group_rot_y_num', 'group_rot_z_num'
    ];
    
    posRotInputs.forEach(id => {
      const el = document.getElementById(id);
      if (el) {
        el.addEventListener('focus', beginParamEdit);
        el.addEventListener('mousedown', beginParamEdit);
        el.addEventListener('input', handlePosRotChange);
        el.addEventListener('blur', endParamEdit);
        el.addEventListener('mouseup', () => setTimeout(endParamEdit, 100));
      }
    });
  }
  
  function populateObjectParams(obj, sceneIdx, objIdx) {
    // Position
    const pos = obj.position || {};
    const px = pos.x ?? pos[0] ?? 0;
    const py = pos.y ?? pos[1] ?? 0;
    const pz = pos.z ?? pos[2] ?? 0;
    
    // Rotation (convert from radians or degrees based on JSON format)
    const rot = obj.rotation || {};
    // Assume JSON stores degrees, or read from existing object
    let rx = rot.x ?? rot[0] ?? 0;
    let ry = rot.y ?? rot[1] ?? 0;
    let rz = rot.z ?? rot[2] ?? 0;
    
    // Scale (handle number, array, or object format)
    let sx = 1, sy = 1, sz = 1;
    if (typeof obj.scale === 'number') {
      sx = sy = sz = obj.scale;
    } else if (Array.isArray(obj.scale)) {
      sx = obj.scale[0] ?? 1;
      sy = obj.scale[1] ?? 1;
      sz = obj.scale[2] ?? 1;
    } else if (obj.scale) {
      sx = obj.scale.x ?? 1;
      sy = obj.scale.y ?? 1;
      sz = obj.scale.z ?? 1;
    }
    
    const mat = typeof obj.material === 'object' ? obj.material : {};
    const color = mat.color || obj.color || '#cccccc';
    
    // Calculate sensible initial ranges based on current values
    const posRange = Math.max(50, Math.abs(px) * 2, Math.abs(py) * 2, Math.abs(pz) * 2);
    const scaleMax = Math.max(5, sx * 3, sy * 3, sz * 3);
    
    let html = `<div class="param-group"><div class="param-group-title">${obj.name || obj.geometry}</div>`;
    html += createParamRow('Position', createXYZ('obj_pos', px, py, pz, -posRange, posRange, 0.1, true), null);
    html += `</div>`;
    
    html += `<div class="param-group"><div class="param-group-title">Transform</div>`;
    html += createParamRow('Scale', createScaleXYZ('obj_scale', sx, sy, sz, 0.01, scaleMax, 0.01, true), null);
    html += createParamRow('Rotation', createRotationXYZ('obj_rot', rx, ry, rz, true), null);
    html += `</div>`;
    
    // Visibility & Shadows section
    const isVisible = obj.visible !== false;
    const castShadow = obj.castShadow !== false;
    const receiveShadow = obj.receiveShadow !== false;
    
    html += `<div class="param-group"><div class="param-group-title">Visibility & Shadows</div>`;
    html += createParamRow('Visible', `
      <label style="display:flex;align-items:center;gap:8px;cursor:pointer;">
        <input type="checkbox" id="obj_visible" ${isVisible ? 'checked' : ''} 
               style="width:16px;height:16px;cursor:pointer;">
        <span style="color:var(--muted);font-size:11px;">${isVisible ? 'On' : 'Off'}</span>
      </label>
    `, null);
    html += createParamRow('Cast Shadow', `
      <label style="display:flex;align-items:center;gap:8px;cursor:pointer;">
        <input type="checkbox" id="obj_cast_shadow" ${castShadow ? 'checked' : ''} 
               style="width:16px;height:16px;cursor:pointer;">
        <span style="color:var(--muted);font-size:11px;">${castShadow ? 'On' : 'Off'}</span>
      </label>
    `, null);
    html += createParamRow('Receive Shadow', `
      <label style="display:flex;align-items:center;gap:8px;cursor:pointer;">
        <input type="checkbox" id="obj_receive_shadow" ${receiveShadow ? 'checked' : ''} 
               style="width:16px;height:16px;cursor:pointer;">
        <span style="color:var(--muted);font-size:11px;">${receiveShadow ? 'On' : 'Off'}</span>
      </label>
    `, null);
    html += `</div>`;
    
    html += `<div class="param-group"><div class="param-group-title">Material</div>`;
    html += createParamRow('Color', createColorPicker('obj_color', color), null);
    html += createParamRow('Opacity', createSlider('obj_opacity', mat.opacity ?? obj.opacity ?? 1, 0, 1, 0.05), 'obj_opacity');
    html += createParamRow('Metalness', createSlider('obj_metalness', mat.metalness ?? 0.5, 0, 1, 0.05), 'obj_metalness');
    html += createParamRow('Roughness', createSlider('obj_roughness', mat.roughness ?? 0.5, 0, 1, 0.05), 'obj_roughness');
    html += `</div>`;
    
    // Emissive section
    html += `<div class="param-group"><div class="param-group-title">Emissive (Glow)</div>`;
    html += createParamRow('Emissive', createColorPicker('obj_emissive', mat.emissive || obj.emissive || '#000000'), null);
    html += createParamRow('Intensity', createSlider('obj_emissive_int', mat.emissiveIntensity ?? 1.0, 0, 5, 0.1), 'obj_emissive_int');
    html += createParamRow('Emissive Map', createTextureSelect('obj_emissive_map', mat.emissiveMap || obj.emissiveMap), null);
    html += `</div>`;
    
    // Texture Maps section
    html += `<div class="param-group"><div class="param-group-title">Texture Maps</div>`;
    html += createParamRow('Color Map', createTextureSelect('obj_map', mat.map || obj.map), null);
    html += createParamRow('Alpha Map', createTextureSelect('obj_alpha_map', mat.alphaMap || obj.alphaMap), null);
    html += `</div>`;
    
    // Normal/Bump section
    html += `<div class="param-group"><div class="param-group-title">Normal & Bump</div>`;
    html += createParamRow('Normal Map', createTextureSelect('obj_normal_map', mat.normalMap || obj.normalMap), null);
    html += createParamRow('Normal Scale', createSlider('obj_normal_scale', mat.normalScale?.x ?? 1.0, 0, 2, 0.05), 'obj_normal_scale');
    html += createParamRow('Bump Map', createTextureSelect('obj_bump_map', mat.bumpMap || obj.bumpMap), null);
    html += createParamRow('Bump Scale', createSlider('obj_bump_scale', mat.bumpScale ?? 1.0, 0, 2, 0.05), 'obj_bump_scale');
    html += `</div>`;
    
    // Displacement section
    html += `<div class="param-group"><div class="param-group-title">Displacement</div>`;
    html += createParamRow('Disp. Map', createTextureSelect('obj_disp_map', mat.displacementMap || obj.displacementMap), null);
    html += createParamRow('Disp. Scale', createSlider('obj_disp_scale', mat.displacementScale ?? 0, 0, 5, 0.1), 'obj_disp_scale');
    html += createParamRow('Disp. Bias', createSlider('obj_disp_bias', mat.displacementBias ?? 0, -2, 2, 0.05), 'obj_disp_bias');
    html += `</div>`;
    
    // PBR Maps section
    html += `<div class="param-group"><div class="param-group-title">PBR Maps</div>`;
    html += createParamRow('Roughness Map', createTextureSelect('obj_rough_map', mat.roughnessMap || obj.roughnessMap), null);
    html += createParamRow('Metalness Map', createTextureSelect('obj_metal_map', mat.metalnessMap || obj.metalnessMap), null);
    html += createParamRow('AO Map', createTextureSelect('obj_ao_map', mat.aoMap || obj.aoMap), null);
    html += createParamRow('AO Intensity', createSlider('obj_ao_int', mat.aoMapIntensity ?? 1.0, 0, 2, 0.05), 'obj_ao_int');
    html += `</div>`;
    
    // Environment section
    html += `<div class="param-group"><div class="param-group-title">Environment</div>`;
    html += createParamRow('Env Map', createTextureSelect('obj_env_map', mat.envMap || obj.envMap), null);
    html += createParamRow('Env Intensity', createSlider('obj_envmap', mat.envMapIntensity ?? 1.0, 0, 5, 0.1), 'obj_envmap');
    html += createParamRow('Light Map', createTextureSelect('obj_light_map', mat.lightMap || obj.lightMap), null);
    html += createParamRow('Light Intensity', createSlider('obj_light_int', mat.lightMapIntensity ?? 1.0, 0, 2, 0.1), 'obj_light_int');
    html += `</div>`;
    
    // Rendering options
    html += `<div class="param-group"><div class="param-group-title">Rendering</div>`;
    html += createParamRow('Wireframe', `<input type="checkbox" id="obj_wireframe" ${mat.wireframe ? 'checked' : ''}>`, null);
    html += createParamRow('Flat Shading', `<input type="checkbox" id="obj_flatshading" ${mat.flatShading ? 'checked' : ''}>`, null);
    html += createParamRow('Side', `<select id="obj_side">
      <option value="front" ${(mat.side || 'front') === 'front' ? 'selected' : ''}>Front</option>
      <option value="back" ${mat.side === 'back' ? 'selected' : ''}>Back</option>
      <option value="double" ${mat.side === 'double' ? 'selected' : ''}>Double</option>
    </select>`, null);
    html += `</div>`;
    
    // Displacement parameters if available
    const disp = mat.displacement || obj.displacement;
    if (disp) {
      html += `<div class="param-group"><div class="param-group-title">Displacement</div>`;
      html += createParamRow('Strength', createSlider('obj_disp_str', disp.strength ?? 0.5, 0, 10, 0.1), 'obj_disp_str');
      html += createParamRow('Frequency', createSlider('obj_disp_freq', disp.frequency ?? 2.0, 0.1, 10, 0.1), 'obj_disp_freq');
      html += createParamRow('Speed', createSlider('obj_disp_speed', disp.speed ?? 0.5, 0, 5, 0.1), 'obj_disp_speed');
      html += createParamRow('Noise Scale', createSlider('obj_disp_noise', disp.noiseScale ?? 1.0, 0.1, 5, 0.1), 'obj_disp_noise');
      html += `</div>`;
    }
    
    paramContent.innerHTML = html;
    [
      'obj_opacity', 'obj_metalness', 'obj_roughness', 'obj_envmap',
      'obj_emissive_int', 'obj_normal_scale', 'obj_bump_scale',
      'obj_disp_scale', 'obj_disp_bias', 'obj_ao_int', 'obj_light_int',
      'obj_disp_str', 'obj_disp_freq', 'obj_disp_speed', 'obj_disp_noise'
    ].forEach(linkSliderToNumber);
    setupXYZSlider('obj_pos');
    setupXYZSlider('obj_rot');
    
    addObjectParamHandlers(sceneIdx, objIdx);
    setupTextureSelectHandlers(sceneIdx, objIdx);
  }
  
  // Setup texture select button handlers
  function setupTextureSelectHandlers(sceneIdx, objIdx) {
    const textureParams = [
      { id: 'obj_map', prop: 'map', label: 'Color Map' },
      { id: 'obj_alpha_map', prop: 'alphaMap', label: 'Alpha Map' },
      { id: 'obj_normal_map', prop: 'normalMap', label: 'Normal Map' },
      { id: 'obj_bump_map', prop: 'bumpMap', label: 'Bump Map' },
      { id: 'obj_disp_map', prop: 'displacementMap', label: 'Displacement Map' },
      { id: 'obj_rough_map', prop: 'roughnessMap', label: 'Roughness Map' },
      { id: 'obj_metal_map', prop: 'metalnessMap', label: 'Metalness Map' },
      { id: 'obj_ao_map', prop: 'aoMap', label: 'AO Map' },
      { id: 'obj_env_map', prop: 'envMap', label: 'Environment Map' },
      { id: 'obj_light_map', prop: 'lightMap', label: 'Light Map' },
      { id: 'obj_emissive_map', prop: 'emissiveMap', label: 'Emissive Map' },
    ];
    
    textureParams.forEach(({ id, prop, label }) => {
      const btn = document.getElementById(`${id}_btn`);
      const clearBtn = document.getElementById(`${id}_clear`);
      const nameEl = document.getElementById(`${id}_name`);
      
      if (btn) {
        btn.addEventListener('click', () => {
          openAssetBrowser('media', async (asset) => {
            if (!asset) return;
            
            // Load the texture and apply it
            const texturePath = asset.path;
            saveUndoStateForAction();
            applyTextureToObject(sceneIdx, objIdx, prop, texturePath);
            
            // Update the UI
            if (nameEl) {
              const fileName = texturePath.split('/').pop();
              const videoIcon = isVideoFile(texturePath) ? '🎬' : '';
              nameEl.textContent = videoIcon + fileName;
              nameEl.classList.add('has-texture');
            }
            
            // Add clear button if not present
            const container = document.getElementById(`${id}_container`);
            if (container && !document.getElementById(`${id}_clear`)) {
              const clearBtn = document.createElement('button');
              clearBtn.className = 'btn texture-clear-btn';
              clearBtn.id = `${id}_clear`;
              clearBtn.title = 'Remove texture';
              clearBtn.textContent = '✕';
              clearBtn.addEventListener('click', () => {
                saveUndoStateForAction();
                applyTextureToObject(sceneIdx, objIdx, prop, null);
                nameEl.textContent = 'None';
                nameEl.classList.remove('has-texture');
                clearBtn.remove();
              });
              container.appendChild(clearBtn);
            }
            
            scheduleAutoSave(sceneIdx);
          });
        });
      }
      
      if (clearBtn) {
        clearBtn.addEventListener('click', () => {
          saveUndoStateForAction();
          applyTextureToObject(sceneIdx, objIdx, prop, null);
          if (nameEl) {
            nameEl.textContent = 'None';
            nameEl.classList.remove('has-texture');
          }
          clearBtn.remove();
          scheduleAutoSave(sceneIdx);
        });
      }
    });
  }
  
  // Store video elements used as textures so they can be properly managed
  const videoTextureElements = new Map(); // key: `${sceneIdx}_${objIdx}_${property}`, value: video element
  
  // Apply a texture to an object's material
  async function applyTextureToObject(sceneIdx, objIdx, property, texturePath) {
    const sceneObj = getSceneByIdx(sceneIdx);
    if (!sceneObj) return;
    
    // Find the root object (could be mesh or group)
    let rootObject = findThreeMeshByIndex(sceneObj, objIdx);
    if (!rootObject) return;
    
    // Collect all meshes to apply texture to
    const meshes = [];
    if (rootObject.isMesh) {
      meshes.push(rootObject);
    }
    // Also traverse children for Groups (GLTF, OBJ, FBX)
    rootObject.traverse?.((child) => {
      if (child.isMesh && child !== rootObject) {
        meshes.push(child);
      }
    });
    
    if (meshes.length === 0) return;
    
    // Use the first mesh for backward compatibility with single-mesh paths
    const mesh = meshes[0];
    
    // Key for tracking video elements
    const videoKey = `${sceneIdx}_${objIdx}_${property}`;
    
    // Clean up any existing video element for this slot
    const existingVideo = videoTextureElements.get(videoKey);
    if (existingVideo) {
      existingVideo.pause();
      existingVideo.src = '';
      existingVideo.remove();
      videoTextureElements.delete(videoKey);
    }
    
    if (texturePath) {
      const isVideo = isVideoFile(texturePath);
      
      if (isVideo) {
        // Create video texture
        try {
          const video = document.createElement('video');
          video.src = '/' + texturePath;
          video.crossOrigin = 'anonymous';
          video.loop = true;
          video.muted = true; // Muted to allow autoplay
          video.playsInline = true;
          video.preload = 'auto';
          
          // Wait for video to be ready
          await new Promise((resolve, reject) => {
            video.onloadeddata = resolve;
            video.onerror = reject;
            video.load();
          });
          
          // Create video texture
          const texture = new THREE.VideoTexture(video);
          texture.minFilter = THREE.LinearFilter;
          texture.magFilter = THREE.LinearFilter;
          texture.format = THREE.RGBAFormat;
          
          // Environment maps need special mapping
          if (property === 'envMap') {
            texture.mapping = THREE.EquirectangularReflectionMapping;
          } else {
            texture.wrapS = THREE.RepeatWrapping;
            texture.wrapT = THREE.RepeatWrapping;
          }
          
          // Store video element for cleanup and playback control
          videoTextureElements.set(videoKey, video);
          
          // Apply texture to ALL meshes in the object
          for (const m of meshes) {
            if (!m.material) continue;
            const mats = Array.isArray(m.material) ? m.material : [m.material];
            for (const mat of mats) {
              if (mat[property]) mat[property].dispose();
              mat[property] = texture;
              mat.needsUpdate = true;
              // For environment maps, ensure intensity is visible
              if (property === 'envMap' && (!mat.envMapIntensity || mat.envMapIntensity < 0.1)) {
                mat.envMapIntensity = 1.0;
              }
            }
          }
          
          // Start playing the video
          video.play().catch(err => console.warn('Video autoplay blocked:', err));
          
          console.log(`[VideoTexture] Applied video texture: ${texturePath} to ${property} on ${meshes.length} meshes`);
        } catch (err) {
          console.error('Failed to load video texture:', err);
        }
      } else {
        // Load regular image texture
        const loader = new THREE.TextureLoader();
        try {
          const texture = await new Promise((resolve, reject) => {
            loader.load(
              '/' + texturePath,
              (tex) => {
                // Environment maps need special mapping
                if (property === 'envMap') {
                  tex.mapping = THREE.EquirectangularReflectionMapping;
                } else {
                  tex.wrapS = THREE.RepeatWrapping;
                  tex.wrapT = THREE.RepeatWrapping;
                }
                resolve(tex);
              },
              undefined,
              reject
            );
          });
          
          // Apply texture to ALL meshes in the object
          for (const m of meshes) {
            if (!m.material) continue;
            const mats = Array.isArray(m.material) ? m.material : [m.material];
            for (const mat of mats) {
              if (mat[property]) mat[property].dispose();
              mat[property] = texture;
              mat.needsUpdate = true;
              // For environment maps, ensure intensity is visible
              if (property === 'envMap' && (!mat.envMapIntensity || mat.envMapIntensity < 0.1)) {
                mat.envMapIntensity = 1.0;
              }
            }
          }
          console.log(`[Texture] Applied texture: ${texturePath} to ${property} on ${meshes.length} meshes`);
        } catch (err) {
          console.error('Failed to load texture:', err);
        }
      }
      
      // For environment maps, update the UI slider if needed
      if (property === 'envMap') {
        const intensitySlider = document.getElementById('obj_envmap');
        if (intensitySlider && parseFloat(intensitySlider.value) < 0.1) {
          intensitySlider.value = 1.0;
          const thumbEl = document.getElementById('obj_envmap_thumb');
          if (thumbEl) thumbEl.textContent = '1.0';
        }
      }
    } else {
      // Clear the texture from ALL meshes
      for (const m of meshes) {
        if (!m.material) continue;
        const mats = Array.isArray(m.material) ? m.material : [m.material];
        for (const mat of mats) {
          if (mat[property]) {
            mat[property].dispose();
            mat[property] = null;
          }
          mat.needsUpdate = true;
        }
      }
    }
    
    // Update the scene data
    const objData = sceneObj.data?.objects?.[objIdx];
    if (objData) {
      // Ensure material is an object (might be a string type like "standard")
      if (typeof objData.material !== 'object' || objData.material === null) {
        objData.material = {};
      }
      objData.material[property] = texturePath;
    }
  }
  
  function addObjectParamHandlers(sceneIdx, objIdx) {
    // Helper to find the mesh/object by stored objIndex
    const findRootObject = (scene) => {
      if (!scene?.threeScene) return null;
      let rootObject = null;
      scene.threeScene.traverse((obj) => {
        if (obj.userData?.objIndex === objIdx && (obj.isMesh || obj.isGroup || obj.isObject3D)) {
          // Prefer root object (for GLTF groups) over child meshes
          if (!rootObject || obj.userData.isGLTF) {
            rootObject = obj;
          }
        }
      });
      return rootObject;
    };
    
    // Also find all meshes under this object for material updates
    const getMeshes = (obj) => {
      const meshes = [];
      if (!obj) return meshes;
      if (obj.isMesh) meshes.push(obj);
      obj.traverse?.((child) => {
        if (child.isMesh && child !== obj) meshes.push(child);
      });
      return meshes;
    };
    
    const handleChange = () => {
      // Get the scene object fresh each time (important for segment-specific scenes)
      const sceneObj = getSceneByIdx(sceneIdx);
      if (!sceneObj) return;
      
      const rootObject = findRootObject(sceneObj);
      if (!rootObject) return;
      
      // Material - apply to all child meshes
      const color = document.getElementById('obj_color')?.value;
      const opacity = parseFloat(document.getElementById('obj_opacity')?.value);
      const metalness = parseFloat(document.getElementById('obj_metalness')?.value);
      const roughness = parseFloat(document.getElementById('obj_roughness')?.value);
      const envMapIntensity = parseFloat(document.getElementById('obj_envmap')?.value);
      
      // New MeshStandardMaterial properties
      const emissive = document.getElementById('obj_emissive')?.value;
      const emissiveIntensity = parseFloat(document.getElementById('obj_emissive_int')?.value);
      const normalScale = parseFloat(document.getElementById('obj_normal_scale')?.value);
      const bumpScale = parseFloat(document.getElementById('obj_bump_scale')?.value);
      const displacementScale = parseFloat(document.getElementById('obj_disp_scale')?.value);
      const displacementBias = parseFloat(document.getElementById('obj_disp_bias')?.value);
      const aoMapIntensity = parseFloat(document.getElementById('obj_ao_int')?.value);
      const lightMapIntensity = parseFloat(document.getElementById('obj_light_int')?.value);
      const wireframe = document.getElementById('obj_wireframe')?.checked;
      const flatShading = document.getElementById('obj_flatshading')?.checked;
      const side = document.getElementById('obj_side')?.value;
      
      const meshes = getMeshes(rootObject);
      for (const mesh of meshes) {
        if (mesh.material) {
          // Standard materials use .color, shader materials use uniforms.uColor
          if (color) {
            if (mesh.material.color) {
              mesh.material.color.set(color);
            } else if (mesh.material.uniforms?.uColor) {
              mesh.material.uniforms.uColor.value.set(color);
            }
          }
          if (!isNaN(opacity)) mesh.material.opacity = opacity;
          if (!isNaN(metalness) && mesh.material.metalness !== undefined) mesh.material.metalness = metalness;
          if (!isNaN(roughness) && mesh.material.roughness !== undefined) mesh.material.roughness = roughness;
          if (!isNaN(envMapIntensity) && mesh.material.envMapIntensity !== undefined) {
            mesh.material.envMapIntensity = envMapIntensity;
          }
          
          // Emissive
          if (emissive && mesh.material.emissive) {
            mesh.material.emissive.set(emissive);
          }
          if (!isNaN(emissiveIntensity) && mesh.material.emissiveIntensity !== undefined) {
            mesh.material.emissiveIntensity = emissiveIntensity;
          }
          
          // Normal scale
          if (!isNaN(normalScale) && mesh.material.normalScale) {
            mesh.material.normalScale.set(normalScale, normalScale);
          }
          
          // Bump scale
          if (!isNaN(bumpScale) && mesh.material.bumpScale !== undefined) {
            mesh.material.bumpScale = bumpScale;
          }
          
          // Displacement
          if (!isNaN(displacementScale) && mesh.material.displacementScale !== undefined) {
            mesh.material.displacementScale = displacementScale;
          }
          if (!isNaN(displacementBias) && mesh.material.displacementBias !== undefined) {
            mesh.material.displacementBias = displacementBias;
          }
          
          // AO and Light map intensity
          if (!isNaN(aoMapIntensity) && mesh.material.aoMapIntensity !== undefined) {
            mesh.material.aoMapIntensity = aoMapIntensity;
          }
          if (!isNaN(lightMapIntensity) && mesh.material.lightMapIntensity !== undefined) {
            mesh.material.lightMapIntensity = lightMapIntensity;
          }
          
          // Rendering options
          if (wireframe !== undefined && mesh.material.wireframe !== undefined) {
            mesh.material.wireframe = wireframe;
          }
          if (flatShading !== undefined && mesh.material.flatShading !== undefined) {
            const needsUpdate = mesh.material.flatShading !== flatShading;
            mesh.material.flatShading = flatShading;
            if (needsUpdate) mesh.material.needsUpdate = true;
          }
          if (side) {
            const sideMap = { 'front': THREE.FrontSide, 'back': THREE.BackSide, 'double': THREE.DoubleSide };
            if (sideMap[side] !== undefined) {
              mesh.material.side = sideMap[side];
            }
          }
        }
      }
      
      // Visibility and shadow settings - apply to all meshes
      const visible = document.getElementById('obj_visible')?.checked;
      const castShadow = document.getElementById('obj_cast_shadow')?.checked;
      const receiveShadow = document.getElementById('obj_receive_shadow')?.checked;
      
      // Apply to root object and all child meshes
      if (visible !== undefined) {
        rootObject.visible = visible;
      }
      for (const mesh of meshes) {
        if (castShadow !== undefined) mesh.castShadow = castShadow;
        if (receiveShadow !== undefined) mesh.receiveShadow = receiveShadow;
      }
      
      // Update checkbox label text
      const visibleLabel = document.querySelector('#obj_visible')?.nextElementSibling;
      if (visibleLabel) visibleLabel.textContent = visible ? 'On' : 'Off';
      const castLabel = document.querySelector('#obj_cast_shadow')?.nextElementSibling;
      if (castLabel) castLabel.textContent = castShadow ? 'On' : 'Off';
      const receiveLabel = document.querySelector('#obj_receive_shadow')?.nextElementSibling;
      if (receiveLabel) receiveLabel.textContent = receiveShadow ? 'On' : 'Off';
      
      // Use rootObject for transforms (applies to GLTF groups and regular meshes)
      const mesh = rootObject;
      
      // Position
      const posX = parseFloat(document.getElementById('obj_pos_x')?.value);
      const posY = parseFloat(document.getElementById('obj_pos_y')?.value);
      const posZ = parseFloat(document.getElementById('obj_pos_z')?.value);
      if (!isNaN(posX)) mesh.position.x = posX;
      if (!isNaN(posY)) mesh.position.y = posY;
      if (!isNaN(posZ)) mesh.position.z = posZ;
      
      // Scale
      const scaleX = parseFloat(document.getElementById('obj_scale_x')?.value);
      const scaleY = parseFloat(document.getElementById('obj_scale_y')?.value);
      const scaleZ = parseFloat(document.getElementById('obj_scale_z')?.value);
      if (!isNaN(scaleX)) mesh.scale.x = scaleX;
      if (!isNaN(scaleY)) mesh.scale.y = scaleY;
      if (!isNaN(scaleZ)) mesh.scale.z = scaleZ;
      
      // Rotation (convert degrees to radians)
      const rotX = parseFloat(document.getElementById('obj_rot_x')?.value);
      const rotY = parseFloat(document.getElementById('obj_rot_y')?.value);
      const rotZ = parseFloat(document.getElementById('obj_rot_z')?.value);
      if (!isNaN(rotX)) mesh.rotation.x = rotX * Math.PI / 180;
      if (!isNaN(rotY)) mesh.rotation.y = rotY * Math.PI / 180;
      if (!isNaN(rotZ)) mesh.rotation.z = rotZ * Math.PI / 180;
      
      // Displacement parameters - check both userData and direct material
      const dispMat = mesh.userData?.displacementMaterial || 
                      (mesh.material?.uniforms?.uDisplacementStrength ? mesh.material : null);
      if (dispMat?.uniforms) {
        const dispStr = parseFloat(document.getElementById('obj_disp_str')?.value);
        const dispFreq = parseFloat(document.getElementById('obj_disp_freq')?.value);
        const dispSpeed = parseFloat(document.getElementById('obj_disp_speed')?.value);
        const dispNoise = parseFloat(document.getElementById('obj_disp_noise')?.value);
        
        if (!isNaN(dispStr) && dispMat.uniforms.uDisplacementStrength) {
          dispMat.uniforms.uDisplacementStrength.value = dispStr;
        }
        if (!isNaN(dispFreq) && dispMat.uniforms.uDisplacementFrequency) {
          dispMat.uniforms.uDisplacementFrequency.value = dispFreq;
        }
        if (!isNaN(dispSpeed) && dispMat.uniforms.uDisplacementSpeed) {
          dispMat.uniforms.uDisplacementSpeed.value = dispSpeed;
        }
        if (!isNaN(dispNoise) && dispMat.uniforms.uNoiseScale) {
          dispMat.uniforms.uNoiseScale.value = dispNoise;
        }
      }
      
      // Also update the scene data directly for immediate persistence
      const objData = sceneObj.data?.objects?.[objIdx];
      if (objData) {
        // Ensure material is an object
        if (typeof objData.material !== 'object' || objData.material === null) {
          objData.material = {};
        }
        const matData = objData.material;
        
        // Sync position, rotation, scale to data (CRITICAL - must persist these!)
        if (!isNaN(posX) || !isNaN(posY) || !isNaN(posZ)) {
          if (!objData.position || typeof objData.position !== 'object') {
            objData.position = {};
          }
          if (!isNaN(posX)) objData.position.x = posX;
          if (!isNaN(posY)) objData.position.y = posY;
          if (!isNaN(posZ)) objData.position.z = posZ;
        }
        
        if (!isNaN(rotX) || !isNaN(rotY) || !isNaN(rotZ)) {
          if (!objData.rotation || typeof objData.rotation !== 'object') {
            objData.rotation = {};
          }
          // Store in degrees (UI format)
          if (!isNaN(rotX)) objData.rotation.x = rotX;
          if (!isNaN(rotY)) objData.rotation.y = rotY;
          if (!isNaN(rotZ)) objData.rotation.z = rotZ;
        }
        
        if (!isNaN(scaleX) || !isNaN(scaleY) || !isNaN(scaleZ)) {
          if (!objData.scale || typeof objData.scale !== 'object') {
            objData.scale = {};
          }
          if (!isNaN(scaleX)) objData.scale.x = scaleX;
          if (!isNaN(scaleY)) objData.scale.y = scaleY;
          if (!isNaN(scaleZ)) objData.scale.z = scaleZ;
        }
        
        // Sync material properties to data
        if (color) matData.color = color;
        if (!isNaN(opacity)) matData.opacity = opacity;
        if (!isNaN(metalness)) matData.metalness = metalness;
        if (!isNaN(roughness)) matData.roughness = roughness;
        if (!isNaN(envMapIntensity)) matData.envMapIntensity = envMapIntensity;
        if (emissive) matData.emissive = emissive;
        if (!isNaN(emissiveIntensity)) matData.emissiveIntensity = emissiveIntensity;
        if (!isNaN(normalScale)) matData.normalScale = normalScale;
        if (!isNaN(bumpScale)) matData.bumpScale = bumpScale;
        if (!isNaN(displacementScale)) matData.displacementScale = displacementScale;
        if (!isNaN(displacementBias)) matData.displacementBias = displacementBias;
        if (!isNaN(aoMapIntensity)) matData.aoMapIntensity = aoMapIntensity;
        if (!isNaN(lightMapIntensity)) matData.lightMapIntensity = lightMapIntensity;
        if (wireframe !== undefined) matData.wireframe = wireframe;
        if (flatShading !== undefined) matData.flatShading = flatShading;
        if (side) matData.side = side;
        
        // Save visibility and shadow to objData (not matData)
        if (visible !== undefined) objData.visible = visible;
        if (castShadow !== undefined) objData.castShadow = castShadow;
        if (receiveShadow !== undefined) objData.receiveShadow = receiveShadow;
      }
      
      // Auto-save to localStorage
      scheduleAutoSave(sceneIdx);
    };
    
    // Setup scale with uniform lock (now with undo support)
    setupScaleXYZ('obj_scale', handleChange);
    
    // Listen to all inputs with undo support
    const inputIds = [
      'obj_color', 'obj_opacity', 'obj_metalness', 'obj_roughness', 'obj_envmap',
      'obj_opacity_num', 'obj_metalness_num', 'obj_roughness_num', 'obj_envmap_num',
      'obj_emissive', 'obj_emissive_int', 'obj_emissive_int_num',
      'obj_normal_scale', 'obj_normal_scale_num',
      'obj_bump_scale', 'obj_bump_scale_num',
      'obj_disp_scale', 'obj_disp_scale_num', 'obj_disp_bias', 'obj_disp_bias_num',
      'obj_ao_int', 'obj_ao_int_num', 'obj_light_int', 'obj_light_int_num',
      'obj_wireframe', 'obj_flatshading', 'obj_side',
      'obj_visible', 'obj_cast_shadow', 'obj_receive_shadow',
      'obj_pos_x', 'obj_pos_y', 'obj_pos_z',
      'obj_pos_x_num', 'obj_pos_y_num', 'obj_pos_z_num',
      'obj_rot_x', 'obj_rot_y', 'obj_rot_z',
      'obj_rot_x_num', 'obj_rot_y_num', 'obj_rot_z_num',
      'obj_disp_str', 'obj_disp_freq', 'obj_disp_speed', 'obj_disp_noise',
      'obj_disp_str_num', 'obj_disp_freq_num', 'obj_disp_speed_num', 'obj_disp_noise_num'
    ];
    inputIds.forEach(id => {
      const el = document.getElementById(id);
      if (el) {
        el.addEventListener('focus', beginParamEdit);
        el.addEventListener('mousedown', beginParamEdit);
        el.addEventListener('input', handleChange);
        
        // Color pickers need special handling - their native dialog steals focus
        if (el.type === 'color') {
          el.addEventListener('change', endParamEdit);
        } else {
        el.addEventListener('blur', endParamEdit);
        el.addEventListener('mouseup', () => setTimeout(endParamEdit, 100));
        }
      }
    });
    
    // Update slider states based on existing keyframes
    updateAllSliderKeyframeStates();
  }
  
  function populateGrassParams(grass, sceneIdx) {
    currentEditingSceneIdx = sceneIdx; // Track which scene is being edited
    // Get current uniform values from the grass mesh if available
    const sceneObj = getSceneByIdx(sceneIdx);
    const uniforms = sceneObj?.grassMesh?.material?.uniforms;
    
    let html = `<div class="param-group"><div class="param-group-title">Grass Info</div>`;
    html += createParamRow('Count', `<span style="color:var(--muted)">${(grass.count || 75000).toLocaleString()} blades</span>`);
    html += `</div>`;
    
    html += `<div class="param-group"><div class="param-group-title">Wind Animation</div>`;
    html += createParamRow('Speed', createSlider('grass_wind_speed', uniforms?.uWindSpeed?.value ?? grass.windSpeed ?? 0.8, 0, 5, 0.05), 'grass_wind_speed');
    html += createParamRow('Strength', createSlider('grass_wind_str', uniforms?.uWindStrength?.value ?? grass.windStrength ?? 0.4, 0, 2, 0.05), 'grass_wind_str');
    html += createParamRow('Frequency', createSlider('grass_wind_freq', uniforms?.uWindFrequency?.value ?? grass.windFrequency ?? 1.0, 0.1, 5, 0.1), 'grass_wind_freq');
    html += createParamRow('Gust', createSlider('grass_wind_gust', uniforms?.uGustStrength?.value ?? grass.gustStrength ?? 0.3, 0, 2, 0.05), 'grass_wind_gust');
    html += `</div>`;
    
    html += `<div class="param-group"><div class="param-group-title">Colors</div>`;
    const baseCol = uniforms?.uBaseColor?.value ? '#' + uniforms.uBaseColor.value.getHexString() : (grass.colors?.base || '#2d5a1e');
    const midCol = uniforms?.uMidColor?.value ? '#' + uniforms.uMidColor.value.getHexString() : (grass.colors?.mid || '#1a4a12');
    const tipCol = uniforms?.uTipColor?.value ? '#' + uniforms.uTipColor.value.getHexString() : (grass.colors?.tip || '#7cb342');
    html += createParamRow('Base', createColorPicker('grass_color_base', baseCol), null);
    html += createParamRow('Mid', createColorPicker('grass_color_mid', midCol), null);
    html += createParamRow('Tip', createColorPicker('grass_color_tip', tipCol), null);
    html += `</div>`;
    
    html += `<div class="param-group"><div class="param-group-title">Lighting</div>`;
    html += createParamRow('Ambient', createSlider('grass_ambient', uniforms?.uAmbient?.value ?? 0.35, 0, 1, 0.02), 'grass_ambient');
    html += createParamRow('AO Strength', createSlider('grass_ao', uniforms?.uAOStrength?.value ?? grass.colors?.aoStrength ?? 0.3, 0, 1, 0.02), 'grass_ao');
    const lightDir = uniforms?.uLightDir?.value;
    html += createParamRow('Light X', createSlider('grass_light_x', lightDir?.x ?? 0.5, -1, 1, 0.05), 'grass_light_x');
    html += createParamRow('Light Y', createSlider('grass_light_y', lightDir?.y ?? 1.0, -1, 1, 0.05), 'grass_light_y');
    html += createParamRow('Light Z', createSlider('grass_light_z', lightDir?.z ?? 0.3, -1, 1, 0.05), 'grass_light_z');
    html += `</div>`;
    
    html += `<div class="param-group"><div class="param-group-title">Fog</div>`;
    const fogCol = uniforms?.uFogColor?.value ? '#' + uniforms.uFogColor.value.getHexString() : '#a8d8e8';
    html += createParamRow('Color', createColorPicker('grass_fog_color', fogCol), null);
    html += createParamRow('Near', createSlider('grass_fog_near', uniforms?.uFogNear?.value ?? 5, 0, 50, 1), 'grass_fog_near');
    html += createParamRow('Far', createSlider('grass_fog_far', uniforms?.uFogFar?.value ?? 80, 10, 200, 5), 'grass_fog_far');
    html += `</div>`;
    
    paramContent.innerHTML = html;
    ['grass_wind_speed', 'grass_wind_str', 'grass_wind_freq', 'grass_wind_gust',
     'grass_ambient', 'grass_ao', 'grass_light_x', 'grass_light_y', 'grass_light_z',
     'grass_fog_near', 'grass_fog_far'].forEach(linkSliderToNumber);
    
    addGrassParamHandlers(sceneIdx);
  }
  
  function addGrassParamHandlers(sceneIdx) {
    // Initial check
    const initialSceneObj = getSceneByIdx(sceneIdx);
    console.log('addGrassParamHandlers: sceneIdx=', sceneIdx, 'sceneObj=', initialSceneObj, 'grassMesh=', initialSceneObj?.grassMesh);
    if (!initialSceneObj || !initialSceneObj.grassMesh) {
      console.warn('No grassMesh found for scene', sceneIdx);
      return;
    }
    
    const handleChange = () => {
      // Get scene object fresh each time (important for segment-specific scenes)
      const sceneObj = getSceneByIdx(sceneIdx);
      if (!sceneObj?.grassMesh) return;
      
      const uniforms = sceneObj.grassMesh.material?.uniforms;
      console.log('Grass handleChange called, uniforms=', uniforms);
      if (!uniforms) return;
      
      const windSpeed = parseFloat(document.getElementById('grass_wind_speed')?.value);
      const windStr = parseFloat(document.getElementById('grass_wind_str')?.value);
      const windFreq = parseFloat(document.getElementById('grass_wind_freq')?.value);
      const windGust = parseFloat(document.getElementById('grass_wind_gust')?.value);
      const baseColor = document.getElementById('grass_color_base')?.value;
      const midColor = document.getElementById('grass_color_mid')?.value;
      const tipColor = document.getElementById('grass_color_tip')?.value;
      
      console.log('Wind values: speed=', windSpeed, 'str=', windStr, 'freq=', windFreq, 'gust=', windGust);
      if (!isNaN(windSpeed)) { uniforms.uWindSpeed.value = windSpeed; console.log('Set uWindSpeed to', windSpeed); }
      if (!isNaN(windStr)) { uniforms.uWindStrength.value = windStr; console.log('Set uWindStrength to', windStr); }
      if (!isNaN(windFreq) && uniforms.uWindFrequency) { uniforms.uWindFrequency.value = windFreq; console.log('Set uWindFrequency to', windFreq); }
      if (!isNaN(windGust) && uniforms.uGustStrength) { uniforms.uGustStrength.value = windGust; console.log('Set uGustStrength to', windGust); }
      if (baseColor) { uniforms.uBaseColor.value.set(baseColor); console.log('Set uBaseColor to', baseColor); }
      if (midColor && uniforms.uMidColor) { uniforms.uMidColor.value.set(midColor); console.log('Set uMidColor to', midColor); }
      if (tipColor) { uniforms.uTipColor.value.set(tipColor); console.log('Set uTipColor to', tipColor); }
      
      // Lighting parameters
      const ambient = parseFloat(document.getElementById('grass_ambient')?.value);
      const aoStrength = parseFloat(document.getElementById('grass_ao')?.value);
      const lightX = parseFloat(document.getElementById('grass_light_x')?.value);
      const lightY = parseFloat(document.getElementById('grass_light_y')?.value);
      const lightZ = parseFloat(document.getElementById('grass_light_z')?.value);
      
      if (!isNaN(ambient) && uniforms.uAmbient) { uniforms.uAmbient.value = ambient; console.log('Set uAmbient to', ambient); }
      if (!isNaN(aoStrength) && uniforms.uAOStrength) { uniforms.uAOStrength.value = aoStrength; console.log('Set uAOStrength to', aoStrength); }
      if (uniforms.uLightDir && !isNaN(lightX) && !isNaN(lightY) && !isNaN(lightZ)) {
        uniforms.uLightDir.value.set(lightX, lightY, lightZ).normalize();
        console.log('Set uLightDir to', lightX, lightY, lightZ);
      }
      
      // Fog parameters
      const fogColor = document.getElementById('grass_fog_color')?.value;
      const fogNear = parseFloat(document.getElementById('grass_fog_near')?.value);
      const fogFar = parseFloat(document.getElementById('grass_fog_far')?.value);
      
      if (fogColor && uniforms.uFogColor) { uniforms.uFogColor.value.set(fogColor); console.log('Set uFogColor to', fogColor); }
      if (!isNaN(fogNear) && uniforms.uFogNear) { uniforms.uFogNear.value = fogNear; console.log('Set uFogNear to', fogNear); }
      if (!isNaN(fogFar) && uniforms.uFogFar) { uniforms.uFogFar.value = fogFar; console.log('Set uFogFar to', fogFar); }
      
      // Auto-save to localStorage
      scheduleAutoSave(sceneIdx);
    };
    
    const inputs = ['grass_wind_speed', 'grass_wind_str', 'grass_wind_freq', 'grass_wind_gust',
      'grass_wind_speed_num', 'grass_wind_str_num', 'grass_wind_freq_num', 'grass_wind_gust_num',
      'grass_color_base', 'grass_color_mid', 'grass_color_tip',
      'grass_ambient', 'grass_ambient_num', 'grass_ao', 'grass_ao_num',
      'grass_light_x', 'grass_light_x_num', 'grass_light_y', 'grass_light_y_num', 'grass_light_z', 'grass_light_z_num',
      'grass_fog_color', 'grass_fog_near', 'grass_fog_near_num', 'grass_fog_far', 'grass_fog_far_num'];
    inputs.forEach(id => {
      const el = document.getElementById(id);
      if (el) {
        el.addEventListener('focus', beginParamEdit);
        el.addEventListener('mousedown', beginParamEdit);
        el.addEventListener('input', handleChange);
        
        // Color pickers need special handling - their native dialog steals focus
        if (el.type === 'color') {
          el.addEventListener('change', endParamEdit);
        } else {
        el.addEventListener('blur', endParamEdit);
        el.addEventListener('mouseup', () => setTimeout(endParamEdit, 100));
        }
      }
    });
  }
  
  function populateVoxelParams(voxel, sceneIdx) {
    currentEditingSceneIdx = sceneIdx; // Track which scene is being edited
    const sceneObj = getSceneByIdx(sceneIdx);
    const uniforms = sceneObj?.voxelMesh?.material?.uniforms;
    
    let html = `<div class="param-group"><div class="param-group-title">Voxel Info</div>`;
    html += createParamRow('Count', `<span style="color:var(--muted)">${(voxel.count || 100000).toLocaleString()} voxels</span>`);
    html += createParamRow('Size', createSlider('voxel_size', voxel.voxelSize || 0.25, 0.05, 1.0, 0.01), 'voxel_size');
    html += `</div>`;
    
    html += `<div class="param-group"><div class="param-group-title">Displacement</div>`;
    html += createParamRow('Strength', createSlider('voxel_disp_str', uniforms?.uDispStrength?.value ?? voxel.displacement?.strength ?? 1.0, 0, 5, 0.1), 'voxel_disp_str');
    html += createParamRow('Frequency', createSlider('voxel_disp_freq', uniforms?.uDispFrequency?.value ?? voxel.displacement?.frequency ?? 1.0, 0.1, 5, 0.1), 'voxel_disp_freq');
    html += createParamRow('Speed', createSlider('voxel_disp_speed', uniforms?.uDispSpeed?.value ?? voxel.displacement?.speed ?? 0.3, 0, 2, 0.05), 'voxel_disp_speed');
    html += createParamRow('Noise Scale', createSlider('voxel_disp_noise', uniforms?.uDispNoiseScale?.value ?? voxel.displacement?.noiseScale ?? 1.5, 0.1, 5, 0.1), 'voxel_disp_noise');
    html += createParamRow('Wave Amp', createSlider('voxel_wave_amp', uniforms?.uWaveAmplitude?.value ?? voxel.displacement?.waveAmplitude ?? 0.5, 0, 3, 0.1), 'voxel_wave_amp');
    html += createParamRow('Wave Freq', createSlider('voxel_wave_freq', uniforms?.uWaveFrequency?.value ?? voxel.displacement?.waveFrequency ?? 3.0, 0.5, 10, 0.5), 'voxel_wave_freq');
    html += `</div>`;
    
    html += `<div class="param-group"><div class="param-group-title">Animation</div>`;
    html += createParamRow('Pulse Speed', createSlider('voxel_pulse_speed', uniforms?.uPulseSpeed?.value ?? voxel.animation?.pulseSpeed ?? 0.5, 0, 3, 0.1), 'voxel_pulse_speed');
    html += createParamRow('Pulse Str', createSlider('voxel_pulse_str', uniforms?.uPulseStrength?.value ?? voxel.animation?.pulseStrength ?? 0.2, 0, 1, 0.05), 'voxel_pulse_str');
    html += createParamRow('Rotate Speed', createSlider('voxel_rotate', uniforms?.uRotateSpeed?.value ?? voxel.animation?.rotateSpeed ?? 0.05, 0, 0.5, 0.01), 'voxel_rotate');
    html += createParamRow('Flow Speed', createSlider('voxel_flow', uniforms?.uFlowSpeed?.value ?? voxel.animation?.flowSpeed ?? 0.2, 0, 1, 0.05), 'voxel_flow');
    html += createParamRow('Spin Speed', createSlider('voxel_spin', uniforms?.uVoxelRotSpeed?.value ?? voxel.animation?.voxelRotSpeed ?? 1.0, 0, 3, 0.1), 'voxel_spin');
    html += `</div>`;
    
    html += `<div class="param-group"><div class="param-group-title">Colors</div>`;
    const colorInner = uniforms?.uColorInner?.value ? '#' + uniforms.uColorInner.value.getHexString() : (voxel.colors?.inner || '#00ffff');
    const colorOuter = uniforms?.uColorOuter?.value ? '#' + uniforms.uColorOuter.value.getHexString() : (voxel.colors?.outer || '#0044aa');
    const emInner = uniforms?.uEmissiveInner?.value ? '#' + uniforms.uEmissiveInner.value.getHexString() : (voxel.colors?.emissiveInner || '#00aaff');
    const emOuter = uniforms?.uEmissiveOuter?.value ? '#' + uniforms.uEmissiveOuter.value.getHexString() : (voxel.colors?.emissiveOuter || '#002255');
    html += createParamRow('Inner', createColorPicker('voxel_color_inner', colorInner), null);
    html += createParamRow('Outer', createColorPicker('voxel_color_outer', colorOuter), null);
    html += createParamRow('Em Inner', createColorPicker('voxel_em_inner', emInner), null);
    html += createParamRow('Em Outer', createColorPicker('voxel_em_outer', emOuter), null);
    html += createParamRow('Em Intensity', createSlider('voxel_em_int', uniforms?.uEmissiveIntensity?.value ?? voxel.colors?.emissiveIntensity ?? 1.0, 0, 5, 0.1), 'voxel_em_int');
    html += `</div>`;
    
    html += `<div class="param-group"><div class="param-group-title">Color Effects</div>`;
    html += createParamRow('Hue Shift', createSlider('voxel_hue', uniforms?.uHueShift?.value ?? voxel.colors?.hueShift ?? 0.1, 0, 1, 0.02), 'voxel_hue');
    html += createParamRow('Saturation', createSlider('voxel_sat', uniforms?.uSaturation?.value ?? voxel.colors?.saturation ?? 1.0, 0, 2, 0.05), 'voxel_sat');
    html += createParamRow('Brightness', createSlider('voxel_bright', uniforms?.uBrightness?.value ?? voxel.colors?.brightness ?? 1.0, 0.2, 3, 0.1), 'voxel_bright');
    html += `</div>`;
    
    html += `<div class="param-group"><div class="param-group-title">Material</div>`;
    html += createParamRow('Metalness', createSlider('voxel_metal', uniforms?.uMetalness?.value ?? voxel.material?.metalness ?? 0.7, 0, 1, 0.05), 'voxel_metal');
    html += createParamRow('Roughness', createSlider('voxel_rough', uniforms?.uRoughness?.value ?? voxel.material?.roughness ?? 0.3, 0, 1, 0.05), 'voxel_rough');
    html += createParamRow('Reflectivity', createSlider('voxel_reflect', uniforms?.uReflectivity?.value ?? voxel.material?.reflectivity ?? 0.8, 0, 1, 0.05), 'voxel_reflect');
    html += createParamRow('Fresnel', createSlider('voxel_fresnel', uniforms?.uFresnelPower?.value ?? voxel.material?.fresnelPower ?? 3.0, 1, 8, 0.5), 'voxel_fresnel');
    html += createParamRow('Spec Power', createSlider('voxel_spec_pow', uniforms?.uSpecularPower?.value ?? voxel.material?.specularPower ?? 32.0, 4, 128, 4), 'voxel_spec_pow');
    html += createParamRow('Spec Intensity', createSlider('voxel_spec_int', uniforms?.uSpecularIntensity?.value ?? voxel.material?.specularIntensity ?? 1.0, 0, 3, 0.1), 'voxel_spec_int');
    html += `</div>`;
    
    html += `<div class="param-group"><div class="param-group-title">Environment Map</div>`;
    html += createParamRow('Intensity', createSlider('voxel_env', uniforms?.uEnvMapIntensity?.value ?? voxel.material?.envMapIntensity ?? 1.0, 0, 5, 0.1), 'voxel_env');
    html += createParamRow('Rotation', createSlider('voxel_env_rot', uniforms?.uEnvMapRotation?.value ?? voxel.material?.envMapRotation ?? 0.0, 0, 6.28, 0.1), 'voxel_env_rot');
    html += `</div>`;
    
    html += `<div class="param-group"><div class="param-group-title">Glow</div>`;
    html += createParamRow('Strength', createSlider('voxel_glow_str', uniforms?.uGlowStrength?.value ?? voxel.glow?.strength ?? 0.5, 0, 3, 0.1), 'voxel_glow_str');
    html += createParamRow('Falloff', createSlider('voxel_glow_fall', uniforms?.uGlowFalloff?.value ?? voxel.glow?.falloff ?? 2.0, 0.5, 5, 0.1), 'voxel_glow_fall');
    html += `</div>`;
    
    html += `<div class="param-group"><div class="param-group-title">Dynamic Point Lights</div>`;
    const pl1Col = uniforms?.uPointLight1Color?.value ? '#' + uniforms.uPointLight1Color.value.getHexString() : '#ff4444';
    const pl2Col = uniforms?.uPointLight2Color?.value ? '#' + uniforms.uPointLight2Color.value.getHexString() : '#4466ff';
    html += createParamRow('Light 1 Color', createColorPicker('voxel_pl1_color', pl1Col), null);
    html += createParamRow('Light 1 Intensity', createSlider('voxel_pl1_int', uniforms?.uPointLight1Intensity?.value ?? 3.0, 0, 10, 0.1), 'voxel_pl1_int');
    html += createParamRow('Light 1 Distance', createSlider('voxel_pl1_dist', uniforms?.uPointLight1Distance?.value ?? 50, 5, 150, 1), 'voxel_pl1_dist');
    html += createParamRow('Light 2 Color', createColorPicker('voxel_pl2_color', pl2Col), null);
    html += createParamRow('Light 2 Intensity', createSlider('voxel_pl2_int', uniforms?.uPointLight2Intensity?.value ?? 3.0, 0, 10, 0.1), 'voxel_pl2_int');
    html += createParamRow('Light 2 Distance', createSlider('voxel_pl2_dist', uniforms?.uPointLight2Distance?.value ?? 50, 5, 150, 1), 'voxel_pl2_dist');
    html += `</div>`;
    
    html += `<div class="param-group"><div class="param-group-title">Fog</div>`;
    const fogCol = uniforms?.uFogColor?.value ? '#' + uniforms.uFogColor.value.getHexString() : '#040810';
    html += createParamRow('Color', createColorPicker('voxel_fog_color', fogCol), null);
    html += createParamRow('Near', createSlider('voxel_fog_near', uniforms?.uFogNear?.value ?? 30, 0, 100, 1), 'voxel_fog_near');
    html += createParamRow('Far', createSlider('voxel_fog_far', uniforms?.uFogFar?.value ?? 180, 50, 400, 10), 'voxel_fog_far');
    html += `</div>`;
    
    paramContent.innerHTML = html;
    
    // Link all sliders
    ['voxel_size', 'voxel_disp_str', 'voxel_disp_freq', 'voxel_disp_speed', 'voxel_disp_noise',
     'voxel_wave_amp', 'voxel_wave_freq', 'voxel_pulse_speed', 'voxel_pulse_str', 'voxel_rotate',
     'voxel_flow', 'voxel_spin', 'voxel_em_int', 'voxel_hue', 'voxel_sat', 'voxel_bright',
     'voxel_metal', 'voxel_rough', 'voxel_reflect', 'voxel_fresnel', 
     'voxel_spec_pow', 'voxel_spec_int', 'voxel_env', 'voxel_env_rot',
     'voxel_glow_str', 'voxel_glow_fall', 
     'voxel_pl1_int', 'voxel_pl1_dist', 'voxel_pl2_int', 'voxel_pl2_dist',
     'voxel_fog_near', 'voxel_fog_far'].forEach(linkSliderToNumber);
    
    addVoxelParamHandlers(sceneIdx);
  }
  
  function addVoxelParamHandlers(sceneIdx) {
    // Initial check
    const initialSceneObj = getSceneByIdx(sceneIdx);
    if (!initialSceneObj?.voxelMesh) return;
    
    const handleChange = () => {
      // Get scene object fresh each time (important for segment-specific scenes)
      const sceneObj = getSceneByIdx(sceneIdx);
      if (!sceneObj?.voxelMesh) return;
      
      const uniforms = sceneObj.voxelMesh.material?.uniforms;
      if (!uniforms) return;
      
      // Displacement
      const dispStr = parseFloat(document.getElementById('voxel_disp_str')?.value);
      const dispFreq = parseFloat(document.getElementById('voxel_disp_freq')?.value);
      const dispSpeed = parseFloat(document.getElementById('voxel_disp_speed')?.value);
      const dispNoise = parseFloat(document.getElementById('voxel_disp_noise')?.value);
      const waveAmp = parseFloat(document.getElementById('voxel_wave_amp')?.value);
      const waveFreq = parseFloat(document.getElementById('voxel_wave_freq')?.value);
      
      if (!isNaN(dispStr) && uniforms.uDispStrength) uniforms.uDispStrength.value = dispStr;
      if (!isNaN(dispFreq) && uniforms.uDispFrequency) uniforms.uDispFrequency.value = dispFreq;
      if (!isNaN(dispSpeed) && uniforms.uDispSpeed) uniforms.uDispSpeed.value = dispSpeed;
      if (!isNaN(dispNoise) && uniforms.uDispNoiseScale) uniforms.uDispNoiseScale.value = dispNoise;
      if (!isNaN(waveAmp) && uniforms.uWaveAmplitude) uniforms.uWaveAmplitude.value = waveAmp;
      if (!isNaN(waveFreq) && uniforms.uWaveFrequency) uniforms.uWaveFrequency.value = waveFreq;
      
      // Animation
      const pulseSpeed = parseFloat(document.getElementById('voxel_pulse_speed')?.value);
      const pulseStr = parseFloat(document.getElementById('voxel_pulse_str')?.value);
      const rotate = parseFloat(document.getElementById('voxel_rotate')?.value);
      const flow = parseFloat(document.getElementById('voxel_flow')?.value);
      const spin = parseFloat(document.getElementById('voxel_spin')?.value);
      
      if (!isNaN(pulseSpeed) && uniforms.uPulseSpeed) uniforms.uPulseSpeed.value = pulseSpeed;
      if (!isNaN(pulseStr) && uniforms.uPulseStrength) uniforms.uPulseStrength.value = pulseStr;
      if (!isNaN(rotate) && uniforms.uRotateSpeed) uniforms.uRotateSpeed.value = rotate;
      if (!isNaN(flow) && uniforms.uFlowSpeed) uniforms.uFlowSpeed.value = flow;
      if (!isNaN(spin) && uniforms.uVoxelRotSpeed) uniforms.uVoxelRotSpeed.value = spin;
      
      // Colors
      const colorInner = document.getElementById('voxel_color_inner')?.value;
      const colorOuter = document.getElementById('voxel_color_outer')?.value;
      const emInner = document.getElementById('voxel_em_inner')?.value;
      const emOuter = document.getElementById('voxel_em_outer')?.value;
      const emInt = parseFloat(document.getElementById('voxel_em_int')?.value);
      
      if (colorInner && uniforms.uColorInner) uniforms.uColorInner.value.set(colorInner);
      if (colorOuter && uniforms.uColorOuter) uniforms.uColorOuter.value.set(colorOuter);
      if (emInner && uniforms.uEmissiveInner) uniforms.uEmissiveInner.value.set(emInner);
      if (emOuter && uniforms.uEmissiveOuter) uniforms.uEmissiveOuter.value.set(emOuter);
      if (!isNaN(emInt) && uniforms.uEmissiveIntensity) uniforms.uEmissiveIntensity.value = emInt;
      
      // Color effects
      const hue = parseFloat(document.getElementById('voxel_hue')?.value);
      const sat = parseFloat(document.getElementById('voxel_sat')?.value);
      const bright = parseFloat(document.getElementById('voxel_bright')?.value);
      
      if (!isNaN(hue) && uniforms.uHueShift) uniforms.uHueShift.value = hue;
      if (!isNaN(sat) && uniforms.uSaturation) uniforms.uSaturation.value = sat;
      if (!isNaN(bright) && uniforms.uBrightness) uniforms.uBrightness.value = bright;
      
      // Material
      const metal = parseFloat(document.getElementById('voxel_metal')?.value);
      const rough = parseFloat(document.getElementById('voxel_rough')?.value);
      const reflect = parseFloat(document.getElementById('voxel_reflect')?.value);
      const fresnel = parseFloat(document.getElementById('voxel_fresnel')?.value);
      const specPow = parseFloat(document.getElementById('voxel_spec_pow')?.value);
      const specInt = parseFloat(document.getElementById('voxel_spec_int')?.value);
      
      if (!isNaN(metal) && uniforms.uMetalness) uniforms.uMetalness.value = metal;
      if (!isNaN(rough) && uniforms.uRoughness) uniforms.uRoughness.value = rough;
      if (!isNaN(reflect) && uniforms.uReflectivity) uniforms.uReflectivity.value = reflect;
      if (!isNaN(fresnel) && uniforms.uFresnelPower) uniforms.uFresnelPower.value = fresnel;
      if (!isNaN(specPow) && uniforms.uSpecularPower) uniforms.uSpecularPower.value = specPow;
      if (!isNaN(specInt) && uniforms.uSpecularIntensity) uniforms.uSpecularIntensity.value = specInt;
      
      // Environment map
      const envInt = parseFloat(document.getElementById('voxel_env')?.value);
      const envRot = parseFloat(document.getElementById('voxel_env_rot')?.value);
      
      if (!isNaN(envInt) && uniforms.uEnvMapIntensity) uniforms.uEnvMapIntensity.value = envInt;
      if (!isNaN(envRot) && uniforms.uEnvMapRotation) uniforms.uEnvMapRotation.value = envRot;
      
      // Glow
      const glowStr = parseFloat(document.getElementById('voxel_glow_str')?.value);
      const glowFall = parseFloat(document.getElementById('voxel_glow_fall')?.value);
      
      if (!isNaN(glowStr) && uniforms.uGlowStrength) uniforms.uGlowStrength.value = glowStr;
      if (!isNaN(glowFall) && uniforms.uGlowFalloff) uniforms.uGlowFalloff.value = glowFall;
      
      // Fog
      const fogColor = document.getElementById('voxel_fog_color')?.value;
      const fogNear = parseFloat(document.getElementById('voxel_fog_near')?.value);
      const fogFar = parseFloat(document.getElementById('voxel_fog_far')?.value);
      
      if (fogColor && uniforms.uFogColor) uniforms.uFogColor.value.set(fogColor);
      if (!isNaN(fogNear) && uniforms.uFogNear) uniforms.uFogNear.value = fogNear;
      if (!isNaN(fogFar) && uniforms.uFogFar) uniforms.uFogFar.value = fogFar;
      
      // Dynamic point lights
      const pl1Color = document.getElementById('voxel_pl1_color')?.value;
      const pl1Int = parseFloat(document.getElementById('voxel_pl1_int')?.value);
      const pl1Dist = parseFloat(document.getElementById('voxel_pl1_dist')?.value);
      const pl2Color = document.getElementById('voxel_pl2_color')?.value;
      const pl2Int = parseFloat(document.getElementById('voxel_pl2_int')?.value);
      const pl2Dist = parseFloat(document.getElementById('voxel_pl2_dist')?.value);
      
      if (pl1Color && uniforms.uPointLight1Color) uniforms.uPointLight1Color.value.set(pl1Color);
      if (!isNaN(pl1Int) && uniforms.uPointLight1Intensity) uniforms.uPointLight1Intensity.value = pl1Int;
      if (!isNaN(pl1Dist) && uniforms.uPointLight1Distance) uniforms.uPointLight1Distance.value = pl1Dist;
      if (pl2Color && uniforms.uPointLight2Color) uniforms.uPointLight2Color.value.set(pl2Color);
      if (!isNaN(pl2Int) && uniforms.uPointLight2Intensity) uniforms.uPointLight2Intensity.value = pl2Int;
      if (!isNaN(pl2Dist) && uniforms.uPointLight2Distance) uniforms.uPointLight2Distance.value = pl2Dist;
      
      // Also update the actual Three.js point lights if they exist
      if (sceneObj.animatedLights) {
        for (let li = 0; li < sceneObj.animatedLights.length && li < 2; li++) {
          const light = sceneObj.animatedLights[li];
          const idx = li + 1;
          const col = document.getElementById(`voxel_pl${idx}_color`)?.value;
          const int = parseFloat(document.getElementById(`voxel_pl${idx}_int`)?.value);
          const dist = parseFloat(document.getElementById(`voxel_pl${idx}_dist`)?.value);
          if (col) light.color.set(col);
          if (!isNaN(int)) light.intensity = int;
          if (!isNaN(dist)) light.distance = dist;
        }
      }
      
      // Auto-save to localStorage
      scheduleAutoSave(sceneIdx);
    };
    
    const inputs = [
      'voxel_disp_str', 'voxel_disp_str_num', 'voxel_disp_freq', 'voxel_disp_freq_num',
      'voxel_disp_speed', 'voxel_disp_speed_num', 'voxel_disp_noise', 'voxel_disp_noise_num',
      'voxel_wave_amp', 'voxel_wave_amp_num', 'voxel_wave_freq', 'voxel_wave_freq_num',
      'voxel_pulse_speed', 'voxel_pulse_speed_num', 'voxel_pulse_str', 'voxel_pulse_str_num',
      'voxel_rotate', 'voxel_rotate_num', 'voxel_flow', 'voxel_flow_num',
      'voxel_color_inner', 'voxel_color_outer', 'voxel_em_inner', 'voxel_em_outer',
      'voxel_em_int', 'voxel_em_int_num', 'voxel_hue', 'voxel_hue_num',
      'voxel_sat', 'voxel_sat_num', 'voxel_bright', 'voxel_bright_num',
      'voxel_metal', 'voxel_metal_num', 'voxel_rough', 'voxel_rough_num',
      'voxel_reflect', 'voxel_reflect_num', 'voxel_env', 'voxel_env_num',
      'voxel_fresnel', 'voxel_fresnel_num',
      'voxel_glow_str', 'voxel_glow_str_num', 'voxel_glow_fall', 'voxel_glow_fall_num',
      'voxel_pl1_color', 'voxel_pl1_int', 'voxel_pl1_int_num', 'voxel_pl1_dist', 'voxel_pl1_dist_num',
      'voxel_pl2_color', 'voxel_pl2_int', 'voxel_pl2_int_num', 'voxel_pl2_dist', 'voxel_pl2_dist_num',
      'voxel_fog_color', 'voxel_fog_near', 'voxel_fog_near_num', 'voxel_fog_far', 'voxel_fog_far_num'
    ];
    
    inputs.forEach(id => {
      const el = document.getElementById(id);
      if (el) {
        el.addEventListener('focus', beginParamEdit);
        el.addEventListener('mousedown', beginParamEdit);
        el.addEventListener('input', handleChange);
        
        // Color pickers need special handling - their native dialog steals focus
        if (el.type === 'color') {
          el.addEventListener('change', endParamEdit);
        } else {
        el.addEventListener('blur', endParamEdit);
        el.addEventListener('mouseup', () => setTimeout(endParamEdit, 100));
        }
      }
    });
  }
  
  function populateSphereClonersParams(cloner, sceneIdx) {
    currentEditingSceneIdx = sceneIdx; // Track which scene is being edited
    const sceneObj = getSceneByIdx(sceneIdx);
    const uniforms = sceneObj?.sphereClonerMesh?.material?.uniforms;
    const clonerPosition = sceneObj?.sphereClonerMesh?.position || {
      x: cloner.offsetX ?? 0,
      y: cloner.offsetY ?? 0,
      z: cloner.offsetZ ?? 0,
    };
    const clonerRotation = sceneObj?.sphereClonerMesh?.rotation
      ? {
        x: sceneObj.sphereClonerMesh.rotation.x * 180 / Math.PI,
        y: sceneObj.sphereClonerMesh.rotation.y * 180 / Math.PI,
        z: sceneObj.sphereClonerMesh.rotation.z * 180 / Math.PI,
      }
      : {
        x: cloner.rotationX ?? 0,
        y: cloner.rotationY ?? 0,
        z: cloner.rotationZ ?? 0,
      };
    
    // === REBUILD REQUIRED SECTION ===
    // All parameters that require rebuild are grouped together with distinctive styling
    const distType = cloner.distributionType || 'sphere';
    
    let html = `<div class="param-group rebuild-required-group">
      <div class="param-group-title">
        Rebuild Required
        <span style="color:var(--muted);font-size:9px;margin-left:6px;">Changes need rebuild to apply</span>
      </div>`;
    
    // Geometry subsection
    html += `<div style="margin-bottom: 12px;">
      <div style="font-size: 10px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 6px;">Clone Geometry</div>`;
    html += createParamRow('Count', `
      <input type="number" id="cloner_count" value="${cloner.count || 1000}" min="100" max="10000" step="100" class="rebuild-input">
    `);
    html += createParamRow('Clone Size', createSlider('cloner_clone_radius', cloner.cloneRadius ?? 0.18, 0.05, 0.5, 0.01), 'cloner_clone_radius');
    html += createParamRow('Size Variation', createSlider('cloner_size_var', cloner.sizeVariation ?? 0.6, 0, 1, 0.05), 'cloner_size_var');
    html += createParamRow('Clone Type', `
      <select id="cloner_type" class="rebuild-select">
        <option value="icosahedron" ${cloner.cloneType === 'icosahedron' ? 'selected' : ''}>Icosahedron</option>
        <option value="octahedron" ${cloner.cloneType === 'octahedron' ? 'selected' : ''}>Octahedron</option>
        <option value="dodecahedron" ${cloner.cloneType === 'dodecahedron' ? 'selected' : ''}>Dodecahedron</option>
        <option value="cube" ${cloner.cloneType === 'cube' ? 'selected' : ''}>Cube</option>
        <option value="sphere" ${cloner.cloneType === 'sphere' ? 'selected' : ''}>Sphere</option>
        <option value="torus" ${cloner.cloneType === 'torus' ? 'selected' : ''}>Torus</option>
        <option value="cone" ${cloner.cloneType === 'cone' ? 'selected' : ''}>Cone</option>
        <option value="cylinder" ${cloner.cloneType === 'cylinder' ? 'selected' : ''}>Cylinder</option>
        <option value="tetrahedron" ${cloner.cloneType === 'tetrahedron' ? 'selected' : ''}>Tetrahedron</option>
        <option value="custom" ${cloner.cloneType === 'custom' ? 'selected' : ''}>📁 Custom 3D Model...</option>
      </select>
    `);
    // Custom clone type browse button
    const hasCustomClone = cloner.cloneType === 'custom';
    const customCloneName = cloner.customCloneModel ? cloner.customCloneModel.split('/').pop() : 'None';
    html += `<div id="cloner_custom_type_container" style="display:${hasCustomClone ? 'block' : 'none'}; margin-top: 6px;">`;
    html += createParamRow('3D Model', `
      <div style="display:flex;gap:6px;align-items:center;width:100%;">
        <span id="cloner_custom_type_name" style="flex:1;font-size:10px;color:${cloner.customCloneModel ? 'var(--text)' : 'var(--muted)'};overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${customCloneName}</span>
        <button id="cloner_custom_type_btn" style="background:var(--accent);color:#fff;border:none;border-radius:4px;padding:4px 8px;font-size:10px;cursor:pointer;white-space:nowrap;">Browse</button>
      </div>
    `);
    html += `</div>`;
    html += createParamRow('Detail Level', createSlider('cloner_detail', cloner.cloneDetail ?? 0, 0, 3, 1), 'cloner_detail');
    html += `</div>`;
    
    // Distribution subsection
    html += `<div style="margin-bottom: 12px;">
      <div style="font-size: 10px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 6px;">📍 Distribution</div>`;
    html += createParamRow('Base Shape', `
      <select id="cloner_dist_type" class="rebuild-select">
        <option value="sphere" ${distType === 'sphere' ? 'selected' : ''}>Sphere</option>
        <option value="cube" ${distType === 'cube' ? 'selected' : ''}>🟦 Cube</option>
        <option value="cylinder" ${distType === 'cylinder' ? 'selected' : ''}>🛢️ Cylinder</option>
        <option value="torus" ${distType === 'torus' ? 'selected' : ''}>🍩 Torus</option>
        <option value="plane" ${distType === 'plane' ? 'selected' : ''}>📄 Plane</option>
        <option value="grid" ${distType === 'grid' ? 'selected' : ''}>🔲 3D Grid</option>
        <option value="ring" ${distType === 'ring' ? 'selected' : ''}>⭕ Ring</option>
        <option value="line" ${distType === 'line' ? 'selected' : ''}>📏 Line</option>
        <option value="helix" ${distType === 'helix' ? 'selected' : ''}>🧬 Helix</option>
        <option value="spiral" ${distType === 'spiral' ? 'selected' : ''}>🌀 Spiral</option>
        <option value="custom" ${distType === 'custom' ? 'selected' : ''}>📁 Custom 3D Surface...</option>
      </select>
    `);
    // Custom distribution browse button
    const hasCustomDist = distType === 'custom';
    const customDistName = cloner.customDistModel ? cloner.customDistModel.split('/').pop() : 'None';
    html += `<div id="cloner_custom_dist_container" style="display:${hasCustomDist ? 'block' : 'none'}; margin-top: 6px;">`;
    html += createParamRow('3D Surface', `
      <div style="display:flex;gap:6px;align-items:center;width:100%;">
        <span id="cloner_custom_dist_name" style="flex:1;font-size:10px;color:${cloner.customDistModel ? 'var(--text)' : 'var(--muted)'};overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${customDistName}</span>
        <button id="cloner_custom_dist_btn" style="background:var(--accent);color:#fff;border:none;border-radius:4px;padding:4px 8px;font-size:10px;cursor:pointer;white-space:nowrap;">Browse</button>
      </div>
    `);
    html += `</div>`;
    html += createParamRow('Base Radius', createSlider('cloner_base_radius', uniforms?.uBaseRadius?.value ?? cloner.baseRadius ?? 6, 1, 20, 0.5), 'cloner_base_radius');
    
    // Torus parameters
    html += `<div id="cloner_torus_params" style="display:${distType === 'torus' ? 'block' : 'none'};">`;
    html += createParamRow('Tube Radius', createSlider('cloner_torus_inner', cloner.torusInnerRadius ?? 2, 0.5, 10, 0.25), 'cloner_torus_inner');
    html += `</div>`;
    
    // Cylinder/Helix parameters
    html += `<div id="cloner_cylinder_params" style="display:${(distType === 'cylinder' || distType === 'helix') ? 'block' : 'none'};">`;
    html += createParamRow('Height', createSlider('cloner_cylinder_height', cloner.cylinderHeight ?? 10, 1, 30, 0.5), 'cloner_cylinder_height');
    html += `</div>`;
    
    // Helix-specific
    html += `<div id="cloner_helix_params" style="display:${distType === 'helix' ? 'block' : 'none'};">`;
    html += createParamRow('Turns', createSlider('cloner_helix_turns', cloner.helixTurns ?? 3, 1, 10, 0.5), 'cloner_helix_turns');
    html += `</div>`;
    
    // Plane parameters
    html += `<div id="cloner_plane_params" style="display:${distType === 'plane' ? 'block' : 'none'};">`;
    html += createParamRow('Width', createSlider('cloner_plane_width', cloner.planeWidth ?? 12, 2, 40, 1), 'cloner_plane_width');
    html += createParamRow('Depth', createSlider('cloner_plane_depth', cloner.planeDepth ?? 12, 2, 40, 1), 'cloner_plane_depth');
    html += `</div>`;
    
    // Line parameters
    html += `<div id="cloner_line_params" style="display:${distType === 'line' ? 'block' : 'none'};">`;
    html += createParamRow('Length', createSlider('cloner_line_length', cloner.lineLength ?? 10, 2, 40, 1), 'cloner_line_length');
    html += createParamRow('Axis', `
      <select id="cloner_line_axis" class="rebuild-select">
        <option value="x" ${cloner.lineAxis === 'x' ? 'selected' : ''}>X</option>
        <option value="y" ${(cloner.lineAxis ?? 'y') === 'y' ? 'selected' : ''}>Y</option>
        <option value="z" ${cloner.lineAxis === 'z' ? 'selected' : ''}>Z</option>
      </select>
    `);
    html += `</div>`;
    
    // Spiral parameters
    html += `<div id="cloner_spiral_params" style="display:${distType === 'spiral' ? 'block' : 'none'};">`;
    html += createParamRow('Turns', createSlider('cloner_spiral_turns', cloner.spiralTurns ?? 5, 1, 15, 0.5), 'cloner_spiral_turns');
    html += `</div>`;
    html += `</div>`;
    
    // Rebuild button at the end of rebuild-required section
    html += `<div style="padding-top: 10px;">
      <button id="cloner_rebuild" class="rebuild-btn">Rebuild Cloner</button>
    </div>`;
    
    html += `</div>`;
    
    html += `<div class="param-group"><div class="param-group-title">Placement</div>`;
    html += createParamRow('Offset X', createSlider('cloner_offset_x', clonerPosition.x, -30, 30, 0.1), 'cloner_offset_x');
    html += createParamRow('Offset Y', createSlider('cloner_offset_y', clonerPosition.y, -30, 30, 0.1), 'cloner_offset_y');
    html += createParamRow('Offset Z', createSlider('cloner_offset_z', clonerPosition.z, -50, 50, 0.1), 'cloner_offset_z');
    html += createParamRow('Rotation X', createSlider('cloner_rotation_x', clonerRotation.x, -180, 180, 0.1), 'cloner_rotation_x');
    html += createParamRow('Rotation Y', createSlider('cloner_rotation_y', clonerRotation.y, -180, 180, 0.1), 'cloner_rotation_y');
    html += createParamRow('Rotation Z', createSlider('cloner_rotation_z', clonerRotation.z, -180, 180, 0.1), 'cloner_rotation_z');
    html += `</div>`;

    html += `<div class="param-group"><div class="param-group-title">Animation</div>`;
    html += createParamRow('Offset Amount', createSlider('cloner_offset_amt', uniforms?.uOffsetAmount?.value ?? cloner.offsetAmount ?? 0.5, 0, 3, 0.05), 'cloner_offset_amt');
    html += createParamRow('Offset Speed', createSlider('cloner_offset_spd', uniforms?.uOffsetSpeed?.value ?? cloner.offsetSpeed ?? 1.0, 0, 5, 0.1), 'cloner_offset_spd');
    html += createParamRow('Rotation Speed', createSlider('cloner_rot_spd', uniforms?.uRotationSpeed?.value ?? cloner.rotationSpeed ?? 1.0, 0, 5, 0.1), 'cloner_rot_spd');
    html += createParamRow('Orbit Speed', createSlider('cloner_orbit_spd', uniforms?.uOrbitSpeed?.value ?? cloner.orbitSpeed ?? 0.2, 0, 1, 0.02), 'cloner_orbit_spd');
    html += createParamRow('Pulse Amount', createSlider('cloner_pulse_amt', uniforms?.uPulseAmount?.value ?? cloner.pulseAmount ?? 0.1, 0, 1, 0.02), 'cloner_pulse_amt');
    html += createParamRow('Pulse Speed', createSlider('cloner_pulse_spd', uniforms?.uPulseSpeed?.value ?? cloner.pulseSpeed ?? 2.0, 0, 5, 0.1), 'cloner_pulse_spd');
    html += createParamRow('Breath Amount', createSlider('cloner_breath_amt', uniforms?.uBreathAmount?.value ?? cloner.breathAmount ?? 0.2, 0, 1, 0.02), 'cloner_breath_amt');
    html += createParamRow('Breath Speed', createSlider('cloner_breath_spd', uniforms?.uBreathSpeed?.value ?? cloner.breathSpeed ?? 0.5, 0, 2, 0.05), 'cloner_breath_spd');
    html += createParamRow('Color Mix Speed', createSlider('cloner_color_mix_spd', uniforms?.uColorMixSpeed?.value ?? cloner.colorMixSpeed ?? 0.5, 0, 3, 0.05), 'cloner_color_mix_spd');
    html += createParamRow('Emissive Pulse', createSlider('cloner_em_pulse_spd', uniforms?.uEmissivePulseSpeed?.value ?? cloner.emissivePulseSpeed ?? 2.0, 0, 5, 0.1), 'cloner_em_pulse_spd');
    html += `</div>`;
    
    html += `<div class="param-group"><div class="param-group-title">Colors</div>`;
    const baseCol = uniforms?.uBaseColor?.value ? '#' + uniforms.uBaseColor.value.getHexString() : (cloner.colors?.base || '#8866ff');
    const accentCol = uniforms?.uAccentColor?.value ? '#' + uniforms.uAccentColor.value.getHexString() : (cloner.colors?.accent || '#ff66aa');
    const emissiveCol = uniforms?.uEmissiveColor?.value ? '#' + uniforms.uEmissiveColor.value.getHexString() : (cloner.colors?.emissive || '#4422aa');
    html += createParamRow('Base', createColorPicker('cloner_color_base', baseCol), null);
    html += createParamRow('Accent', createColorPicker('cloner_color_accent', accentCol), null);
    html += createParamRow('Emissive', createColorPicker('cloner_color_emissive', emissiveCol), null);
    html += createParamRow('Em Intensity', createSlider('cloner_em_int', uniforms?.uEmissiveIntensity?.value ?? cloner.colors?.emissiveIntensity ?? 0.3, 0, 3, 0.1), 'cloner_em_int');
    html += `</div>`;
    
    html += `<div class="param-group"><div class="param-group-title">Material</div>`;
    html += createParamRow('Metalness', createSlider('cloner_metalness', uniforms?.uMetalness?.value ?? cloner.material?.metalness ?? 0.9, 0, 1, 0.02), 'cloner_metalness');
    html += createParamRow('Roughness', createSlider('cloner_roughness', uniforms?.uRoughness?.value ?? cloner.material?.roughness ?? 0.1, 0, 1, 0.02), 'cloner_roughness');
    html += createParamRow('Env Map Int', createSlider('cloner_env_int', uniforms?.uEnvMapIntensity?.value ?? cloner.material?.envMapIntensity ?? 1.5, 0, 5, 0.1), 'cloner_env_int');
    html += createParamRow('Fresnel Power', createSlider('cloner_fresnel', uniforms?.uFresnelPower?.value ?? cloner.material?.fresnelPower ?? 3.0, 0.5, 8, 0.5), 'cloner_fresnel');
    html += `</div>`;
    
    // Lighting section (shader-based lighting)
    html += `<div class="param-group"><div class="param-group-title">Main Light</div>`;
    const lightCol = uniforms?.uLightColor?.value ? '#' + uniforms.uLightColor.value.getHexString() : (cloner.light?.color || '#ffffff');
    html += createParamRow('Color', createColorPicker('cloner_light_color', lightCol), null);
    html += createParamRow('Intensity', createSlider('cloner_light_int', uniforms?.uLightIntensity?.value ?? cloner.light?.intensity ?? 1.0, 0, 3, 0.1), 'cloner_light_int');
    const lightDir = cloner.light?.direction || { x: 1, y: 1, z: 0.5 };
    const ldx = uniforms?.uLightDir?.value?.x ?? lightDir.x;
    const ldy = uniforms?.uLightDir?.value?.y ?? lightDir.y;
    const ldz = uniforms?.uLightDir?.value?.z ?? lightDir.z;
    html += createParamRow('Direction', createXYZ('cloner_light_dir', ldx, ldy, ldz, -1, 1, 0.05, true), null);
    html += `</div>`;
    
    html += `<div class="param-group"><div class="param-group-title">Ambient Light</div>`;
    const ambCol = uniforms?.uAmbientColor?.value ? '#' + uniforms.uAmbientColor.value.getHexString() : (cloner.light?.ambientColor || '#1a1a3a');
    html += createParamRow('Color', createColorPicker('cloner_ambient_color', ambCol), null);
    html += createParamRow('Intensity', createSlider('cloner_ambient_int', uniforms?.uAmbientIntensity?.value ?? cloner.light?.ambientIntensity ?? 0.4, 0, 2, 0.05), 'cloner_ambient_int');
    html += `</div>`;
    
    // === EFFECTORS ===
    const eff = cloner.effectors || {};
    
    // Plain Effector
    html += `<div class="param-group"><div class="param-group-title">⬜ Plain Effector</div>`;
    html += createParamRow('Enabled', `<input type="checkbox" id="eff_plain_enabled" ${eff.plain?.enabled ? 'checked' : ''} style="width:16px;height:16px;">`, null);
    html += createParamRow('Position X', createSlider('eff_plain_pos_x', uniforms?.uPlainPosition?.value?.x ?? eff.plain?.position?.x ?? 0, -5, 5, 0.1), 'eff_plain_pos_x');
    html += createParamRow('Position Y', createSlider('eff_plain_pos_y', uniforms?.uPlainPosition?.value?.y ?? eff.plain?.position?.y ?? 0, -5, 5, 0.1), 'eff_plain_pos_y');
    html += createParamRow('Position Z', createSlider('eff_plain_pos_z', uniforms?.uPlainPosition?.value?.z ?? eff.plain?.position?.z ?? 0, -5, 5, 0.1), 'eff_plain_pos_z');
    html += createParamRow('Scale', createSlider('eff_plain_scale', uniforms?.uPlainScale?.value ?? eff.plain?.scale ?? 0, -1, 2, 0.05), 'eff_plain_scale');
    html += createParamRow('Rotation X', createSlider('eff_plain_rot_x', uniforms?.uPlainRotation?.value?.x ?? eff.plain?.rotation?.x ?? 0, -3.14, 3.14, 0.1), 'eff_plain_rot_x');
    html += createParamRow('Rotation Y', createSlider('eff_plain_rot_y', uniforms?.uPlainRotation?.value?.y ?? eff.plain?.rotation?.y ?? 0, -3.14, 3.14, 0.1), 'eff_plain_rot_y');
    html += createParamRow('Rotation Z', createSlider('eff_plain_rot_z', uniforms?.uPlainRotation?.value?.z ?? eff.plain?.rotation?.z ?? 0, -3.14, 3.14, 0.1), 'eff_plain_rot_z');
    html += `</div>`;
    
    // Noise Effector
    html += `<div class="param-group"><div class="param-group-title">🌊 Noise Effector</div>`;
    html += createParamRow('Enabled', `<input type="checkbox" id="eff_noise_enabled" ${eff.noise?.enabled ? 'checked' : ''} style="width:16px;height:16px;">`, null);
    html += createParamRow('Strength', createSlider('eff_noise_strength', uniforms?.uNoiseStrength?.value ?? eff.noise?.strength ?? 0.5, 0, 3, 0.05), 'eff_noise_strength');
    html += createParamRow('Scale', createSlider('eff_noise_scale', uniforms?.uNoiseScale?.value ?? eff.noise?.scale ?? 1.0, 0.1, 5, 0.1), 'eff_noise_scale');
    html += createParamRow('Speed', createSlider('eff_noise_speed', uniforms?.uNoiseSpeed?.value ?? eff.noise?.speed ?? 0.5, 0, 3, 0.05), 'eff_noise_speed');
    html += createParamRow('Octaves', createSlider('eff_noise_octaves', uniforms?.uNoiseOctaves?.value ?? eff.noise?.octaves ?? 3, 1, 6, 1), 'eff_noise_octaves');
    html += createParamRow('Scale Effect', createSlider('eff_noise_scale_eff', uniforms?.uNoiseScaleEffect?.value ?? eff.noise?.scaleEffect ?? 0, -1, 1, 0.05), 'eff_noise_scale_eff');
    html += createParamRow('Rotation Effect', createSlider('eff_noise_rot_eff', uniforms?.uNoiseRotEffect?.value ?? eff.noise?.rotationEffect ?? 0, -2, 2, 0.1), 'eff_noise_rot_eff');
    html += `</div>`;
    
    // Delay Effector
    html += `<div class="param-group"><div class="param-group-title">⏱️ Delay Effector</div>`;
    html += createParamRow('Enabled', `<input type="checkbox" id="eff_delay_enabled" ${eff.delay?.enabled ? 'checked' : ''} style="width:16px;height:16px;">`, null);
    html += createParamRow('Amount', createSlider('eff_delay_amount', uniforms?.uDelayAmount?.value ?? eff.delay?.amount ?? 0.5, 0, 5, 0.1), 'eff_delay_amount');
    html += createParamRow('Mode', `
      <select id="eff_delay_mode" style="background:#1a1d24;border:1px solid rgba(255,255,255,0.2);border-radius:4px;color:#e0e0e0;padding:4px 6px;font-size:11px;width:100%;">
        <option value="0" ${(eff.delay?.mode ?? 0) === 0 ? 'selected' : ''}>Linear (by index)</option>
        <option value="1" ${(eff.delay?.mode ?? 0) === 1 ? 'selected' : ''}>Radial (by Y)</option>
        <option value="2" ${(eff.delay?.mode ?? 0) === 2 ? 'selected' : ''}>Random</option>
      </select>
    `);
    html += createParamRow('Offset', createSlider('eff_delay_offset', uniforms?.uDelayOffset?.value ?? eff.delay?.offset ?? 0, -2, 2, 0.1), 'eff_delay_offset');
    html += `</div>`;
    
    // Spherical Field Effector
    html += `<div class="param-group"><div class="param-group-title">🔮 Spherical Field</div>`;
    html += createParamRow('Enabled', `<input type="checkbox" id="eff_field_enabled" ${eff.field?.enabled ? 'checked' : ''} style="width:16px;height:16px;">`, null);
    html += createParamRow('Position X', createSlider('eff_field_pos_x', uniforms?.uFieldPosition?.value?.x ?? eff.field?.position?.x ?? 0, -10, 10, 0.2), 'eff_field_pos_x');
    html += createParamRow('Position Y', createSlider('eff_field_pos_y', uniforms?.uFieldPosition?.value?.y ?? eff.field?.position?.y ?? 0, -10, 10, 0.2), 'eff_field_pos_y');
    html += createParamRow('Position Z', createSlider('eff_field_pos_z', uniforms?.uFieldPosition?.value?.z ?? eff.field?.position?.z ?? 0, -10, 10, 0.2), 'eff_field_pos_z');
    html += createParamRow('Radius', createSlider('eff_field_radius', uniforms?.uFieldRadius?.value ?? eff.field?.radius ?? 5, 0.5, 20, 0.5), 'eff_field_radius');
    html += createParamRow('Falloff', createSlider('eff_field_falloff', uniforms?.uFieldFalloff?.value ?? eff.field?.falloff ?? 1, 0.1, 5, 0.1), 'eff_field_falloff');
    html += createParamRow('Strength', createSlider('eff_field_strength', uniforms?.uFieldStrength?.value ?? eff.field?.strength ?? 1, 0, 3, 0.1), 'eff_field_strength');
    html += createParamRow('Invert', `<input type="checkbox" id="eff_field_invert" ${eff.field?.invert ? 'checked' : ''} style="width:16px;height:16px;">`, null);
    html += createParamRow('Scale Effect', createSlider('eff_field_scale_eff', uniforms?.uFieldScaleEffect?.value ?? eff.field?.scaleEffect ?? 0.5, -2, 2, 0.1), 'eff_field_scale_eff');
    html += createParamRow('Push Effect', createSlider('eff_field_push_eff', uniforms?.uFieldPushEffect?.value ?? eff.field?.pushEffect ?? 0.5, -3, 3, 0.1), 'eff_field_push_eff');
    html += `</div>`;
    
    // Formula Effector
    html += `<div class="param-group"><div class="param-group-title">📐 Formula Effector</div>`;
    html += createParamRow('Enabled', `<input type="checkbox" id="eff_formula_enabled" ${eff.formula?.enabled ? 'checked' : ''} style="width:16px;height:16px;">`, null);
    html += createParamRow('Type', `
      <select id="eff_formula_type" style="background:#1a1d24;border:1px solid rgba(255,255,255,0.2);border-radius:4px;color:#e0e0e0;padding:4px 6px;font-size:11px;width:100%;">
        <option value="0" ${(eff.formula?.type ?? 0) === 0 ? 'selected' : ''}>Sine Wave</option>
        <option value="1" ${(eff.formula?.type ?? 0) === 1 ? 'selected' : ''}>Cosine Wave</option>
        <option value="2" ${(eff.formula?.type ?? 0) === 2 ? 'selected' : ''}>Radial Wave</option>
        <option value="3" ${(eff.formula?.type ?? 0) === 3 ? 'selected' : ''}>Spiral</option>
      </select>
    `);
    html += createParamRow('Axis', `
      <select id="eff_formula_axis" style="background:#1a1d24;border:1px solid rgba(255,255,255,0.2);border-radius:4px;color:#e0e0e0;padding:4px 6px;font-size:11px;width:100%;">
        <option value="0" ${(eff.formula?.axis ?? 1) === 0 ? 'selected' : ''}>X</option>
        <option value="1" ${(eff.formula?.axis ?? 1) === 1 ? 'selected' : ''}>Y</option>
        <option value="2" ${(eff.formula?.axis ?? 1) === 2 ? 'selected' : ''}>Z</option>
      </select>
    `);
    html += createParamRow('Frequency', createSlider('eff_formula_freq', uniforms?.uFormulaFrequency?.value ?? eff.formula?.frequency ?? 2, 0.1, 10, 0.1), 'eff_formula_freq');
    html += createParamRow('Amplitude', createSlider('eff_formula_amp', uniforms?.uFormulaAmplitude?.value ?? eff.formula?.amplitude ?? 1, 0, 3, 0.1), 'eff_formula_amp');
    html += createParamRow('Speed', createSlider('eff_formula_speed', uniforms?.uFormulaSpeed?.value ?? eff.formula?.speed ?? 1, 0, 5, 0.1), 'eff_formula_speed');
    html += createParamRow('Scale Effect', createSlider('eff_formula_scale_eff', uniforms?.uFormulaScaleEffect?.value ?? eff.formula?.scaleEffect ?? 0.3, -1, 1, 0.05), 'eff_formula_scale_eff');
    html += createParamRow('Position Effect', createSlider('eff_formula_pos_eff', uniforms?.uFormulaPosEffect?.value ?? eff.formula?.positionEffect ?? 0.5, -2, 2, 0.1), 'eff_formula_pos_eff');
    html += `</div>`;
    
    // Random Effector
    html += `<div class="param-group"><div class="param-group-title">🎲 Random Effector</div>`;
    html += createParamRow('Enabled', `<input type="checkbox" id="eff_random_enabled" ${eff.random?.enabled ? 'checked' : ''} style="width:16px;height:16px;">`, null);
    html += createParamRow('Seed', createSlider('eff_random_seed', uniforms?.uRandomSeed?.value ?? eff.random?.seed ?? 42, 0, 1000, 1), 'eff_random_seed');
    html += createParamRow('Position Str', createSlider('eff_random_pos', uniforms?.uRandomPosStrength?.value ?? eff.random?.positionStrength ?? 0, 0, 3, 0.05), 'eff_random_pos');
    html += createParamRow('Scale Str', createSlider('eff_random_scale', uniforms?.uRandomScaleStrength?.value ?? eff.random?.scaleStrength ?? 0, 0, 2, 0.05), 'eff_random_scale');
    html += createParamRow('Uniform Scale', `<input type="checkbox" id="eff_random_uniform" ${eff.random?.scaleUniform ? 'checked' : ''} style="width:16px;height:16px;">`, null);
    html += createParamRow('Rotation Str', createSlider('eff_random_rot', uniforms?.uRandomRotStrength?.value ?? eff.random?.rotationStrength ?? 0, 0, 3, 0.05), 'eff_random_rot');
    html += `</div>`;
    
    paramContent.innerHTML = html;
    
    // Link all sliders
    ['cloner_clone_radius', 'cloner_size_var', 'cloner_detail',
     'cloner_base_radius', 'cloner_offset_x', 'cloner_offset_y', 'cloner_offset_z',
     'cloner_rotation_x', 'cloner_rotation_y', 'cloner_rotation_z',
     'cloner_offset_amt', 'cloner_offset_spd', 'cloner_rot_spd',
     'cloner_orbit_spd', 'cloner_pulse_amt', 'cloner_pulse_spd', 'cloner_breath_amt', 
     'cloner_breath_spd', 'cloner_color_mix_spd', 'cloner_em_pulse_spd',
     'cloner_em_int', 'cloner_metalness', 'cloner_roughness', 
     'cloner_env_int', 'cloner_fresnel',
     'cloner_light_int', 'cloner_ambient_int',
     // Distribution sliders
     'cloner_torus_inner', 'cloner_cylinder_height', 'cloner_helix_turns',
     'cloner_plane_width', 'cloner_plane_depth', 'cloner_line_length', 'cloner_spiral_turns',
     // Effector sliders
     'eff_plain_pos_x', 'eff_plain_pos_y', 'eff_plain_pos_z', 'eff_plain_scale',
     'eff_plain_rot_x', 'eff_plain_rot_y', 'eff_plain_rot_z',
     'eff_noise_strength', 'eff_noise_scale', 'eff_noise_speed', 'eff_noise_octaves',
     'eff_noise_scale_eff', 'eff_noise_rot_eff',
     'eff_delay_amount', 'eff_delay_offset',
     'eff_field_pos_x', 'eff_field_pos_y', 'eff_field_pos_z', 'eff_field_radius',
     'eff_field_falloff', 'eff_field_strength', 'eff_field_scale_eff', 'eff_field_push_eff',
     'eff_formula_freq', 'eff_formula_amp', 'eff_formula_speed', 'eff_formula_scale_eff', 'eff_formula_pos_eff',
     'eff_random_seed', 'eff_random_pos', 'eff_random_scale', 'eff_random_rot'
    ].forEach(linkSliderToNumber);
    
    // Setup XYZ slider for light direction
    setupXYZSlider('cloner_light_dir');
    
    // Clone type change handler - show/hide custom browse container
    const cloneTypeSelect = document.getElementById('cloner_type');
    if (cloneTypeSelect) {
      cloneTypeSelect.addEventListener('change', () => {
        const ct = cloneTypeSelect.value;
        const customContainer = document.getElementById('cloner_custom_type_container');
        if (customContainer) {
          customContainer.style.display = ct === 'custom' ? 'block' : 'none';
        }
      });
    }
    
    // Clone type custom browse button
    const cloneTypeBrowseBtn = document.getElementById('cloner_custom_type_btn');
    if (cloneTypeBrowseBtn) {
      cloneTypeBrowseBtn.addEventListener('click', () => {
        openAssetBrowser('3d', (asset) => {
          if (!asset) return;
          const sceneObj = getSceneByIdx(sceneIdx);
          if (!sceneObj?.data?.sphereCloner) return;
          
          saveUndoStateForAction();
          sceneObj.data.sphereCloner.customCloneModel = asset.path;
          const nameSpan = document.getElementById('cloner_custom_type_name');
          if (nameSpan) {
            nameSpan.textContent = asset.name || asset.path.split('/').pop();
            nameSpan.style.color = 'var(--text)';
          }
          console.log('[Cloner] Custom clone model set:', asset.path);
        });
      });
    }
    
    // Distribution type change handler - show/hide relevant parameters
    const distTypeSelect = document.getElementById('cloner_dist_type');
    if (distTypeSelect) {
      distTypeSelect.addEventListener('change', () => {
        const dt = distTypeSelect.value;
        document.getElementById('cloner_torus_params').style.display = dt === 'torus' ? 'block' : 'none';
        document.getElementById('cloner_cylinder_params').style.display = (dt === 'cylinder' || dt === 'helix') ? 'block' : 'none';
        document.getElementById('cloner_helix_params').style.display = dt === 'helix' ? 'block' : 'none';
        document.getElementById('cloner_plane_params').style.display = dt === 'plane' ? 'block' : 'none';
        document.getElementById('cloner_line_params').style.display = dt === 'line' ? 'block' : 'none';
        document.getElementById('cloner_spiral_params').style.display = dt === 'spiral' ? 'block' : 'none';
        // Show/hide custom distribution container
        const customDistContainer = document.getElementById('cloner_custom_dist_container');
        if (customDistContainer) {
          customDistContainer.style.display = dt === 'custom' ? 'block' : 'none';
        }
      });
    }
    
    // Distribution custom browse button
    const distBrowseBtn = document.getElementById('cloner_custom_dist_btn');
    if (distBrowseBtn) {
      distBrowseBtn.addEventListener('click', () => {
        openAssetBrowser('3d', (asset) => {
          if (!asset) return;
          const sceneObj = getSceneByIdx(sceneIdx);
          if (!sceneObj?.data?.sphereCloner) return;
          
          saveUndoStateForAction();
          sceneObj.data.sphereCloner.customDistModel = asset.path;
          const nameSpan = document.getElementById('cloner_custom_dist_name');
          if (nameSpan) {
            nameSpan.textContent = asset.name || asset.path.split('/').pop();
            nameSpan.style.color = 'var(--text)';
          }
          console.log('[Cloner] Custom distribution model set:', asset.path);
        });
      });
    }
    
    addSphereClonersParamHandlers(sceneIdx);
  }
  
  function addSphereClonersParamHandlers(sceneIdx) {
    // NOTE: Don't return early if mesh doesn't exist yet - handlers will check inside handleChange
    // The mesh might not be created until after the scene fully loads
    
    const handleChange = () => {
      // Get scene object fresh each time (important for segment-specific scenes)
      const sceneObj = getSceneByIdx(sceneIdx);
      if (!sceneObj?.sphereClonerMesh) return;
      
      const uniforms = sceneObj.sphereClonerMesh.material?.uniforms || {};
      
      // Layout
      const baseRadius = parseFloat(document.getElementById('cloner_base_radius')?.value);
      if (!isNaN(baseRadius) && uniforms.uBaseRadius) uniforms.uBaseRadius.value = baseRadius;

      // Whole-system placement (distinct from the procedural Offset Amount).
      const positionX = parseFloat(document.getElementById('cloner_offset_x')?.value);
      const positionY = parseFloat(document.getElementById('cloner_offset_y')?.value);
      const positionZ = parseFloat(document.getElementById('cloner_offset_z')?.value);
      const rotationX = parseFloat(document.getElementById('cloner_rotation_x')?.value);
      const rotationY = parseFloat(document.getElementById('cloner_rotation_y')?.value);
      const rotationZ = parseFloat(document.getElementById('cloner_rotation_z')?.value);
      if (!isNaN(positionX) && !isNaN(positionY) && !isNaN(positionZ)) {
        sceneObj.data.sphereCloner.offsetX = positionX;
        sceneObj.data.sphereCloner.offsetY = positionY;
        sceneObj.data.sphereCloner.offsetZ = positionZ;
      }
      if (!isNaN(rotationX) && !isNaN(rotationY) && !isNaN(rotationZ)) {
        sceneObj.data.sphereCloner.rotationX = rotationX;
        sceneObj.data.sphereCloner.rotationY = rotationY;
        sceneObj.data.sphereCloner.rotationZ = rotationZ;
      }
      applySphereClonerPlacement(sceneObj.data.sphereCloner, sceneObj.sphereClonerMesh);
      
      // Animation
      const offsetAmt = parseFloat(document.getElementById('cloner_offset_amt')?.value);
      const offsetSpd = parseFloat(document.getElementById('cloner_offset_spd')?.value);
      const rotSpd = parseFloat(document.getElementById('cloner_rot_spd')?.value);
      const orbitSpd = parseFloat(document.getElementById('cloner_orbit_spd')?.value);
      const pulseAmt = parseFloat(document.getElementById('cloner_pulse_amt')?.value);
      const pulseSpd = parseFloat(document.getElementById('cloner_pulse_spd')?.value);
      const breathAmt = parseFloat(document.getElementById('cloner_breath_amt')?.value);
      const breathSpd = parseFloat(document.getElementById('cloner_breath_spd')?.value);
      
      if (!isNaN(offsetAmt) && uniforms.uOffsetAmount) uniforms.uOffsetAmount.value = offsetAmt;
      if (!isNaN(offsetSpd) && uniforms.uOffsetSpeed) uniforms.uOffsetSpeed.value = offsetSpd;
      if (!isNaN(rotSpd) && uniforms.uRotationSpeed) uniforms.uRotationSpeed.value = rotSpd;
      if (!isNaN(orbitSpd) && uniforms.uOrbitSpeed) uniforms.uOrbitSpeed.value = orbitSpd;
      if (!isNaN(pulseAmt) && uniforms.uPulseAmount) uniforms.uPulseAmount.value = pulseAmt;
      if (!isNaN(pulseSpd) && uniforms.uPulseSpeed) uniforms.uPulseSpeed.value = pulseSpd;
      if (!isNaN(breathAmt) && uniforms.uBreathAmount) uniforms.uBreathAmount.value = breathAmt;
      if (!isNaN(breathSpd) && uniforms.uBreathSpeed) uniforms.uBreathSpeed.value = breathSpd;
      
      // Color mix and emissive pulse speeds
      const colorMixSpd = parseFloat(document.getElementById('cloner_color_mix_spd')?.value);
      const emPulseSpd = parseFloat(document.getElementById('cloner_em_pulse_spd')?.value);
      if (!isNaN(colorMixSpd) && uniforms.uColorMixSpeed) uniforms.uColorMixSpeed.value = colorMixSpd;
      if (!isNaN(emPulseSpd) && uniforms.uEmissivePulseSpeed) uniforms.uEmissivePulseSpeed.value = emPulseSpd;
      
      // Colors
      const baseCol = document.getElementById('cloner_color_base')?.value;
      const accentCol = document.getElementById('cloner_color_accent')?.value;
      const emissiveCol = document.getElementById('cloner_color_emissive')?.value;
      const emInt = parseFloat(document.getElementById('cloner_em_int')?.value);
      
      if (baseCol && uniforms.uBaseColor) uniforms.uBaseColor.value.set(baseCol);
      if (accentCol && uniforms.uAccentColor) uniforms.uAccentColor.value.set(accentCol);
      if (emissiveCol && uniforms.uEmissiveColor) uniforms.uEmissiveColor.value.set(emissiveCol);
      if (!isNaN(emInt) && uniforms.uEmissiveIntensity) uniforms.uEmissiveIntensity.value = emInt;
      
      // Material
      const metalness = parseFloat(document.getElementById('cloner_metalness')?.value);
      const roughness = parseFloat(document.getElementById('cloner_roughness')?.value);
      const envInt = parseFloat(document.getElementById('cloner_env_int')?.value);
      const fresnel = parseFloat(document.getElementById('cloner_fresnel')?.value);
      
      if (!isNaN(metalness) && uniforms.uMetalness) uniforms.uMetalness.value = metalness;
      if (!isNaN(roughness) && uniforms.uRoughness) uniforms.uRoughness.value = roughness;
      if (!isNaN(envInt) && uniforms.uEnvMapIntensity) uniforms.uEnvMapIntensity.value = envInt;
      if (!isNaN(fresnel) && uniforms.uFresnelPower) uniforms.uFresnelPower.value = fresnel;
      
      // Main light
      const lightColor = document.getElementById('cloner_light_color')?.value;
      const lightInt = parseFloat(document.getElementById('cloner_light_int')?.value);
      const lightDirX = parseFloat(document.getElementById('cloner_light_dir_x')?.value);
      const lightDirY = parseFloat(document.getElementById('cloner_light_dir_y')?.value);
      const lightDirZ = parseFloat(document.getElementById('cloner_light_dir_z')?.value);
      
      if (lightColor && uniforms.uLightColor) uniforms.uLightColor.value.set(lightColor);
      if (!isNaN(lightInt) && uniforms.uLightIntensity) uniforms.uLightIntensity.value = lightInt;
      if (!isNaN(lightDirX) && !isNaN(lightDirY) && !isNaN(lightDirZ) && uniforms.uLightDir) {
        uniforms.uLightDir.value.set(lightDirX, lightDirY, lightDirZ).normalize();
      }
      
      // Ambient light
      const ambientColor = document.getElementById('cloner_ambient_color')?.value;
      const ambientInt = parseFloat(document.getElementById('cloner_ambient_int')?.value);
      
      if (ambientColor && uniforms.uAmbientColor) uniforms.uAmbientColor.value.set(ambientColor);
      if (!isNaN(ambientInt) && uniforms.uAmbientIntensity) uniforms.uAmbientIntensity.value = ambientInt;
      
      // === EFFECTOR HANDLERS ===
      
      // Plain Effector
      const plainEnabledEl = document.getElementById('eff_plain_enabled');
      const plainPosX = parseFloat(document.getElementById('eff_plain_pos_x')?.value);
      const plainPosY = parseFloat(document.getElementById('eff_plain_pos_y')?.value);
      const plainPosZ = parseFloat(document.getElementById('eff_plain_pos_z')?.value);
      const plainScale = parseFloat(document.getElementById('eff_plain_scale')?.value);
      const plainRotX = parseFloat(document.getElementById('eff_plain_rot_x')?.value);
      const plainRotY = parseFloat(document.getElementById('eff_plain_rot_y')?.value);
      const plainRotZ = parseFloat(document.getElementById('eff_plain_rot_z')?.value);
      
      // Only update enabled state if the checkbox element exists (prevents disabling when element not in DOM)
      if (plainEnabledEl && uniforms.uPlainEnabled) uniforms.uPlainEnabled.value = plainEnabledEl.checked ? 1.0 : 0.0;
      if (!isNaN(plainPosX) && !isNaN(plainPosY) && !isNaN(plainPosZ) && uniforms.uPlainPosition) {
        uniforms.uPlainPosition.value.set(plainPosX, plainPosY, plainPosZ);
      }
      if (!isNaN(plainScale) && uniforms.uPlainScale) uniforms.uPlainScale.value = plainScale;
      if (!isNaN(plainRotX) && !isNaN(plainRotY) && !isNaN(plainRotZ) && uniforms.uPlainRotation) {
        uniforms.uPlainRotation.value.set(plainRotX, plainRotY, plainRotZ);
      }
      
      // Noise Effector
      const noiseEnabledEl = document.getElementById('eff_noise_enabled');
      const noiseStrength = parseFloat(document.getElementById('eff_noise_strength')?.value);
      const noiseScale = parseFloat(document.getElementById('eff_noise_scale')?.value);
      const noiseSpeed = parseFloat(document.getElementById('eff_noise_speed')?.value);
      const noiseOctaves = parseFloat(document.getElementById('eff_noise_octaves')?.value);
      const noiseScaleEff = parseFloat(document.getElementById('eff_noise_scale_eff')?.value);
      const noiseRotEff = parseFloat(document.getElementById('eff_noise_rot_eff')?.value);
      
      if (noiseEnabledEl && uniforms.uNoiseEnabled) uniforms.uNoiseEnabled.value = noiseEnabledEl.checked ? 1.0 : 0.0;
      if (!isNaN(noiseStrength) && uniforms.uNoiseStrength) uniforms.uNoiseStrength.value = noiseStrength;
      if (!isNaN(noiseScale) && uniforms.uNoiseScale) uniforms.uNoiseScale.value = noiseScale;
      if (!isNaN(noiseSpeed) && uniforms.uNoiseSpeed) uniforms.uNoiseSpeed.value = noiseSpeed;
      if (!isNaN(noiseOctaves) && uniforms.uNoiseOctaves) uniforms.uNoiseOctaves.value = noiseOctaves;
      if (!isNaN(noiseScaleEff) && uniforms.uNoiseScaleEffect) uniforms.uNoiseScaleEffect.value = noiseScaleEff;
      if (!isNaN(noiseRotEff) && uniforms.uNoiseRotEffect) uniforms.uNoiseRotEffect.value = noiseRotEff;
      
      // Delay Effector
      const delayEnabledEl = document.getElementById('eff_delay_enabled');
      const delayAmount = parseFloat(document.getElementById('eff_delay_amount')?.value);
      const delayMode = parseInt(document.getElementById('eff_delay_mode')?.value);
      const delayOffset = parseFloat(document.getElementById('eff_delay_offset')?.value);
      
      if (delayEnabledEl && uniforms.uDelayEnabled) uniforms.uDelayEnabled.value = delayEnabledEl.checked ? 1.0 : 0.0;
      if (!isNaN(delayAmount) && uniforms.uDelayAmount) uniforms.uDelayAmount.value = delayAmount;
      if (!isNaN(delayMode) && uniforms.uDelayMode) uniforms.uDelayMode.value = delayMode;
      if (!isNaN(delayOffset) && uniforms.uDelayOffset) uniforms.uDelayOffset.value = delayOffset;
      
      // Spherical Field Effector
      const fieldEnabledEl = document.getElementById('eff_field_enabled');
      const fieldPosX = parseFloat(document.getElementById('eff_field_pos_x')?.value);
      const fieldPosY = parseFloat(document.getElementById('eff_field_pos_y')?.value);
      const fieldPosZ = parseFloat(document.getElementById('eff_field_pos_z')?.value);
      const fieldRadius = parseFloat(document.getElementById('eff_field_radius')?.value);
      const fieldFalloff = parseFloat(document.getElementById('eff_field_falloff')?.value);
      const fieldStrength = parseFloat(document.getElementById('eff_field_strength')?.value);
      const fieldInvertEl = document.getElementById('eff_field_invert');
      const fieldScaleEff = parseFloat(document.getElementById('eff_field_scale_eff')?.value);
      const fieldPushEff = parseFloat(document.getElementById('eff_field_push_eff')?.value);
      
      if (fieldEnabledEl && uniforms.uFieldEnabled) uniforms.uFieldEnabled.value = fieldEnabledEl.checked ? 1.0 : 0.0;
      if (!isNaN(fieldPosX) && !isNaN(fieldPosY) && !isNaN(fieldPosZ) && uniforms.uFieldPosition) {
        uniforms.uFieldPosition.value.set(fieldPosX, fieldPosY, fieldPosZ);
      }
      if (!isNaN(fieldRadius) && uniforms.uFieldRadius) uniforms.uFieldRadius.value = fieldRadius;
      if (!isNaN(fieldFalloff) && uniforms.uFieldFalloff) uniforms.uFieldFalloff.value = fieldFalloff;
      if (!isNaN(fieldStrength) && uniforms.uFieldStrength) uniforms.uFieldStrength.value = fieldStrength;
      if (fieldInvertEl && uniforms.uFieldInvert) uniforms.uFieldInvert.value = fieldInvertEl.checked ? 1.0 : 0.0;
      if (!isNaN(fieldScaleEff) && uniforms.uFieldScaleEffect) uniforms.uFieldScaleEffect.value = fieldScaleEff;
      if (!isNaN(fieldPushEff) && uniforms.uFieldPushEffect) uniforms.uFieldPushEffect.value = fieldPushEff;
      
      // Formula Effector
      const formulaEnabledEl = document.getElementById('eff_formula_enabled');
      const formulaType = parseInt(document.getElementById('eff_formula_type')?.value);
      const formulaAxis = parseInt(document.getElementById('eff_formula_axis')?.value);
      const formulaFreq = parseFloat(document.getElementById('eff_formula_freq')?.value);
      const formulaAmp = parseFloat(document.getElementById('eff_formula_amp')?.value);
      const formulaSpeed = parseFloat(document.getElementById('eff_formula_speed')?.value);
      const formulaScaleEff = parseFloat(document.getElementById('eff_formula_scale_eff')?.value);
      const formulaPosEff = parseFloat(document.getElementById('eff_formula_pos_eff')?.value);
      
      if (formulaEnabledEl && uniforms.uFormulaEnabled) uniforms.uFormulaEnabled.value = formulaEnabledEl.checked ? 1.0 : 0.0;
      if (!isNaN(formulaType) && uniforms.uFormulaType) uniforms.uFormulaType.value = formulaType;
      if (!isNaN(formulaAxis) && uniforms.uFormulaAxis) uniforms.uFormulaAxis.value = formulaAxis;
      if (!isNaN(formulaFreq) && uniforms.uFormulaFrequency) uniforms.uFormulaFrequency.value = formulaFreq;
      if (!isNaN(formulaAmp) && uniforms.uFormulaAmplitude) uniforms.uFormulaAmplitude.value = formulaAmp;
      if (!isNaN(formulaSpeed) && uniforms.uFormulaSpeed) uniforms.uFormulaSpeed.value = formulaSpeed;
      if (!isNaN(formulaScaleEff) && uniforms.uFormulaScaleEffect) uniforms.uFormulaScaleEffect.value = formulaScaleEff;
      if (!isNaN(formulaPosEff) && uniforms.uFormulaPosEffect) uniforms.uFormulaPosEffect.value = formulaPosEff;
      
      // Random Effector
      const randomEnabledEl = document.getElementById('eff_random_enabled');
      const randomSeed = parseFloat(document.getElementById('eff_random_seed')?.value);
      const randomPos = parseFloat(document.getElementById('eff_random_pos')?.value);
      const randomScale = parseFloat(document.getElementById('eff_random_scale')?.value);
      const randomUniformEl = document.getElementById('eff_random_uniform');
      const randomRot = parseFloat(document.getElementById('eff_random_rot')?.value);
      
      if (randomEnabledEl && uniforms.uRandomEnabled) uniforms.uRandomEnabled.value = randomEnabledEl.checked ? 1.0 : 0.0;
      if (!isNaN(randomSeed) && uniforms.uRandomSeed) uniforms.uRandomSeed.value = randomSeed;
      if (!isNaN(randomPos) && uniforms.uRandomPosStrength) uniforms.uRandomPosStrength.value = randomPos;
      if (!isNaN(randomScale) && uniforms.uRandomScaleStrength) uniforms.uRandomScaleStrength.value = randomScale;
      if (randomUniformEl && uniforms.uRandomScaleUniform) uniforms.uRandomScaleUniform.value = randomUniformEl.checked ? 1.0 : 0.0;
      if (!isNaN(randomRot) && uniforms.uRandomRotStrength) uniforms.uRandomRotStrength.value = randomRot;
      
      // Auto-save to localStorage
      scheduleAutoSave(sceneIdx);
    };
    
    const inputs = [
      'cloner_base_radius', 'cloner_base_radius_num',
      'cloner_offset_x', 'cloner_offset_x_num',
      'cloner_offset_y', 'cloner_offset_y_num',
      'cloner_offset_z', 'cloner_offset_z_num',
      'cloner_rotation_x', 'cloner_rotation_x_num',
      'cloner_rotation_y', 'cloner_rotation_y_num',
      'cloner_rotation_z', 'cloner_rotation_z_num',
      'cloner_offset_amt', 'cloner_offset_amt_num',
      'cloner_offset_spd', 'cloner_offset_spd_num',
      'cloner_rot_spd', 'cloner_rot_spd_num',
      'cloner_orbit_spd', 'cloner_orbit_spd_num',
      'cloner_pulse_amt', 'cloner_pulse_amt_num',
      'cloner_pulse_spd', 'cloner_pulse_spd_num',
      'cloner_breath_amt', 'cloner_breath_amt_num',
      'cloner_breath_spd', 'cloner_breath_spd_num',
      'cloner_color_mix_spd', 'cloner_color_mix_spd_num',
      'cloner_em_pulse_spd', 'cloner_em_pulse_spd_num',
      'cloner_color_base', 'cloner_color_accent', 'cloner_color_emissive',
      'cloner_em_int', 'cloner_em_int_num',
      'cloner_metalness', 'cloner_metalness_num',
      'cloner_roughness', 'cloner_roughness_num',
      'cloner_env_int', 'cloner_env_int_num',
      'cloner_fresnel', 'cloner_fresnel_num',
      // Light parameters
      'cloner_light_color', 'cloner_light_int', 'cloner_light_int_num',
      'cloner_light_dir_x', 'cloner_light_dir_y', 'cloner_light_dir_z',
      'cloner_light_dir_x_num', 'cloner_light_dir_y_num', 'cloner_light_dir_z_num',
      'cloner_ambient_color', 'cloner_ambient_int', 'cloner_ambient_int_num',
      // === EFFECTOR INPUTS ===
      // Plain Effector
      'eff_plain_enabled',
      'eff_plain_pos_x', 'eff_plain_pos_x_num', 'eff_plain_pos_y', 'eff_plain_pos_y_num', 'eff_plain_pos_z', 'eff_plain_pos_z_num',
      'eff_plain_scale', 'eff_plain_scale_num',
      'eff_plain_rot_x', 'eff_plain_rot_x_num', 'eff_plain_rot_y', 'eff_plain_rot_y_num', 'eff_plain_rot_z', 'eff_plain_rot_z_num',
      // Noise Effector
      'eff_noise_enabled',
      'eff_noise_strength', 'eff_noise_strength_num', 'eff_noise_scale', 'eff_noise_scale_num',
      'eff_noise_speed', 'eff_noise_speed_num', 'eff_noise_octaves', 'eff_noise_octaves_num',
      'eff_noise_scale_eff', 'eff_noise_scale_eff_num', 'eff_noise_rot_eff', 'eff_noise_rot_eff_num',
      // Delay Effector
      'eff_delay_enabled', 'eff_delay_amount', 'eff_delay_amount_num', 'eff_delay_mode', 'eff_delay_offset', 'eff_delay_offset_num',
      // Spherical Field
      'eff_field_enabled',
      'eff_field_pos_x', 'eff_field_pos_x_num', 'eff_field_pos_y', 'eff_field_pos_y_num', 'eff_field_pos_z', 'eff_field_pos_z_num',
      'eff_field_radius', 'eff_field_radius_num', 'eff_field_falloff', 'eff_field_falloff_num',
      'eff_field_strength', 'eff_field_strength_num', 'eff_field_invert',
      'eff_field_scale_eff', 'eff_field_scale_eff_num', 'eff_field_push_eff', 'eff_field_push_eff_num',
      // Formula Effector
      'eff_formula_enabled', 'eff_formula_type', 'eff_formula_axis',
      'eff_formula_freq', 'eff_formula_freq_num', 'eff_formula_amp', 'eff_formula_amp_num',
      'eff_formula_speed', 'eff_formula_speed_num',
      'eff_formula_scale_eff', 'eff_formula_scale_eff_num', 'eff_formula_pos_eff', 'eff_formula_pos_eff_num',
      // Random Effector
      'eff_random_enabled', 'eff_random_seed', 'eff_random_seed_num',
      'eff_random_pos', 'eff_random_pos_num', 'eff_random_scale', 'eff_random_scale_num',
      'eff_random_uniform', 'eff_random_rot', 'eff_random_rot_num'
    ];
    
    inputs.forEach(id => {
      const el = document.getElementById(id);
      if (el) {
        el.addEventListener('focus', beginParamEdit);
        el.addEventListener('mousedown', beginParamEdit);
        el.addEventListener('input', handleChange);
        
        // Color pickers need special handling - their native dialog steals focus,
        // causing blur to fire prematurely. Only use change event to end editing for colors.
        if (el.type === 'color') {
          el.addEventListener('change', endParamEdit);
        } else {
        el.addEventListener('blur', endParamEdit);
        el.addEventListener('mouseup', () => setTimeout(endParamEdit, 100));
        }
      }
    });
    
    // Rebuild button handler
    const rebuildBtn = document.getElementById('cloner_rebuild');
    if (rebuildBtn) {
      rebuildBtn.addEventListener('click', () => {
        // Get scene object fresh
        const sceneObj = getSceneByIdx(sceneIdx);
        if (!sceneObj) return;
        
        const cloner = sceneObj.data.sphereCloner;
        if (!cloner) return;

        saveUndoStateForAction();
        
        // Get new geometry values from UI
        const newCount = parseInt(document.getElementById('cloner_count')?.value) || 1000;
        const newCloneRadius = parseFloat(document.getElementById('cloner_clone_radius')?.value) || 0.18;
        const newSizeVar = parseFloat(document.getElementById('cloner_size_var')?.value) || 0.6;
        const newType = document.getElementById('cloner_type')?.value || 'icosahedron';
        const newDetail = parseInt(document.getElementById('cloner_detail')?.value) || 0;
        
        // Get distribution parameters
        const newDistType = document.getElementById('cloner_dist_type')?.value || 'sphere';
        const newTorusInner = parseFloat(document.getElementById('cloner_torus_inner')?.value) || 2;
        const newCylinderHeight = parseFloat(document.getElementById('cloner_cylinder_height')?.value) || 10;
        const newHelixTurns = parseFloat(document.getElementById('cloner_helix_turns')?.value) || 3;
        const newPlaneWidth = parseFloat(document.getElementById('cloner_plane_width')?.value) || 12;
        const newPlaneDepth = parseFloat(document.getElementById('cloner_plane_depth')?.value) || 12;
        const newLineLength = parseFloat(document.getElementById('cloner_line_length')?.value) || 10;
        const newLineAxis = document.getElementById('cloner_line_axis')?.value || 'y';
        const newSpiralTurns = parseFloat(document.getElementById('cloner_spiral_turns')?.value) || 5;
        
        // Update config
        cloner.count = newCount;
        cloner.cloneRadius = newCloneRadius;
        cloner.sizeVariation = newSizeVar;
        cloner.cloneType = newType;
        cloner.cloneDetail = newDetail;
        
        // Update distribution config
        cloner.distributionType = newDistType;
        cloner.torusInnerRadius = newTorusInner;
        cloner.cylinderHeight = newCylinderHeight;
        cloner.helixTurns = newHelixTurns;
        cloner.planeWidth = newPlaneWidth;
        cloner.planeDepth = newPlaneDepth;
        cloner.lineLength = newLineLength;
        cloner.lineAxis = newLineAxis;
        cloner.spiralTurns = newSpiralTurns;
        
        // Custom models are already set via browse buttons, just preserve them
        // (customCloneModel and customDistModel are set by the browse button handlers)
        
        // Preserve ALL current uniform values before rebuild
        const oldUniforms = sceneObj.sphereClonerMesh?.material?.uniforms;
        if (sceneObj.sphereClonerMesh) {
          cloner.offsetX = sceneObj.sphereClonerMesh.position.x;
          cloner.offsetY = sceneObj.sphereClonerMesh.position.y;
          cloner.offsetZ = sceneObj.sphereClonerMesh.position.z;
          cloner.rotationX = sceneObj.sphereClonerMesh.rotation.x * 180 / Math.PI;
          cloner.rotationY = sceneObj.sphereClonerMesh.rotation.y * 180 / Math.PI;
          cloner.rotationZ = sceneObj.sphereClonerMesh.rotation.z * 180 / Math.PI;
        }
        if (oldUniforms) {
          // Animation parameters
          cloner.baseRadius = oldUniforms.uBaseRadius?.value ?? cloner.baseRadius;
          cloner.offsetAmount = oldUniforms.uOffsetAmount?.value ?? cloner.offsetAmount;
          cloner.offsetSpeed = oldUniforms.uOffsetSpeed?.value ?? cloner.offsetSpeed;
          cloner.rotationSpeed = oldUniforms.uRotationSpeed?.value ?? cloner.rotationSpeed;
          cloner.orbitSpeed = oldUniforms.uOrbitSpeed?.value ?? cloner.orbitSpeed;
          cloner.pulseAmount = oldUniforms.uPulseAmount?.value ?? cloner.pulseAmount;
          cloner.pulseSpeed = oldUniforms.uPulseSpeed?.value ?? cloner.pulseSpeed;
          cloner.breathAmount = oldUniforms.uBreathAmount?.value ?? cloner.breathAmount;
          cloner.breathSpeed = oldUniforms.uBreathSpeed?.value ?? cloner.breathSpeed;
          cloner.colorMixSpeed = oldUniforms.uColorMixSpeed?.value ?? cloner.colorMixSpeed;
          cloner.emissivePulseSpeed = oldUniforms.uEmissivePulseSpeed?.value ?? cloner.emissivePulseSpeed;
          
          // Colors
          cloner.colors = cloner.colors || {};
          if (oldUniforms.uBaseColor?.value) cloner.colors.base = '#' + oldUniforms.uBaseColor.value.getHexString();
          if (oldUniforms.uAccentColor?.value) cloner.colors.accent = '#' + oldUniforms.uAccentColor.value.getHexString();
          if (oldUniforms.uEmissiveColor?.value) cloner.colors.emissive = '#' + oldUniforms.uEmissiveColor.value.getHexString();
          cloner.colors.emissiveIntensity = oldUniforms.uEmissiveIntensity?.value ?? cloner.colors?.emissiveIntensity;
          
          // Material
          cloner.material = cloner.material || {};
          cloner.material.metalness = oldUniforms.uMetalness?.value ?? cloner.material?.metalness;
          cloner.material.roughness = oldUniforms.uRoughness?.value ?? cloner.material?.roughness;
          cloner.material.envMapIntensity = oldUniforms.uEnvMapIntensity?.value ?? cloner.material?.envMapIntensity;
          cloner.material.fresnelPower = oldUniforms.uFresnelPower?.value ?? cloner.material?.fresnelPower;
          
          // Light settings
          cloner.light = cloner.light || {};
          if (oldUniforms.uLightDir?.value) {
            cloner.light.direction = { 
              x: oldUniforms.uLightDir.value.x, 
              y: oldUniforms.uLightDir.value.y, 
              z: oldUniforms.uLightDir.value.z 
            };
          }
          if (oldUniforms.uLightColor?.value) cloner.light.color = '#' + oldUniforms.uLightColor.value.getHexString();
          cloner.light.intensity = oldUniforms.uLightIntensity?.value ?? cloner.light?.intensity;
          if (oldUniforms.uAmbientColor?.value) cloner.light.ambientColor = '#' + oldUniforms.uAmbientColor.value.getHexString();
          cloner.light.ambientIntensity = oldUniforms.uAmbientIntensity?.value ?? cloner.light?.ambientIntensity;
          
          // Effectors - preserve enabled state and all values
          cloner.effectors = cloner.effectors || {};
          
          // Plain Effector
          cloner.effectors.plain = cloner.effectors.plain || {};
          cloner.effectors.plain.enabled = (oldUniforms.uPlainEnabled?.value > 0.5);
          if (oldUniforms.uPlainPosition?.value) {
            cloner.effectors.plain.position = {
              x: oldUniforms.uPlainPosition.value.x,
              y: oldUniforms.uPlainPosition.value.y,
              z: oldUniforms.uPlainPosition.value.z
            };
          }
          cloner.effectors.plain.scale = oldUniforms.uPlainScale?.value ?? cloner.effectors.plain?.scale;
          if (oldUniforms.uPlainRotation?.value) {
            cloner.effectors.plain.rotation = {
              x: oldUniforms.uPlainRotation.value.x,
              y: oldUniforms.uPlainRotation.value.y,
              z: oldUniforms.uPlainRotation.value.z
            };
          }
          
          // Noise Effector
          cloner.effectors.noise = cloner.effectors.noise || {};
          cloner.effectors.noise.enabled = (oldUniforms.uNoiseEnabled?.value > 0.5);
          cloner.effectors.noise.strength = oldUniforms.uNoiseStrength?.value ?? cloner.effectors.noise?.strength;
          cloner.effectors.noise.scale = oldUniforms.uNoiseScale?.value ?? cloner.effectors.noise?.scale;
          cloner.effectors.noise.speed = oldUniforms.uNoiseSpeed?.value ?? cloner.effectors.noise?.speed;
          cloner.effectors.noise.octaves = oldUniforms.uNoiseOctaves?.value ?? cloner.effectors.noise?.octaves;
          cloner.effectors.noise.scaleEffect = oldUniforms.uNoiseScaleEffect?.value ?? cloner.effectors.noise?.scaleEffect;
          cloner.effectors.noise.rotationEffect = oldUniforms.uNoiseRotEffect?.value ?? cloner.effectors.noise?.rotationEffect;
          
          // Delay Effector
          cloner.effectors.delay = cloner.effectors.delay || {};
          cloner.effectors.delay.enabled = (oldUniforms.uDelayEnabled?.value > 0.5);
          cloner.effectors.delay.amount = oldUniforms.uDelayAmount?.value ?? cloner.effectors.delay?.amount;
          cloner.effectors.delay.mode = oldUniforms.uDelayMode?.value ?? cloner.effectors.delay?.mode;
          cloner.effectors.delay.offset = oldUniforms.uDelayOffset?.value ?? cloner.effectors.delay?.offset;
          
          // Spherical Field Effector
          cloner.effectors.field = cloner.effectors.field || {};
          cloner.effectors.field.enabled = (oldUniforms.uFieldEnabled?.value > 0.5);
          if (oldUniforms.uFieldPosition?.value) {
            cloner.effectors.field.position = {
              x: oldUniforms.uFieldPosition.value.x,
              y: oldUniforms.uFieldPosition.value.y,
              z: oldUniforms.uFieldPosition.value.z
            };
          }
          cloner.effectors.field.radius = oldUniforms.uFieldRadius?.value ?? cloner.effectors.field?.radius;
          cloner.effectors.field.falloff = oldUniforms.uFieldFalloff?.value ?? cloner.effectors.field?.falloff;
          cloner.effectors.field.strength = oldUniforms.uFieldStrength?.value ?? cloner.effectors.field?.strength;
          cloner.effectors.field.invert = (oldUniforms.uFieldInvert?.value > 0.5);
          cloner.effectors.field.scaleEffect = oldUniforms.uFieldScaleEffect?.value ?? cloner.effectors.field?.scaleEffect;
          cloner.effectors.field.pushEffect = oldUniforms.uFieldPushEffect?.value ?? cloner.effectors.field?.pushEffect;
          
          // Formula Effector
          cloner.effectors.formula = cloner.effectors.formula || {};
          cloner.effectors.formula.enabled = (oldUniforms.uFormulaEnabled?.value > 0.5);
          cloner.effectors.formula.type = oldUniforms.uFormulaType?.value ?? cloner.effectors.formula?.type;
          cloner.effectors.formula.axis = oldUniforms.uFormulaAxis?.value ?? cloner.effectors.formula?.axis;
          cloner.effectors.formula.frequency = oldUniforms.uFormulaFrequency?.value ?? cloner.effectors.formula?.frequency;
          cloner.effectors.formula.amplitude = oldUniforms.uFormulaAmplitude?.value ?? cloner.effectors.formula?.amplitude;
          cloner.effectors.formula.speed = oldUniforms.uFormulaSpeed?.value ?? cloner.effectors.formula?.speed;
          cloner.effectors.formula.scaleEffect = oldUniforms.uFormulaScaleEffect?.value ?? cloner.effectors.formula?.scaleEffect;
          cloner.effectors.formula.positionEffect = oldUniforms.uFormulaPosEffect?.value ?? cloner.effectors.formula?.positionEffect;
          
          // Random Effector
          cloner.effectors.random = cloner.effectors.random || {};
          cloner.effectors.random.enabled = (oldUniforms.uRandomEnabled?.value > 0.5);
          cloner.effectors.random.seed = oldUniforms.uRandomSeed?.value ?? cloner.effectors.random?.seed;
          cloner.effectors.random.positionStrength = oldUniforms.uRandomPosStrength?.value ?? cloner.effectors.random?.positionStrength;
          cloner.effectors.random.scaleStrength = oldUniforms.uRandomScaleStrength?.value ?? cloner.effectors.random?.scaleStrength;
          cloner.effectors.random.scaleUniform = (oldUniforms.uRandomScaleUniform?.value > 0.5);
          cloner.effectors.random.rotationStrength = oldUniforms.uRandomRotStrength?.value ?? cloner.effectors.random?.rotationStrength;
        }
        
        // Remove old mesh (handle both instanced and animated cloners)
        if (sceneObj.sphereClonerMesh) {
          sceneObj.threeScene.remove(sceneObj.sphereClonerMesh);
          if (sceneObj.sphereClonerMesh.geometry) sceneObj.sphereClonerMesh.geometry.dispose();
          if (sceneObj.sphereClonerMesh.material) sceneObj.sphereClonerMesh.material.dispose();
        }
        // Clean up animated cloner group if it exists (stored on threeScene.userData)
        if (sceneObj.threeScene.userData.animatedClonerGroup) {
          cleanupAnimatedCloner(sceneObj.threeScene.userData.animatedClonerGroup);
          sceneObj.threeScene.remove(sceneObj.threeScene.userData.animatedClonerGroup);
          sceneObj.threeScene.userData.animatedClonerGroup = null;
        }
        
        // Create new mesh
        sceneObj.sphereClonerMesh = createSphereCloner(cloner, sceneObj.threeScene);
        applySphereClonerPlacement(cloner, sceneObj.sphereClonerMesh);
        
        // Update object list to show new count
        if (selectedSegment) {
          const segs = getSegmentsForTrack(selectedSegment.track);
          const seg = segs[selectedSegment.index];
          if (seg) populateObjectList(selectedSegment.track, seg);
        }
        
        // Re-select the sphere cloner and repopulate params
        populateSphereClonersParams(cloner, sceneIdx);
        const item = document.querySelector('.obj-item[data-type="sphereCloner"]');
        if (item) item.classList.add('selected');
        
        scheduleAutoSave(sceneIdx);
        console.log(`Rebuilt sphere cloner with ${newCount} clones`);
      });
    }
  }
  
  // =========================
  // PARTICLE SYSTEM UI
  // =========================
  function populateParticleSystemParams(particles, sceneIdx) {
    currentEditingSceneIdx = sceneIdx; // Track which scene is being edited
    const sceneObj = getSceneByIdx(sceneIdx);
    console.log('[ParticleSystem] populateParams called', { 
      sceneIdx, 
      hasSceneObj: !!sceneObj, 
      hasParticleMesh: !!sceneObj?.particleSystemMesh,
      hasMaterial: !!sceneObj?.particleSystemMesh?.material,
      hasUniforms: !!sceneObj?.particleSystemMesh?.material?.uniforms,
      particlesConfig: particles
    });
    const uniforms = sceneObj?.particleSystemMesh?.material?.uniforms;
    if (!uniforms) {
      // If no mesh but we have config, try to create the particle system now
      if (particles?.enabled && sceneObj?.threeScene && !sceneObj.particleSystemMesh) {
        console.log('[ParticleSystem] Creating particle system on demand...');
        sceneObj.particleSystemMesh = createParticleSystem(particles, sceneObj.threeScene);
        // Try again with the newly created mesh
        const newUniforms = sceneObj.particleSystemMesh?.material?.uniforms;
        if (newUniforms) {
          console.log('[ParticleSystem] Successfully created on demand');
          populateParticleSystemParams(particles, sceneIdx);
          return;
        }
      }
      paramContent.innerHTML = '<div class="panel-empty">Particle System not initialized</div>';
      return;
    }
    
    // Get current values from uniforms
    const count = particles.count ?? 50000;
    const particleSize = uniforms.uParticleSize?.value ?? 0.05;
    const lifetime = uniforms.uLifetime?.value ?? 5.0;
    const emissionRate = uniforms.uEmissionRate?.value ?? 1.0;
    
    // Emitter
    const emitterType = particles.emitter?.type || 'sphere';
    const emitterRadius = particles.emitter?.radius ?? 5.0;
    
    // Physics
    const gravityX = uniforms.uGravity?.value?.x ?? 0;
    const gravityY = uniforms.uGravity?.value?.y ?? 0;
    const gravityZ = uniforms.uGravity?.value?.z ?? 0;
    const drag = uniforms.uDrag?.value ?? 0.01;
    const bounce = uniforms.uBounce?.value ?? 0.5;
    const floorY = uniforms.uFloorY?.value ?? -10;
    const floorEnabled = (uniforms.uFloorEnabled?.value || 0) > 0.5;
    
    // Turbulence
    const turbEnabled = (uniforms.uTurbEnabled?.value || 0) > 0.5;
    const turbStrength = uniforms.uTurbStrength?.value ?? 1.0;
    const turbScale = uniforms.uTurbScale?.value ?? 1.0;
    const turbSpeed = uniforms.uTurbSpeed?.value ?? 0.5;
    const turbOctaves = uniforms.uTurbOctaves?.value ?? 3;
    
    // Vortex
    const vortexEnabled = (uniforms.uVortexEnabled?.value || 0) > 0.5;
    const vortexStrength = uniforms.uVortexStrength?.value ?? 1.0;
    const vortexInward = uniforms.uVortexInward?.value ?? 0;
    
    // Attractor
    const attractorEnabled = (uniforms.uAttractorEnabled?.value || 0) > 0.5;
    const attractorStrength = uniforms.uAttractorStrength?.value ?? 1.0;
    const attractorRadius = uniforms.uAttractorRadius?.value ?? 5.0;
    const attractorFalloff = uniforms.uAttractorFalloff?.value ?? 2.0;
    
    // Curl noise (fluid)
    const curlEnabled = (uniforms.uCurlEnabled?.value || 0) > 0.5;
    const curlStrength = uniforms.uCurlStrength?.value ?? 1.0;
    const curlScale = uniforms.uCurlScale?.value ?? 0.5;
    const curlSpeed = uniforms.uCurlSpeed?.value ?? 0.3;
    const curlEvolution = uniforms.uCurlEvolution?.value ?? 0.1;
    
    // Cohesion/Liquid
    const cohesionEnabled = (uniforms.uCohesionEnabled?.value || 0) > 0.5;
    const cohesionStrength = uniforms.uCohesionStrength?.value ?? 0.5;
    const cohesionRadius = uniforms.uCohesionRadius?.value ?? 1.0;
    const surfaceTension = uniforms.uSurfaceTension?.value ?? 0.3;
    const viscosity = uniforms.uViscosity?.value ?? 0.1;
    
    // Wind
    const windEnabled = (uniforms.uWindEnabled?.value || 0) > 0.5;
    const windStrength = uniforms.uWindStrength?.value ?? 1.0;
    const windGust = uniforms.uWindGust?.value ?? 0.3;
    const windGustFreq = uniforms.uWindGustFreq?.value ?? 0.5;
    
    // Swarm
    const swarmEnabled = (uniforms.uSwarmEnabled?.value || 0) > 0.5;
    const swarmSeparation = uniforms.uSwarmSeparation?.value ?? 1.0;
    const swarmAlignment = uniforms.uSwarmAlignment?.value ?? 0.5;
    const swarmCohesion = uniforms.uSwarmCohesion?.value ?? 0.3;
    
    // Size over life
    const sizeStart = uniforms.uSizeStart?.value ?? 1.0;
    const sizeMid = uniforms.uSizeMid?.value ?? 1.0;
    const sizeEnd = uniforms.uSizeEnd?.value ?? 0.0;
    
    // Opacity over life
    const opacityStart = uniforms.uOpacityStart?.value ?? 0.0;
    const opacityMid = uniforms.uOpacityMid?.value ?? 1.0;
    const opacityEnd = uniforms.uOpacityEnd?.value ?? 0.0;
    
    // Rotation
    const rotationSpeed = uniforms.uRotationSpeed?.value ?? 0;
    
    // Colors
    const colorStart = '#' + (uniforms.uColorStart?.value?.getHexString() || 'ffffff');
    const colorMid = '#' + (uniforms.uColorMid?.value?.getHexString() || 'ffaa00');
    const colorEnd = '#' + (uniforms.uColorEnd?.value?.getHexString() || 'ff0000');
    
    // Rendering
    const glowIntensity = uniforms.uGlowIntensity?.value ?? 0.5;
    const shapeNames = ['orb', 'star', 'streak', 'ring', 'diamond'];
    const particleShape = shapeNames[Math.round(uniforms.uShape?.value ?? 0)] || 'orb';
    const flareAspect = uniforms.uFlareAspect?.value ?? 1.0;
    const flareSharpness = uniforms.uFlareSharpness?.value ?? 2.0;
    const alignToMotion = (uniforms.uAlignToMotion?.value || 0) > 0.5;
    const offsetX = sceneObj.particleSystemMesh.position.x;
    const offsetY = sceneObj.particleSystemMesh.position.y;
    const offsetZ = sceneObj.particleSystemMesh.position.z;
    
    // Build HTML using the standard param helpers
    let html = '';
    
    // === REBUILD REQUIRED SECTION ===
    html += `<div class="param-group rebuild-required-group">
      <div class="param-group-title">
        Rebuild Required
        <span style="color:var(--muted);font-size:9px;margin-left:6px;">Changes need rebuild to apply</span>
      </div>`;
    
    html += createParamRow('Count', `
      <input type="number" id="particle_count" value="${count}" min="1000" max="500000" step="1000" class="rebuild-input">
    `);
    html += createParamRow('Emitter Type', `
      <select id="particle_emitter_type" class="rebuild-select">
        <option value="point" ${emitterType === 'point' ? 'selected' : ''}>Point</option>
        <option value="sphere" ${emitterType === 'sphere' ? 'selected' : ''}>🔵 Sphere (Volume)</option>
        <option value="sphereSurface" ${emitterType === 'sphereSurface' ? 'selected' : ''}>⭕ Sphere (Surface)</option>
        <option value="box" ${emitterType === 'box' ? 'selected' : ''}>🟦 Box</option>
        <option value="cone" ${emitterType === 'cone' ? 'selected' : ''}>🔺 Cone</option>
        <option value="ring" ${emitterType === 'ring' ? 'selected' : ''}>💍 Ring</option>
        <option value="disc" ${emitterType === 'disc' ? 'selected' : ''}>⚪ Disc</option>
        <option value="line" ${emitterType === 'line' ? 'selected' : ''}>📏 Line</option>
      </select>
    `);
    html += createParamRow('Emitter Radius', createSlider('particle_emitter_radius', emitterRadius, 0.1, 50, 0.5));
    
    html += `<div style="padding-top: 10px;">
      <button id="particle_rebuild_btn" class="rebuild-btn">Rebuild Particles</button>
    </div>`;
    html += `</div>`;
    
    // === PARTICLE SETTINGS ===
    html += `<div class="param-group"><div class="param-group-title">✨ Particle Settings</div>`;
    html += createParamRow('Size', createSlider('particle_size', particleSize, 0.01, 5, 0.01), 'particle_size');
    html += createParamRow('Lifetime', createSlider('particle_lifetime', lifetime, 0.5, 20, 0.1), 'particle_lifetime');
    html += createParamRow('Emission Rate', createSlider('particle_emission_rate', emissionRate, 0.1, 5, 0.1), 'particle_emission_rate');
    html += createParamRow('Rotation Speed', createSlider('particle_rotation_speed', rotationSpeed, 0, 10, 0.1), 'particle_rotation_speed');
    html += createParamRow('Glow', createSlider('particle_glow', glowIntensity, 0, 3, 0.1), 'particle_glow');
    html += `</div>`;

    html += `<div class="param-group"><div class="param-group-title">✦ Flare Rendering</div>`;
    html += createParamRow('Shape', `<select id="particle_shape">
      <option value="orb" ${particleShape === 'orb' ? 'selected' : ''}>Soft Orb</option>
      <option value="star" ${particleShape === 'star' ? 'selected' : ''}>Diffraction Star</option>
      <option value="streak" ${particleShape === 'streak' ? 'selected' : ''}>Anamorphic Streak</option>
      <option value="ring" ${particleShape === 'ring' ? 'selected' : ''}>Optical Ring</option>
      <option value="diamond" ${particleShape === 'diamond' ? 'selected' : ''}>Diamond Spark</option>
    </select>`, null);
    html += createParamRow('Aspect', createSlider('particle_flare_aspect', flareAspect, 0.25, 12, 0.05), 'particle_flare_aspect');
    html += createParamRow('Sharpness', createSlider('particle_flare_sharpness', flareSharpness, 0.25, 8, 0.05), 'particle_flare_sharpness');
    html += createParamRow('Radial Alignment', `<input type="checkbox" id="particle_align_motion" ${alignToMotion ? 'checked' : ''} style="width:16px;height:16px;">`, null);
    html += `</div>`;

    html += `<div class="param-group"><div class="param-group-title">Placement</div>`;
    html += createParamRow('Offset X', createSlider('particle_offset_x', offsetX, -30, 30, 0.1), 'particle_offset_x');
    html += createParamRow('Offset Y', createSlider('particle_offset_y', offsetY, -30, 30, 0.1), 'particle_offset_y');
    html += createParamRow('Offset Z', createSlider('particle_offset_z', offsetZ, -50, 50, 0.1), 'particle_offset_z');
    html += `</div>`;
    
    // === PHYSICS ===
    html += `<div class="param-group"><div class="param-group-title">⚡ Physics</div>`;
    html += createParamRow('Gravity X', createSlider('particle_gravity_x', gravityX, -10, 10, 0.1), 'particle_gravity_x');
    html += createParamRow('Gravity Y', createSlider('particle_gravity_y', gravityY, -10, 10, 0.1), 'particle_gravity_y');
    html += createParamRow('Gravity Z', createSlider('particle_gravity_z', gravityZ, -10, 10, 0.1), 'particle_gravity_z');
    html += createParamRow('Drag', createSlider('particle_drag', drag, 0, 0.5, 0.01), 'particle_drag');
    html += createParamRow('Floor Collision', `<input type="checkbox" id="particle_floor_enabled" ${floorEnabled ? 'checked' : ''} style="width:16px;height:16px;">`, null);
    html += createParamRow('Floor Y', createSlider('particle_floor_y', floorY, -50, 50, 0.5), 'particle_floor_y');
    html += createParamRow('Bounce', createSlider('particle_bounce', bounce, 0, 1, 0.05), 'particle_bounce');
    html += `</div>`;
    
    // === SIZE OVER LIFETIME ===
    html += `<div class="param-group"><div class="param-group-title">📐 Size Over Lifetime</div>`;
    html += createParamRow('Start', createSlider('particle_size_start', sizeStart, 0, 3, 0.1), 'particle_size_start');
    html += createParamRow('Mid', createSlider('particle_size_mid', sizeMid, 0, 3, 0.1), 'particle_size_mid');
    html += createParamRow('End', createSlider('particle_size_end', sizeEnd, 0, 3, 0.1), 'particle_size_end');
    html += `</div>`;
    
    // === OPACITY OVER LIFETIME ===
    html += `<div class="param-group"><div class="param-group-title">👁️ Opacity Over Lifetime</div>`;
    html += createParamRow('Start', createSlider('particle_opacity_start', opacityStart, 0, 1, 0.05), 'particle_opacity_start');
    html += createParamRow('Mid', createSlider('particle_opacity_mid', opacityMid, 0, 1, 0.05), 'particle_opacity_mid');
    html += createParamRow('End', createSlider('particle_opacity_end', opacityEnd, 0, 1, 0.05), 'particle_opacity_end');
    html += `</div>`;
    
    // === COLORS ===
    html += `<div class="param-group"><div class="param-group-title">🎨 Colors Over Lifetime</div>`;
    html += createParamRow('Start', createColorPicker('particle_color_start', colorStart), null);
    html += createParamRow('Mid', createColorPicker('particle_color_mid', colorMid), null);
    html += createParamRow('End', createColorPicker('particle_color_end', colorEnd), null);
    html += `</div>`;
    
    // === TURBULENCE EFFECTOR ===
    html += `<div class="param-group"><div class="param-group-title">🌀 Turbulence Effector</div>`;
    html += createParamRow('Enabled', `<input type="checkbox" id="particle_turb_enabled" ${turbEnabled ? 'checked' : ''} style="width:16px;height:16px;">`, null);
    html += createParamRow('Strength', createSlider('particle_turb_strength', turbStrength, 0, 10, 0.1), 'particle_turb_strength');
    html += createParamRow('Scale', createSlider('particle_turb_scale', turbScale, 0.1, 5, 0.1), 'particle_turb_scale');
    html += createParamRow('Speed', createSlider('particle_turb_speed', turbSpeed, 0, 3, 0.05), 'particle_turb_speed');
    html += createParamRow('Octaves', createSlider('particle_turb_octaves', turbOctaves, 1, 6, 1), 'particle_turb_octaves');
    html += `</div>`;
    
    // === VORTEX EFFECTOR ===
    html += `<div class="param-group"><div class="param-group-title">🔄 Vortex Effector</div>`;
    html += createParamRow('Enabled', `<input type="checkbox" id="particle_vortex_enabled" ${vortexEnabled ? 'checked' : ''} style="width:16px;height:16px;">`, null);
    html += createParamRow('Strength', createSlider('particle_vortex_strength', vortexStrength, 0, 20, 0.5), 'particle_vortex_strength');
    html += createParamRow('Inward Force', createSlider('particle_vortex_inward', vortexInward, -10, 10, 0.1), 'particle_vortex_inward');
    html += `</div>`;
    
    // === ATTRACTOR EFFECTOR ===
    html += `<div class="param-group"><div class="param-group-title">🎯 Attractor Effector</div>`;
    html += createParamRow('Enabled', `<input type="checkbox" id="particle_attractor_enabled" ${attractorEnabled ? 'checked' : ''} style="width:16px;height:16px;">`, null);
    html += createParamRow('Strength', createSlider('particle_attractor_strength', attractorStrength, 0, 30, 0.5), 'particle_attractor_strength');
    html += createParamRow('Radius', createSlider('particle_attractor_radius', attractorRadius, 1, 50, 0.5), 'particle_attractor_radius');
    html += createParamRow('Falloff', createSlider('particle_attractor_falloff', attractorFalloff, 0.5, 5, 0.1), 'particle_attractor_falloff');
    html += `</div>`;
    
    // === CURL NOISE (FLUID) EFFECTOR ===
    html += `<div class="param-group"><div class="param-group-title">🌊 Curl Noise (Fluid)</div>`;
    html += createParamRow('Enabled', `<input type="checkbox" id="particle_curl_enabled" ${curlEnabled ? 'checked' : ''} style="width:16px;height:16px;">`, null);
    html += createParamRow('Strength', createSlider('particle_curl_strength', curlStrength, 0, 10, 0.1), 'particle_curl_strength');
    html += createParamRow('Scale', createSlider('particle_curl_scale', curlScale, 0.1, 3, 0.05), 'particle_curl_scale');
    html += createParamRow('Speed', createSlider('particle_curl_speed', curlSpeed, 0, 2, 0.05), 'particle_curl_speed');
    html += createParamRow('Evolution', createSlider('particle_curl_evolution', curlEvolution, 0, 1, 0.01), 'particle_curl_evolution');
    html += `</div>`;
    
    // === COHESION/LIQUID EFFECTOR ===
    html += `<div class="param-group"><div class="param-group-title">💧 Liquid Cohesion</div>`;
    html += createParamRow('Enabled', `<input type="checkbox" id="particle_cohesion_enabled" ${cohesionEnabled ? 'checked' : ''} style="width:16px;height:16px;">`, null);
    html += createParamRow('Strength', createSlider('particle_cohesion_strength', cohesionStrength, 0, 3, 0.1), 'particle_cohesion_strength');
    html += createParamRow('Radius', createSlider('particle_cohesion_radius', cohesionRadius, 0.1, 5, 0.1), 'particle_cohesion_radius');
    html += createParamRow('Surface Tension', createSlider('particle_surface_tension', surfaceTension, 0, 1, 0.05), 'particle_surface_tension');
    html += createParamRow('Viscosity', createSlider('particle_viscosity', viscosity, 0, 0.5, 0.01), 'particle_viscosity');
    html += `</div>`;
    
    // === WIND EFFECTOR ===
    html += `<div class="param-group"><div class="param-group-title">🌬️ Wind Effector</div>`;
    html += createParamRow('Enabled', `<input type="checkbox" id="particle_wind_enabled" ${windEnabled ? 'checked' : ''} style="width:16px;height:16px;">`, null);
    html += createParamRow('Strength', createSlider('particle_wind_strength', windStrength, 0, 10, 0.1), 'particle_wind_strength');
    html += createParamRow('Gust Amount', createSlider('particle_wind_gust', windGust, 0, 1, 0.05), 'particle_wind_gust');
    html += createParamRow('Gust Frequency', createSlider('particle_wind_gust_freq', windGustFreq, 0.1, 3, 0.1), 'particle_wind_gust_freq');
    html += `</div>`;
    
    // === SWARM EFFECTOR ===
    html += `<div class="param-group"><div class="param-group-title">🐝 Swarm/Flocking</div>`;
    html += createParamRow('Enabled', `<input type="checkbox" id="particle_swarm_enabled" ${swarmEnabled ? 'checked' : ''} style="width:16px;height:16px;">`, null);
    html += createParamRow('Separation', createSlider('particle_swarm_separation', swarmSeparation, 0, 5, 0.1), 'particle_swarm_separation');
    html += createParamRow('Alignment', createSlider('particle_swarm_alignment', swarmAlignment, 0, 3, 0.1), 'particle_swarm_alignment');
    html += createParamRow('Cohesion', createSlider('particle_swarm_cohesion', swarmCohesion, 0, 3, 0.1), 'particle_swarm_cohesion');
    html += `</div>`;
    
    // === OSCILLATION EFFECTOR ===
    const oscillationEnabled = (uniforms.uOscillationEnabled?.value || 0) > 0.5;
    const oscillationAmplitude = uniforms.uOscillationAmplitude?.value ?? 0.5;
    const oscillationFrequency = uniforms.uOscillationFrequency?.value ?? 1.0;
    const oscillationPhaseOffset = uniforms.uOscillationPhaseOffset?.value ?? 0;
    const oscillationRadial = (uniforms.uOscillationRadial?.value || 0) > 0.5;
    html += `<div class="param-group"><div class="param-group-title">🌊 Oscillation</div>`;
    html += createParamRow('Enabled', `<input type="checkbox" id="particle_oscillation_enabled" ${oscillationEnabled ? 'checked' : ''} style="width:16px;height:16px;">`, null);
    html += createParamRow('Amplitude', createSlider('particle_oscillation_amplitude', oscillationAmplitude, 0, 3, 0.05), 'particle_oscillation_amplitude');
    html += createParamRow('Frequency', createSlider('particle_oscillation_frequency', oscillationFrequency, 0.1, 5, 0.1), 'particle_oscillation_frequency');
    html += createParamRow('Phase Offset', createSlider('particle_oscillation_phase', oscillationPhaseOffset, 0, 1, 0.05), 'particle_oscillation_phase');
    html += createParamRow('Radial Mode', `<input type="checkbox" id="particle_oscillation_radial" ${oscillationRadial ? 'checked' : ''} style="width:16px;height:16px;">`, null);
    html += `</div>`;
    
    // === BUOYANCY EFFECTOR ===
    const buoyancyEnabled = (uniforms.uBuoyancyEnabled?.value || 0) > 0.5;
    const buoyancyTemperature = uniforms.uBuoyancyTemperature?.value ?? 0.5;
    const buoyancyDensity = uniforms.uBuoyancyDensity?.value ?? 1.0;
    const buoyancyThermalNoise = uniforms.uBuoyancyThermalNoise?.value ?? 0.2;
    const buoyancyCoolingRate = uniforms.uBuoyancyCoolingRate?.value ?? 0.1;
    html += `<div class="param-group"><div class="param-group-title">🎈 Buoyancy</div>`;
    html += createParamRow('Enabled', `<input type="checkbox" id="particle_buoyancy_enabled" ${buoyancyEnabled ? 'checked' : ''} style="width:16px;height:16px;">`, null);
    html += createParamRow('Temperature', createSlider('particle_buoyancy_temperature', buoyancyTemperature, 0, 1, 0.05), 'particle_buoyancy_temperature');
    html += createParamRow('Density', createSlider('particle_buoyancy_density', buoyancyDensity, 0.1, 3, 0.1), 'particle_buoyancy_density');
    html += createParamRow('Thermal Noise', createSlider('particle_buoyancy_noise', buoyancyThermalNoise, 0, 1, 0.05), 'particle_buoyancy_noise');
    html += createParamRow('Cooling Rate', createSlider('particle_buoyancy_cooling', buoyancyCoolingRate, 0, 1, 0.05), 'particle_buoyancy_cooling');
    html += `</div>`;
    
    // === MEMBRANE EFFECTOR ===
    const membraneEnabled = (uniforms.uMembraneEnabled?.value || 0) > 0.5;
    const membraneStiffness = uniforms.uMembraneStiffness?.value ?? 0.5;
    const membraneMaxStretch = uniforms.uMembraneMaxStretch?.value ?? 2.0;
    const membraneConnectionRadius = uniforms.uMembraneConnectionRadius?.value ?? 1.0;
    const membraneDamping = uniforms.uMembraneDamping?.value ?? 0.1;
    html += `<div class="param-group"><div class="param-group-title">🫧 Membrane</div>`;
    html += createParamRow('Enabled', `<input type="checkbox" id="particle_membrane_enabled" ${membraneEnabled ? 'checked' : ''} style="width:16px;height:16px;">`, null);
    html += createParamRow('Stiffness', createSlider('particle_membrane_stiffness', membraneStiffness, 0, 2, 0.05), 'particle_membrane_stiffness');
    html += createParamRow('Max Stretch', createSlider('particle_membrane_stretch', membraneMaxStretch, 0.5, 5, 0.1), 'particle_membrane_stretch');
    html += createParamRow('Connection Radius', createSlider('particle_membrane_radius', membraneConnectionRadius, 0.1, 3, 0.1), 'particle_membrane_radius');
    html += createParamRow('Damping', createSlider('particle_membrane_damping', membraneDamping, 0, 1, 0.05), 'particle_membrane_damping');
    html += `</div>`;
    
    paramContent.innerHTML = html;
    
    // Link all sliders to their min/max inputs
    const sliderIds = [
      'particle_emitter_radius', 'particle_size', 'particle_lifetime', 'particle_emission_rate',
      'particle_rotation_speed', 'particle_glow', 'particle_flare_aspect', 'particle_flare_sharpness',
      'particle_offset_x', 'particle_offset_y', 'particle_offset_z',
      'particle_gravity_x', 'particle_gravity_y',
      'particle_gravity_z', 'particle_drag', 'particle_floor_y', 'particle_bounce',
      'particle_size_start', 'particle_size_mid', 'particle_size_end',
      'particle_opacity_start', 'particle_opacity_mid', 'particle_opacity_end',
      'particle_turb_strength', 'particle_turb_scale', 'particle_turb_speed', 'particle_turb_octaves',
      'particle_vortex_strength', 'particle_vortex_inward',
      'particle_attractor_strength', 'particle_attractor_radius', 'particle_attractor_falloff',
      'particle_curl_strength', 'particle_curl_scale', 'particle_curl_speed', 'particle_curl_evolution',
      'particle_cohesion_strength', 'particle_cohesion_radius', 'particle_surface_tension', 'particle_viscosity',
      'particle_wind_strength', 'particle_wind_gust', 'particle_wind_gust_freq',
      'particle_swarm_separation', 'particle_swarm_alignment', 'particle_swarm_cohesion',
      'particle_oscillation_amplitude', 'particle_oscillation_frequency', 'particle_oscillation_phase',
      'particle_buoyancy_temperature', 'particle_buoyancy_density', 'particle_buoyancy_noise', 'particle_buoyancy_cooling',
      'particle_membrane_stiffness', 'particle_membrane_stretch', 'particle_membrane_radius', 'particle_membrane_damping'
    ];
    sliderIds.forEach(id => linkSliderToNumber(id));
    
    addParticleSystemParamHandlers(sceneIdx);
  }
  
  function addParticleSystemParamHandlers(sceneIdx) {
    const sceneObj = getSceneByIdx(sceneIdx);
    if (!sceneObj?.particleSystemMesh) return;
    
    const uniforms = sceneObj.particleSystemMesh.material?.uniforms;
    if (!uniforms) return;
    
    // Helper to add range handler that updates uniform and slider thumb with undo support
    function addRangeHandler(inputId, uniformName) {
      const input = document.getElementById(inputId);
      if (input && uniforms[uniformName]) {
        input.addEventListener('focus', beginParamEdit);
        input.addEventListener('mousedown', beginParamEdit);
        input.addEventListener('input', () => {
          const value = parseFloat(input.value);
          uniforms[uniformName].value = value;
          updateSliderThumb(input);
          scheduleAutoSave(sceneIdx);
        });
        input.addEventListener('blur', endParamEdit);
        input.addEventListener('mouseup', () => setTimeout(endParamEdit, 100));
      }
    }
    
    // Helper to add checkbox handler with undo support
    function addCheckboxHandler(inputId, uniformName) {
      const input = document.getElementById(inputId);
      if (input && uniforms[uniformName]) {
        input.addEventListener('focus', beginParamEdit);
        input.addEventListener('mousedown', beginParamEdit);
        input.addEventListener('change', () => {
          uniforms[uniformName].value = input.checked ? 1.0 : 0.0;
          scheduleAutoSave(sceneIdx);
          endParamEdit();
        });
      }
    }
    
    // Helper to add color handler with undo support
    function addColorHandler(inputId, uniformName) {
      const input = document.getElementById(inputId);
      if (input && uniforms[uniformName]) {
        input.addEventListener('focus', beginParamEdit);
        input.addEventListener('mousedown', beginParamEdit);
        input.addEventListener('input', () => {
          uniforms[uniformName].value.set(input.value);
          scheduleAutoSave(sceneIdx);
        });
        input.addEventListener('change', endParamEdit);
      }
    }
    
    // Particle settings
    addRangeHandler('particle_size', 'uParticleSize');
    addRangeHandler('particle_lifetime', 'uLifetime');
    addRangeHandler('particle_emission_rate', 'uEmissionRate');
    addRangeHandler('particle_rotation_speed', 'uRotationSpeed');
    addRangeHandler('particle_glow', 'uGlowIntensity');
    addRangeHandler('particle_flare_aspect', 'uFlareAspect');
    addRangeHandler('particle_flare_sharpness', 'uFlareSharpness');
    addCheckboxHandler('particle_align_motion', 'uAlignToMotion');
    const shapeNames = ['orb', 'star', 'streak', 'ring', 'diamond'];
    const shapeSelect = document.getElementById('particle_shape');
    shapeSelect?.addEventListener('change', () => {
      const shapeIndex = Math.max(0, shapeNames.indexOf(shapeSelect.value));
      if (uniforms.uShape) uniforms.uShape.value = shapeIndex;
      sceneObj.data.particleSystem.rendering ||= {};
      sceneObj.data.particleSystem.rendering.shape = shapeNames[shapeIndex];
      scheduleAutoSave(sceneIdx);
    });

    const renderingBindings = {
      particle_flare_aspect: 'aspect',
      particle_flare_sharpness: 'sharpness',
    };
    Object.entries(renderingBindings).forEach(([id, key]) => {
      document.getElementById(id)?.addEventListener('input', event => {
        sceneObj.data.particleSystem.rendering ||= {};
        sceneObj.data.particleSystem.rendering[key] = parseFloat(event.target.value);
      });
    });
    document.getElementById('particle_align_motion')?.addEventListener('change', event => {
      sceneObj.data.particleSystem.rendering ||= {};
      sceneObj.data.particleSystem.rendering.alignToMotion = event.target.checked;
    });

    const offsetBindings = {
      particle_offset_x: ['x', 'offsetX'],
      particle_offset_y: ['y', 'offsetY'],
      particle_offset_z: ['z', 'offsetZ'],
    };
    Object.entries(offsetBindings).forEach(([id, [axis, key]]) => {
      const input = document.getElementById(id);
      input?.addEventListener('focus', beginParamEdit);
      input?.addEventListener('mousedown', beginParamEdit);
      input?.addEventListener('input', () => {
        const value = parseFloat(input.value);
        sceneObj.particleSystemMesh.position[axis] = value;
        sceneObj.data.particleSystem[key] = value;
        updateSliderThumb(input);
        scheduleAutoSave(sceneIdx);
      });
      input?.addEventListener('blur', endParamEdit);
      input?.addEventListener('mouseup', () => setTimeout(endParamEdit, 100));
    });
    
    // Physics - gravity needs special handling for Vector3
    const gravityXInput = document.getElementById('particle_gravity_x');
    const gravityYInput = document.getElementById('particle_gravity_y');
    const gravityZInput = document.getElementById('particle_gravity_z');
    if (gravityXInput && uniforms.uGravity) {
      gravityXInput.addEventListener('focus', beginParamEdit);
      gravityXInput.addEventListener('mousedown', beginParamEdit);
      gravityXInput.addEventListener('input', () => {
        uniforms.uGravity.value.x = parseFloat(gravityXInput.value);
        updateSliderThumb(gravityXInput);
        scheduleAutoSave(sceneIdx);
      });
      gravityXInput.addEventListener('blur', endParamEdit);
      gravityXInput.addEventListener('mouseup', () => setTimeout(endParamEdit, 100));
    }
    if (gravityYInput && uniforms.uGravity) {
      gravityYInput.addEventListener('focus', beginParamEdit);
      gravityYInput.addEventListener('mousedown', beginParamEdit);
      gravityYInput.addEventListener('input', () => {
        uniforms.uGravity.value.y = parseFloat(gravityYInput.value);
        updateSliderThumb(gravityYInput);
        scheduleAutoSave(sceneIdx);
      });
      gravityYInput.addEventListener('blur', endParamEdit);
      gravityYInput.addEventListener('mouseup', () => setTimeout(endParamEdit, 100));
    }
    if (gravityZInput && uniforms.uGravity) {
      gravityZInput.addEventListener('focus', beginParamEdit);
      gravityZInput.addEventListener('mousedown', beginParamEdit);
      gravityZInput.addEventListener('input', () => {
        uniforms.uGravity.value.z = parseFloat(gravityZInput.value);
        updateSliderThumb(gravityZInput);
        scheduleAutoSave(sceneIdx);
      });
      gravityZInput.addEventListener('blur', endParamEdit);
      gravityZInput.addEventListener('mouseup', () => setTimeout(endParamEdit, 100));
    }
    
    addRangeHandler('particle_drag', 'uDrag');
    addCheckboxHandler('particle_floor_enabled', 'uFloorEnabled');
    addRangeHandler('particle_floor_y', 'uFloorY');
    addRangeHandler('particle_bounce', 'uBounce');
    
    // Size over life
    addRangeHandler('particle_size_start', 'uSizeStart');
    addRangeHandler('particle_size_mid', 'uSizeMid');
    addRangeHandler('particle_size_end', 'uSizeEnd');
    
    // Opacity over life
    addRangeHandler('particle_opacity_start', 'uOpacityStart');
    addRangeHandler('particle_opacity_mid', 'uOpacityMid');
    addRangeHandler('particle_opacity_end', 'uOpacityEnd');
    
    // Colors
    addColorHandler('particle_color_start', 'uColorStart');
    addColorHandler('particle_color_mid', 'uColorMid');
    addColorHandler('particle_color_end', 'uColorEnd');
    
    // Turbulence
    addCheckboxHandler('particle_turb_enabled', 'uTurbEnabled');
    addRangeHandler('particle_turb_strength', 'uTurbStrength');
    addRangeHandler('particle_turb_scale', 'uTurbScale');
    addRangeHandler('particle_turb_speed', 'uTurbSpeed');
    addRangeHandler('particle_turb_octaves', 'uTurbOctaves');
    
    // Vortex
    addCheckboxHandler('particle_vortex_enabled', 'uVortexEnabled');
    addRangeHandler('particle_vortex_strength', 'uVortexStrength');
    addRangeHandler('particle_vortex_inward', 'uVortexInward');
    
    // Attractor
    addCheckboxHandler('particle_attractor_enabled', 'uAttractorEnabled');
    addRangeHandler('particle_attractor_strength', 'uAttractorStrength');
    addRangeHandler('particle_attractor_radius', 'uAttractorRadius');
    addRangeHandler('particle_attractor_falloff', 'uAttractorFalloff');
    
    // Curl noise (fluid)
    addCheckboxHandler('particle_curl_enabled', 'uCurlEnabled');
    addRangeHandler('particle_curl_strength', 'uCurlStrength');
    addRangeHandler('particle_curl_scale', 'uCurlScale');
    addRangeHandler('particle_curl_speed', 'uCurlSpeed');
    addRangeHandler('particle_curl_evolution', 'uCurlEvolution');
    
    // Cohesion/Liquid
    addCheckboxHandler('particle_cohesion_enabled', 'uCohesionEnabled');
    addRangeHandler('particle_cohesion_strength', 'uCohesionStrength');
    addRangeHandler('particle_cohesion_radius', 'uCohesionRadius');
    addRangeHandler('particle_surface_tension', 'uSurfaceTension');
    addRangeHandler('particle_viscosity', 'uViscosity');
    
    // Wind
    addCheckboxHandler('particle_wind_enabled', 'uWindEnabled');
    addRangeHandler('particle_wind_strength', 'uWindStrength');
    addRangeHandler('particle_wind_gust', 'uWindGust');
    addRangeHandler('particle_wind_gust_freq', 'uWindGustFreq');
    
    // Swarm
    addCheckboxHandler('particle_swarm_enabled', 'uSwarmEnabled');
    addRangeHandler('particle_swarm_separation', 'uSwarmSeparation');
    addRangeHandler('particle_swarm_alignment', 'uSwarmAlignment');
    addRangeHandler('particle_swarm_cohesion', 'uSwarmCohesion');
    
    // Oscillation
    addCheckboxHandler('particle_oscillation_enabled', 'uOscillationEnabled');
    addRangeHandler('particle_oscillation_amplitude', 'uOscillationAmplitude');
    addRangeHandler('particle_oscillation_frequency', 'uOscillationFrequency');
    addRangeHandler('particle_oscillation_phase', 'uOscillationPhaseOffset');
    addCheckboxHandler('particle_oscillation_radial', 'uOscillationRadial');
    
    // Buoyancy
    addCheckboxHandler('particle_buoyancy_enabled', 'uBuoyancyEnabled');
    addRangeHandler('particle_buoyancy_temperature', 'uBuoyancyTemperature');
    addRangeHandler('particle_buoyancy_density', 'uBuoyancyDensity');
    addRangeHandler('particle_buoyancy_noise', 'uBuoyancyThermalNoise');
    addRangeHandler('particle_buoyancy_cooling', 'uBuoyancyCoolingRate');
    
    // Membrane
    addCheckboxHandler('particle_membrane_enabled', 'uMembraneEnabled');
    addRangeHandler('particle_membrane_stiffness', 'uMembraneStiffness');
    addRangeHandler('particle_membrane_stretch', 'uMembraneMaxStretch');
    addRangeHandler('particle_membrane_radius', 'uMembraneConnectionRadius');
    addRangeHandler('particle_membrane_damping', 'uMembraneDamping');
    
    // Rebuild button
    const rebuildBtn = document.getElementById('particle_rebuild_btn');
    if (rebuildBtn) {
      rebuildBtn.addEventListener('click', () => {
        const particles = sceneObj.data.particleSystem;
        if (!particles) return;

        saveUndoStateForAction();
        
        // Read rebuild params from UI
        const parsedCount = parseInt(document.getElementById('particle_count')?.value, 10);
        const newCount = Number.isFinite(parsedCount) ? parsedCount : particles.count;
        const newEmitterType = document.getElementById('particle_emitter_type')?.value || particles.emitter?.type;
        const parsedEmitterRadius = parseFloat(document.getElementById('particle_emitter_radius')?.value);
        const newEmitterRadius = Number.isFinite(parsedEmitterRadius)
          ? parsedEmitterRadius
          : particles.emitter?.radius;
        
        // Rebuild from the exact runtime state so live edits and keyframes do not
        // disappear when the geometry is recreated.
        syncParticleConfigFromRuntime(particles, sceneObj.particleSystemMesh);
        
        // Update rebuild params
        particles.count = newCount;
        particles.emitter = particles.emitter || {};
        particles.emitter.type = newEmitterType;
        particles.emitter.radius = newEmitterRadius;
        
        // Remove old mesh
        if (sceneObj.particleSystemMesh) {
          sceneObj.threeScene.remove(sceneObj.particleSystemMesh);
          if (sceneObj.particleSystemMesh.geometry) sceneObj.particleSystemMesh.geometry.dispose();
          if (sceneObj.particleSystemMesh.material) sceneObj.particleSystemMesh.material.dispose();
        }
        
        // Create new mesh
        sceneObj.particleSystemMesh = createParticleSystem(particles, sceneObj.threeScene);
        
        // Update object list
        if (selectedSegment) {
          const seg = getCurrentSegment();
          if (seg) populateObjectList(selectedSegment.track, seg);
        }
        
        // Re-select and repopulate
        populateParticleSystemParams(particles, sceneIdx);
        const item = document.querySelector('.obj-item[data-type="particleSystem"]');
        if (item) item.classList.add('selected');
        
        scheduleAutoSave(sceneIdx);
        console.log(`Rebuilt particle system with ${newCount} particles`);
      });
    }
  }
  
  // =========================
  // DISPLACED SPHERE PARAMS
  // =========================
  function populateDisplacedSphereParams(sphere, sceneIdx) {
    currentEditingSceneIdx = sceneIdx; // Track which scene is being edited
    const sceneObj = getSceneByIdx(sceneIdx);
    if (!sceneObj || !sphere) {
      return;
    }
    
    const uniforms = sceneObj.displacedSphereMesh?.material?.uniforms;
    
    // Geometry section (requires rebuild)
    const currentDetail = sphere.detail ?? 6;
    const currentRadius = sphere.radius ?? 3.0;
    const vertexCount = sceneObj.displacedSphereMesh?.geometry?.attributes?.position?.count || 
                        Math.pow(4, currentDetail) * 10 + 2; // Approximate for icosahedron
    
    let html = `<div class="param-group"><div class="param-group-title">Geometry (requires rebuild)</div>`;
    html += createParamRow('Detail Level', createSlider('dsphere_detail', currentDetail, 2, 8, 1), 'dsphere_detail');
    html += `<div class="param-row"><span class="param-label">Vertices</span><span class="param-value" id="dsphere_vertex_count">~${vertexCount.toLocaleString()}</span></div>`;
    html += createParamRow('Radius', createSlider('dsphere_radius', currentRadius, 0.5, 10, 0.5), 'dsphere_radius');
    html += `<div style="padding-top: 10px;">
      <button id="dsphere_rebuild" class="rebuild-btn">Rebuild Sphere</button>
    </div>`;
    html += `</div>`;
    
    html += `<div class="param-group"><div class="param-group-title">Displacement</div>`;
    html += createParamRow('Strength', createSlider('dsphere_disp_strength', uniforms?.uDispStrength?.value ?? sphere.displacement?.strength ?? 0.5, 0, 2, 0.05), 'dsphere_disp_strength');
    html += createParamRow('Scale', createSlider('dsphere_disp_scale', uniforms?.uDispScale?.value ?? sphere.displacement?.scale ?? 2.0, 0.5, 10, 0.5), 'dsphere_disp_scale');
    html += createParamRow('Speed', createSlider('dsphere_disp_speed', uniforms?.uDispSpeed?.value ?? sphere.displacement?.speed ?? 0.5, 0, 2, 0.1), 'dsphere_disp_speed');
    html += `</div>`;
    
    // Effect-specific params
    const effectType = uniforms?.uEffectType?.value ?? 0;
    
    if (effectType > 0.5 && effectType < 1.5) {
      // Spiky
      html += `<div class="param-group"><div class="param-group-title">Spikes</div>`;
      html += createParamRow('Count', createSlider('dsphere_spike_count', uniforms?.uSpikeCount?.value ?? sphere.spikes?.count ?? 20, 5, 50, 1), 'dsphere_spike_count');
      html += createParamRow('Sharpness', createSlider('dsphere_spike_sharp', uniforms?.uSpikeSharpness?.value ?? sphere.spikes?.sharpness ?? 3, 1, 10, 0.5), 'dsphere_spike_sharp');
      html += `</div>`;
    }
    
    if (effectType > 1.5 && effectType < 2.5) {
      // Molten
      html += `<div class="param-group"><div class="param-group-title">Lava Cracks</div>`;
      html += createParamRow('Crack Width', createSlider('dsphere_crack_width', uniforms?.uCrackWidth?.value ?? sphere.cracks?.width ?? 0.1, 0.01, 0.3, 0.01), 'dsphere_crack_width');
      html += createParamRow('Crack Depth', createSlider('dsphere_crack_depth', uniforms?.uCrackDepth?.value ?? sphere.cracks?.depth ?? 0.3, 0, 1, 0.05), 'dsphere_crack_depth');
      html += `</div>`;
    }
    
    if (effectType > 2.5 && effectType < 3.5) {
      // Organic
      html += `<div class="param-group"><div class="param-group-title">Pulse</div>`;
      html += createParamRow('Pulse Speed', createSlider('dsphere_pulse_speed', uniforms?.uPulseSpeed?.value ?? sphere.pulse?.speed ?? 1.0, 0.1, 3, 0.1), 'dsphere_pulse_speed');
      html += createParamRow('Pulse Strength', createSlider('dsphere_pulse_strength', uniforms?.uPulseStrength?.value ?? sphere.pulse?.strength ?? 0.2, 0, 0.5, 0.02), 'dsphere_pulse_strength');
      html += `</div>`;
    }
    
    if (effectType > 3.5) {
      // Fractal
      html += `<div class="param-group"><div class="param-group-title">Fractal</div>`;
      html += createParamRow('Power', createSlider('dsphere_fractal_power', uniforms?.uFractalPower?.value ?? sphere.fractal?.power ?? 8, 2, 16, 0.5), 'dsphere_fractal_power');
      html += createParamRow('Iterations', createSlider('dsphere_fractal_iter', uniforms?.uFractalIterations?.value ?? sphere.fractal?.iterations ?? 4, 1, 8, 1), 'dsphere_fractal_iter');
      html += `</div>`;
    }
    
    html += `<div class="param-group"><div class="param-group-title">Material</div>`;
    const baseCol = uniforms?.uBaseColor?.value ? '#' + uniforms.uBaseColor.value.getHexString() : (sphere.colors?.base || '#6688ff');
    const accentCol = uniforms?.uAccentColor?.value ? '#' + uniforms.uAccentColor.value.getHexString() : (sphere.colors?.accent || '#ff8866');
    const emissiveCol = uniforms?.uEmissiveColor?.value ? '#' + uniforms.uEmissiveColor.value.getHexString() : (sphere.colors?.emissive || '#442244');
    html += createParamRow('Base Color', createColorPicker('dsphere_base_color', baseCol), null);
    html += createParamRow('Accent Color', createColorPicker('dsphere_accent_color', accentCol), null);
    html += createParamRow('Emissive', createColorPicker('dsphere_emissive_color', emissiveCol), null);
    html += createParamRow('Emissive Int', createSlider('dsphere_emissive_int', uniforms?.uEmissiveIntensity?.value ?? sphere.colors?.emissiveIntensity ?? 0.3, 0, 2, 0.1), 'dsphere_emissive_int');
    html += createParamRow('Metalness', createSlider('dsphere_metalness', uniforms?.uMetalness?.value ?? sphere.material?.metalness ?? 0.7, 0, 1, 0.05), 'dsphere_metalness');
    html += createParamRow('Roughness', createSlider('dsphere_roughness', uniforms?.uRoughness?.value ?? sphere.material?.roughness ?? 0.3, 0, 1, 0.05), 'dsphere_roughness');
    html += createParamRow('Env Map Int', createSlider('dsphere_envmap_int', uniforms?.uEnvMapIntensity?.value ?? sphere.material?.envMapIntensity ?? 1.0, 0, 3, 0.1), 'dsphere_envmap_int');
    html += createParamRow('Fresnel', createSlider('dsphere_fresnel', uniforms?.uFresnelPower?.value ?? sphere.material?.fresnelPower ?? 2.0, 0.5, 5, 0.25), 'dsphere_fresnel');
    html += `</div>`;
    
    html += `<div class="param-group"><div class="param-group-title">Audio Reactivity</div>`;
    html += createParamRow('Displace React', createSlider('dsphere_audio_disp', uniforms?.uAudioDisplace?.value ?? sphere.audio?.displaceReact ?? 0.3, 0, 1, 0.05), 'dsphere_audio_disp');
    html += createParamRow('Emissive React', createSlider('dsphere_audio_emis', uniforms?.uAudioEmissive?.value ?? sphere.audio?.emissiveReact ?? 0.5, 0, 1, 0.05), 'dsphere_audio_emis');
    html += `</div>`;
    
    paramContent.innerHTML = html;
    
    // Link sliders to number inputs
    ['dsphere_detail', 'dsphere_radius',
     'dsphere_disp_strength', 'dsphere_disp_scale', 'dsphere_disp_speed',
     'dsphere_spike_count', 'dsphere_spike_sharp',
     'dsphere_crack_width', 'dsphere_crack_depth',
     'dsphere_pulse_speed', 'dsphere_pulse_strength',
     'dsphere_fractal_power', 'dsphere_fractal_iter',
     'dsphere_emissive_int', 'dsphere_metalness', 'dsphere_roughness',
     'dsphere_envmap_int', 'dsphere_fresnel',
     'dsphere_audio_disp', 'dsphere_audio_emis'
    ].forEach(linkSliderToNumber);
    
    addDisplacedSphereParamHandlers(sceneIdx);
  }
  
  function addDisplacedSphereParamHandlers(sceneIdx) {
    const sceneObj = getSceneByIdx(sceneIdx);
    if (!sceneObj?.displacedSphereMesh) return;
    
    const uniforms = sceneObj.displacedSphereMesh.material?.uniforms;
    if (!uniforms) return;
    
    // Helper to add range handler that updates uniform and slider thumb with undo support
    function addRangeHandler(inputId, uniformName) {
      const input = document.getElementById(inputId);
      if (input && uniforms[uniformName]) {
        input.addEventListener('focus', beginParamEdit);
        input.addEventListener('mousedown', beginParamEdit);
        input.addEventListener('input', () => {
          const value = parseFloat(input.value);
          uniforms[uniformName].value = value;
          updateSliderThumb(input);
          scheduleAutoSave(sceneIdx);
        });
        input.addEventListener('blur', endParamEdit);
        input.addEventListener('mouseup', () => setTimeout(endParamEdit, 100));
      }
    }
    
    // Helper to add color handler with undo support
    function addColorHandler(inputId, uniformName) {
      const input = document.getElementById(inputId);
      if (input && uniforms[uniformName]) {
        input.addEventListener('focus', beginParamEdit);
        input.addEventListener('mousedown', beginParamEdit);
        input.addEventListener('input', () => {
          uniforms[uniformName].value.set(input.value);
          scheduleAutoSave(sceneIdx);
        });
        input.addEventListener('change', endParamEdit);
      }
    }
    
    // Detail slider - update vertex count preview
    const detailSlider = document.getElementById('dsphere_detail');
    const vertexCountEl = document.getElementById('dsphere_vertex_count');
    if (detailSlider && vertexCountEl) {
      detailSlider.addEventListener('input', () => {
        const detail = parseInt(detailSlider.value);
        // Icosahedron vertex count formula: 10 * 4^detail + 2
        const estimatedVerts = Math.pow(4, detail) * 10 + 2;
        vertexCountEl.textContent = `~${estimatedVerts.toLocaleString()}`;
        updateSliderThumb(detailSlider);
      });
    }
    
    // Radius slider - just update thumb (doesn't affect uniform, needs rebuild)
    const radiusSlider = document.getElementById('dsphere_radius');
    if (radiusSlider) {
      radiusSlider.addEventListener('input', () => {
        updateSliderThumb(radiusSlider);
      });
    }
    
    // Rebuild button handler
    const rebuildBtn = document.getElementById('dsphere_rebuild');
    if (rebuildBtn) {
      rebuildBtn.addEventListener('click', () => {
        // Get scene object fresh
        const currentSceneObj = getSceneByIdx(sceneIdx);
        if (!currentSceneObj) return;
        
        const sphere = currentSceneObj.data.displacedSphere;
        if (!sphere) return;

        saveUndoStateForAction();
        
        // Get new geometry values from UI
        const newDetail = parseInt(document.getElementById('dsphere_detail')?.value) || 6;
        const newRadius = parseFloat(document.getElementById('dsphere_radius')?.value) || 3.0;
        
        // Preserve current uniform values before rebuild
        const oldUniforms = currentSceneObj.displacedSphereMesh?.material?.uniforms;
        if (oldUniforms) {
          // Displacement
          sphere.displacement = sphere.displacement || {};
          sphere.displacement.strength = oldUniforms.uDispStrength?.value ?? sphere.displacement.strength;
          sphere.displacement.scale = oldUniforms.uDispScale?.value ?? sphere.displacement.scale;
          sphere.displacement.speed = oldUniforms.uDispSpeed?.value ?? sphere.displacement.speed;
          
          // Spikes
          sphere.spikes = sphere.spikes || {};
          sphere.spikes.count = oldUniforms.uSpikeCount?.value ?? sphere.spikes.count;
          sphere.spikes.sharpness = oldUniforms.uSpikeSharpness?.value ?? sphere.spikes.sharpness;
          
          // Cracks
          sphere.cracks = sphere.cracks || {};
          sphere.cracks.width = oldUniforms.uCrackWidth?.value ?? sphere.cracks.width;
          sphere.cracks.depth = oldUniforms.uCrackDepth?.value ?? sphere.cracks.depth;
          
          // Pulse
          sphere.pulse = sphere.pulse || {};
          sphere.pulse.speed = oldUniforms.uPulseSpeed?.value ?? sphere.pulse.speed;
          sphere.pulse.strength = oldUniforms.uPulseStrength?.value ?? sphere.pulse.strength;
          
          // Fractal
          sphere.fractal = sphere.fractal || {};
          sphere.fractal.power = oldUniforms.uFractalPower?.value ?? sphere.fractal.power;
          sphere.fractal.iterations = oldUniforms.uFractalIterations?.value ?? sphere.fractal.iterations;
          
          // Colors
          sphere.colors = sphere.colors || {};
          if (oldUniforms.uBaseColor?.value) sphere.colors.base = '#' + oldUniforms.uBaseColor.value.getHexString();
          if (oldUniforms.uAccentColor?.value) sphere.colors.accent = '#' + oldUniforms.uAccentColor.value.getHexString();
          if (oldUniforms.uEmissiveColor?.value) sphere.colors.emissive = '#' + oldUniforms.uEmissiveColor.value.getHexString();
          sphere.colors.emissiveIntensity = oldUniforms.uEmissiveIntensity?.value ?? sphere.colors.emissiveIntensity;
          
          // Material
          sphere.material = sphere.material || {};
          sphere.material.metalness = oldUniforms.uMetalness?.value ?? sphere.material.metalness;
          sphere.material.roughness = oldUniforms.uRoughness?.value ?? sphere.material.roughness;
          sphere.material.envMapIntensity = oldUniforms.uEnvMapIntensity?.value ?? sphere.material.envMapIntensity;
          sphere.material.fresnelPower = oldUniforms.uFresnelPower?.value ?? sphere.material.fresnelPower;
          
          // Audio
          sphere.audio = sphere.audio || {};
          sphere.audio.displaceReact = oldUniforms.uAudioDisplace?.value ?? sphere.audio.displaceReact;
          sphere.audio.emissiveReact = oldUniforms.uAudioEmissive?.value ?? sphere.audio.emissiveReact;
        }
        
        // Update geometry config
        sphere.detail = newDetail;
        sphere.radius = newRadius;
        
        // Remove old mesh
        if (currentSceneObj.displacedSphereMesh) {
          currentSceneObj.threeScene.remove(currentSceneObj.displacedSphereMesh);
          if (currentSceneObj.displacedSphereMesh.geometry) currentSceneObj.displacedSphereMesh.geometry.dispose();
          if (currentSceneObj.displacedSphereMesh.material) currentSceneObj.displacedSphereMesh.material.dispose();
        }
        
        // Create new mesh
        currentSceneObj.displacedSphereMesh = createDisplacedSphere(sphere, currentSceneObj.threeScene);
        
        // Update object list
        if (selectedSegment) {
          let seg = null;
          if (selectedSegment.trackId) {
            const track = getTrackById(selectedSegment.trackId);
            if (track) seg = track.segments[selectedSegment.index];
          }
          if (!seg) {
            const segs = getSegmentsForTrack(selectedSegment.track);
            seg = segs[selectedSegment.index];
          }
          if (seg) populateObjectList(selectedSegment.track, seg);
        }
        
        // Re-select and repopulate
        populateDisplacedSphereParams(sphere, sceneIdx);
        const item = document.querySelector('.obj-item[data-type="displacedSphere"]');
        if (item) item.classList.add('selected');
        
        scheduleAutoSave(sceneIdx);
        const vertCount = currentSceneObj.displacedSphereMesh?.geometry?.attributes?.position?.count || 0;
        console.log(`Rebuilt displaced sphere with detail ${newDetail} (~${vertCount.toLocaleString()} vertices)`);
      });
    }
    
    // Displacement
    addRangeHandler('dsphere_disp_strength', 'uDispStrength');
    addRangeHandler('dsphere_disp_scale', 'uDispScale');
    addRangeHandler('dsphere_disp_speed', 'uDispSpeed');
    
    // Spikes
    addRangeHandler('dsphere_spike_count', 'uSpikeCount');
    addRangeHandler('dsphere_spike_sharp', 'uSpikeSharpness');
    
    // Cracks
    addRangeHandler('dsphere_crack_width', 'uCrackWidth');
    addRangeHandler('dsphere_crack_depth', 'uCrackDepth');
    
    // Pulse
    addRangeHandler('dsphere_pulse_speed', 'uPulseSpeed');
    addRangeHandler('dsphere_pulse_strength', 'uPulseStrength');
    
    // Fractal
    addRangeHandler('dsphere_fractal_power', 'uFractalPower');
    addRangeHandler('dsphere_fractal_iter', 'uFractalIterations');
    
    // Material
    addColorHandler('dsphere_base_color', 'uBaseColor');
    addColorHandler('dsphere_accent_color', 'uAccentColor');
    addColorHandler('dsphere_emissive_color', 'uEmissiveColor');
    addRangeHandler('dsphere_emissive_int', 'uEmissiveIntensity');
    addRangeHandler('dsphere_metalness', 'uMetalness');
    addRangeHandler('dsphere_roughness', 'uRoughness');
    addRangeHandler('dsphere_envmap_int', 'uEnvMapIntensity');
    addRangeHandler('dsphere_fresnel', 'uFresnelPower');
    
    // Audio
    addRangeHandler('dsphere_audio_disp', 'uAudioDisplace');
    addRangeHandler('dsphere_audio_emis', 'uAudioEmissive');
  }
  
  // =========================
  // IFS ENV MAP PICKER HELPER
  // =========================
  function setupIFSEnvMapPicker(inputId, sceneObj, meshKey, subMeshKey, sceneIdx, dataKey) {
    const btn = document.getElementById(`${inputId}_btn`);
    const clearBtn = document.getElementById(`${inputId}_clear`);
    const nameEl = document.getElementById(`${inputId}_name`);
    
    // Data key in scene data (e.g., 'sierpinskiPyramid' vs mesh key 'sierpinskiMesh')
    const effectKey = dataKey || meshKey.replace('Mesh', '');
    
    if (btn) {
      btn.addEventListener('click', () => {
        openAssetBrowser('media', async (asset) => {
          if (!asset) return;
          
          const texturePath = asset.path;
          const loader = new THREE.TextureLoader();
          
          try {
            const texture = await new Promise((resolve, reject) => {
              loader.load(
                '/' + texturePath,
                (tex) => {
                  tex.mapping = THREE.EquirectangularReflectionMapping;
                  resolve(tex);
                },
                undefined,
                reject
              );
            });
            
            saveUndoStateForAction();
            // Get the mesh and update its uniforms
            const mesh = sceneObj[meshKey];
            if (mesh?.userData?.[subMeshKey]?.material?.uniforms) {
              const uniforms = mesh.userData[subMeshKey].material.uniforms;
              if (uniforms.uEnvMap?.value) uniforms.uEnvMap.value.dispose();
              uniforms.uEnvMap.value = texture;
              uniforms.uHasEnvMap.value = 1.0;
            }
            
            // Also update the scene data for saving
            const sceneData = sceneObj.data;
            if (sceneData[effectKey]) {
              sceneData[effectKey].material = sceneData[effectKey].material || {};
              sceneData[effectKey].material.envMap = texturePath;
            }
            
            // Update UI
            if (nameEl) {
              const fileName = texturePath.split('/').pop();
              nameEl.textContent = fileName;
              nameEl.classList.add('has-texture');
            }
            
            // Add clear button if not present
            const container = document.getElementById(`${inputId}_container`);
            if (container && !document.getElementById(`${inputId}_clear`)) {
              const newClearBtn = document.createElement('button');
              newClearBtn.className = 'btn texture-clear-btn';
              newClearBtn.id = `${inputId}_clear`;
              newClearBtn.title = 'Remove texture';
              newClearBtn.textContent = '✕';
              newClearBtn.addEventListener('click', () => clearIFSEnvMap(inputId, sceneObj, meshKey, subMeshKey, sceneIdx, effectKey));
              container.appendChild(newClearBtn);
            }
            
            scheduleAutoSave(sceneIdx);
            console.log(`[IFS EnvMap] Applied ${texturePath} to ${meshKey}`);
          } catch (err) {
            console.error('Failed to load env map:', err);
          }
        });
      });
    }
    
    if (clearBtn) {
      clearBtn.addEventListener('click', () => clearIFSEnvMap(inputId, sceneObj, meshKey, subMeshKey, sceneIdx, effectKey));
    }
  }
  
  function clearIFSEnvMap(inputId, sceneObj, meshKey, subMeshKey, sceneIdx, effectKey) {
    saveUndoStateForAction();
    const mesh = sceneObj[meshKey];
    if (mesh?.userData?.[subMeshKey]?.material?.uniforms) {
      const uniforms = mesh.userData[subMeshKey].material.uniforms;
      if (uniforms.uEnvMap?.value) {
        uniforms.uEnvMap.value.dispose();
        uniforms.uEnvMap.value = null;
      }
      uniforms.uHasEnvMap.value = 0.0;
    }
    
    // Update scene data
    const sceneData = sceneObj.data;
    if (sceneData[effectKey]?.material) {
      delete sceneData[effectKey].material.envMap;
    }
    
    // Update UI
    const nameEl = document.getElementById(`${inputId}_name`);
    if (nameEl) {
      nameEl.textContent = 'None';
      nameEl.classList.remove('has-texture');
    }
    
    const clearBtn = document.getElementById(`${inputId}_clear`);
    if (clearBtn) clearBtn.remove();
    
    scheduleAutoSave(sceneIdx);
  }
  
  // =========================
  // FRACTAL TREE PARAMS
  // =========================
  function populateFractalTreeParams(tree, sceneIdx) {
    currentEditingSceneIdx = sceneIdx;
    const sceneObj = getSceneByIdx(sceneIdx);
    if (!sceneObj || !tree) return;
    
    const branchUniforms = sceneObj.fractalTreeMesh?.userData?.branchMesh?.material?.uniforms;
    const leafUniforms = sceneObj.fractalTreeMesh?.userData?.leafMesh?.material?.uniforms;
    
    let html = `<div class="param-group"><div class="param-group-title">Structure (requires rebuild)</div>`;
    html += createParamRow('Max Depth', createSlider('tree_max_depth', tree.maxDepth ?? 7, 3, 10, 1), null);
    html += createParamRow('Trunk Height', createSlider('tree_trunk_height', tree.trunkHeight ?? 2.0, 0.5, 5, 0.1), null);
    html += createParamRow('Trunk Radius', createSlider('tree_trunk_radius', tree.trunkRadius ?? 0.15, 0.02, 0.5, 0.01), null);
    html += createParamRow('Branch Angle', createSlider('tree_branch_angle', tree.branchAngle ?? 30, 10, 60, 1), null);
    html += createParamRow('Length Ratio', createSlider('tree_length_ratio', tree.branchLengthRatio ?? 0.7, 0.4, 0.9, 0.02), null);
    html += createParamRow('Radius Ratio', createSlider('tree_radius_ratio', tree.branchRadiusRatio ?? 0.65, 0.4, 0.9, 0.02), null);
    html += createParamRow('Branch Count', createSlider('tree_branch_count', tree.branchCount ?? 3, 2, 5, 1), null);
    html += createParamRow('Twist', createSlider('tree_twist', tree.twist ?? 45, 0, 180, 5), null);
    html += createParamRow('Randomness', createSlider('tree_randomness', tree.randomness ?? 0.15, 0, 0.5, 0.02), null);
    html += createParamRow('Seed', createSlider('tree_seed', tree.seed ?? 12345, 1, 99999, 1), null);
    html += `<div style="padding-top: 10px;">
      <button id="tree_rebuild" class="rebuild-btn">Rebuild Tree</button>
    </div>`;
    html += `</div>`;
    
    html += `<div class="param-group"><div class="param-group-title">Leaves</div>`;
    html += createParamRow('Leaf Size', createSlider('tree_leaf_size', tree.leafSize ?? 0.08, 0.02, 0.3, 0.01), null);
    html += createParamRow('Leaf Density', createSlider('tree_leaf_density', tree.leafDensity ?? 0.5, 0, 1, 0.05), null);
    html += `</div>`;
    
    html += `<div class="param-group"><div class="param-group-title">Animation</div>`;
    html += createParamRow('Growth', createSlider('tree_growth', branchUniforms?.uGrowth?.value ?? 1.0, 0, 1, 0.01), 'tree_growth');
    html += createParamRow('Wind Strength', createSlider('tree_wind_strength', branchUniforms?.uWindStrength?.value ?? tree.animation?.windStrength ?? 0.3, 0, 1, 0.02), 'tree_wind_strength');
    html += createParamRow('Wind Speed', createSlider('tree_wind_speed', branchUniforms?.uWindSpeed?.value ?? tree.animation?.windSpeed ?? 1.0, 0.1, 3, 0.1), 'tree_wind_speed');
    html += createParamRow('Sway Amount', createSlider('tree_sway', branchUniforms?.uSwayAmount?.value ?? tree.animation?.swayAmount ?? 0.2, 0, 0.5, 0.02), 'tree_sway');
    html += `</div>`;
    
    html += `<div class="param-group"><div class="param-group-title">Colors</div>`;
    const trunkCol = branchUniforms?.uTrunkColor?.value ? '#' + branchUniforms.uTrunkColor.value.getHexString() : (tree.colors?.trunk || '#4a3728');
    const branchCol = branchUniforms?.uBranchColor?.value ? '#' + branchUniforms.uBranchColor.value.getHexString() : (tree.colors?.branch || '#5d4a3a');
    const leafStartCol = leafUniforms?.uLeafColorStart?.value ? '#' + leafUniforms.uLeafColorStart.value.getHexString() : (tree.colors?.leafStart || '#228b22');
    const leafEndCol = leafUniforms?.uLeafColorEnd?.value ? '#' + leafUniforms.uLeafColorEnd.value.getHexString() : (tree.colors?.leafEnd || '#90ee90');
    const emissiveCol = branchUniforms?.uEmissiveColor?.value ? '#' + branchUniforms.uEmissiveColor.value.getHexString() : (tree.colors?.emissive || '#000000');
    html += createParamRow('Trunk', createColorPicker('tree_trunk_color', trunkCol), null);
    html += createParamRow('Branch', createColorPicker('tree_branch_color', branchCol), null);
    html += createParamRow('Leaf Start', createColorPicker('tree_leaf_start_color', leafStartCol), null);
    html += createParamRow('Leaf End', createColorPicker('tree_leaf_end_color', leafEndCol), null);
    html += createParamRow('Emissive', createColorPicker('tree_emissive_color', emissiveCol), null);
    html += createParamRow('Emissive Int', createSlider('tree_emissive_int', branchUniforms?.uEmissiveIntensity?.value ?? tree.colors?.emissiveIntensity ?? 0.0, 0, 2, 0.1), 'tree_emissive_int');
    html += `</div>`;
    
    html += `<div class="param-group"><div class="param-group-title">Material</div>`;
    html += createParamRow('Metalness', createSlider('tree_metalness', branchUniforms?.uMetalness?.value ?? tree.material?.metalness ?? 0.1, 0, 1, 0.05), 'tree_metalness');
    html += createParamRow('Roughness', createSlider('tree_roughness', branchUniforms?.uRoughness?.value ?? tree.material?.roughness ?? 0.8, 0, 1, 0.05), 'tree_roughness');
    html += createParamRow('Reflection', createSlider('tree_envmap', branchUniforms?.uEnvMapIntensity?.value ?? tree.material?.envMapIntensity ?? 0.3, 0, 2, 0.05), 'tree_envmap');
    html += createParamRow('Env Map', createTextureSelect('tree_envmap_tex', tree.material?.envMap), null);
    html += `</div>`;
    
    paramContent.innerHTML = html;
    
    ['tree_max_depth', 'tree_trunk_height', 'tree_trunk_radius', 'tree_branch_angle',
     'tree_length_ratio', 'tree_radius_ratio', 'tree_branch_count', 'tree_twist',
     'tree_randomness', 'tree_seed', 'tree_leaf_size', 'tree_leaf_density',
     'tree_growth', 'tree_wind_strength', 'tree_wind_speed', 'tree_sway',
     'tree_emissive_int', 'tree_metalness', 'tree_roughness', 'tree_envmap'
    ].forEach(linkSliderToNumber);
    
    addFractalTreeParamHandlers(sceneIdx);
  }
  
  function addFractalTreeParamHandlers(sceneIdx) {
    const sceneObj = getSceneByIdx(sceneIdx);
    if (!sceneObj?.fractalTreeMesh) return;
    
    const branchUniforms = sceneObj.fractalTreeMesh.userData?.branchMesh?.material?.uniforms;
    const leafUniforms = sceneObj.fractalTreeMesh.userData?.leafMesh?.material?.uniforms;
    
    function addRangeHandler(inputId, uniformName, targetUniforms) {
      const input = document.getElementById(inputId);
      if (input && targetUniforms?.[uniformName]) {
        input.addEventListener('focus', beginParamEdit);
        input.addEventListener('mousedown', beginParamEdit);
        input.addEventListener('input', () => {
          const value = parseFloat(input.value);
          targetUniforms[uniformName].value = value;
          updateSliderThumb(input);
          scheduleAutoSave(sceneIdx);
        });
        input.addEventListener('blur', endParamEdit);
        input.addEventListener('mouseup', () => setTimeout(endParamEdit, 100));
      }
    }
    
    function addColorHandler(inputId, uniformName, targetUniforms) {
      const input = document.getElementById(inputId);
      if (input && targetUniforms?.[uniformName]) {
        input.addEventListener('focus', beginParamEdit);
        input.addEventListener('mousedown', beginParamEdit);
        input.addEventListener('input', () => {
          targetUniforms[uniformName].value.set(input.value);
          scheduleAutoSave(sceneIdx);
        });
        input.addEventListener('change', endParamEdit);
      }
    }
    
    // Animation uniforms (both branch and leaf)
    addRangeHandler('tree_growth', 'uGrowth', branchUniforms);
    addRangeHandler('tree_wind_strength', 'uWindStrength', branchUniforms);
    addRangeHandler('tree_wind_speed', 'uWindSpeed', branchUniforms);
    addRangeHandler('tree_sway', 'uSwayAmount', branchUniforms);
    
    // Also update leaf uniforms for wind
    const windStrengthInput = document.getElementById('tree_wind_strength');
    const windSpeedInput = document.getElementById('tree_wind_speed');
    const growthInput = document.getElementById('tree_growth');
    
    if (windStrengthInput && leafUniforms?.uWindStrength) {
      windStrengthInput.addEventListener('input', () => {
        leafUniforms.uWindStrength.value = parseFloat(windStrengthInput.value) * 1.5;
      });
    }
    if (windSpeedInput && leafUniforms?.uWindSpeed) {
      windSpeedInput.addEventListener('input', () => {
        leafUniforms.uWindSpeed.value = parseFloat(windSpeedInput.value);
      });
    }
    if (growthInput && leafUniforms?.uGrowth) {
      growthInput.addEventListener('input', () => {
        leafUniforms.uGrowth.value = parseFloat(growthInput.value);
      });
    }
    
    // Colors
    addColorHandler('tree_trunk_color', 'uTrunkColor', branchUniforms);
    addColorHandler('tree_branch_color', 'uBranchColor', branchUniforms);
    addColorHandler('tree_leaf_start_color', 'uLeafColorStart', leafUniforms);
    addColorHandler('tree_leaf_end_color', 'uLeafColorEnd', leafUniforms);
    addColorHandler('tree_emissive_color', 'uEmissiveColor', branchUniforms);
    
    // Also sync emissive to leaves
    const emissiveColorInput = document.getElementById('tree_emissive_color');
    if (emissiveColorInput && leafUniforms?.uEmissiveColor) {
      emissiveColorInput.addEventListener('input', () => {
        leafUniforms.uEmissiveColor.value.set(emissiveColorInput.value);
      });
    }
    
    addRangeHandler('tree_emissive_int', 'uEmissiveIntensity', branchUniforms);
    const emissiveIntInput = document.getElementById('tree_emissive_int');
    if (emissiveIntInput && leafUniforms?.uEmissiveIntensity) {
      emissiveIntInput.addEventListener('input', () => {
        leafUniforms.uEmissiveIntensity.value = parseFloat(emissiveIntInput.value);
      });
    }
    
    // Material
    addRangeHandler('tree_metalness', 'uMetalness', branchUniforms);
    addRangeHandler('tree_roughness', 'uRoughness', branchUniforms);
    addRangeHandler('tree_envmap', 'uEnvMapIntensity', branchUniforms);
    
    // Env map texture picker
    setupIFSEnvMapPicker('tree_envmap_tex', sceneObj, 'fractalTreeMesh', 'branchMesh', sceneIdx);
    
    // Rebuild button
    const rebuildBtn = document.getElementById('tree_rebuild');
    if (rebuildBtn) {
      rebuildBtn.addEventListener('click', () => {
        const currentSceneObj = getSceneByIdx(sceneIdx);
        if (!currentSceneObj) return;
        
        const tree = currentSceneObj.data.fractalTree;
        if (!tree) return;

        saveUndoStateForAction();
        
        // Update tree config from UI
        tree.maxDepth = parseInt(document.getElementById('tree_max_depth')?.value) || 7;
        tree.trunkHeight = parseFloat(document.getElementById('tree_trunk_height')?.value) || 2.0;
        tree.trunkRadius = parseFloat(document.getElementById('tree_trunk_radius')?.value) || 0.15;
        tree.branchAngle = parseFloat(document.getElementById('tree_branch_angle')?.value) || 30;
        tree.branchLengthRatio = parseFloat(document.getElementById('tree_length_ratio')?.value) || 0.7;
        tree.branchRadiusRatio = parseFloat(document.getElementById('tree_radius_ratio')?.value) || 0.65;
        tree.branchCount = parseInt(document.getElementById('tree_branch_count')?.value) || 3;
        tree.twist = parseFloat(document.getElementById('tree_twist')?.value) || 45;
        tree.randomness = parseFloat(document.getElementById('tree_randomness')?.value) || 0.15;
        tree.seed = parseInt(document.getElementById('tree_seed')?.value) || 12345;
        tree.leafSize = parseFloat(document.getElementById('tree_leaf_size')?.value) || 0.08;
        tree.leafDensity = parseFloat(document.getElementById('tree_leaf_density')?.value) || 0.5;
        
        // Preserve current color values
        const oldBranchUniforms = currentSceneObj.fractalTreeMesh?.userData?.branchMesh?.material?.uniforms;
        const oldLeafUniforms = currentSceneObj.fractalTreeMesh?.userData?.leafMesh?.material?.uniforms;
        
        if (oldBranchUniforms) {
          tree.colors = tree.colors || {};
          if (oldBranchUniforms.uTrunkColor?.value) tree.colors.trunk = '#' + oldBranchUniforms.uTrunkColor.value.getHexString();
          if (oldBranchUniforms.uBranchColor?.value) tree.colors.branch = '#' + oldBranchUniforms.uBranchColor.value.getHexString();
          if (oldBranchUniforms.uEmissiveColor?.value) tree.colors.emissive = '#' + oldBranchUniforms.uEmissiveColor.value.getHexString();
          tree.colors.emissiveIntensity = oldBranchUniforms.uEmissiveIntensity?.value;
          
          tree.animation = tree.animation || {};
          tree.animation.windStrength = oldBranchUniforms.uWindStrength?.value;
          tree.animation.windSpeed = oldBranchUniforms.uWindSpeed?.value;
          tree.animation.swayAmount = oldBranchUniforms.uSwayAmount?.value;
          
          tree.material = tree.material || {};
          tree.material.metalness = oldBranchUniforms.uMetalness?.value;
          tree.material.roughness = oldBranchUniforms.uRoughness?.value;
          tree.material.envMapIntensity = oldBranchUniforms.uEnvMapIntensity?.value;
        }
        
        if (oldLeafUniforms) {
          tree.colors = tree.colors || {};
          if (oldLeafUniforms.uLeafColorStart?.value) tree.colors.leafStart = '#' + oldLeafUniforms.uLeafColorStart.value.getHexString();
          if (oldLeafUniforms.uLeafColorEnd?.value) tree.colors.leafEnd = '#' + oldLeafUniforms.uLeafColorEnd.value.getHexString();
        }
        
        // Remove old tree
        if (currentSceneObj.fractalTreeMesh) {
          currentSceneObj.threeScene.remove(currentSceneObj.fractalTreeMesh);
          currentSceneObj.fractalTreeMesh = null;
        }
        
        // Create new tree
        currentSceneObj.fractalTreeMesh = createFractalTree(tree, currentSceneObj.threeScene);
        
        // Update object list
        if (selectedSegment) {
          let seg = null;
          if (selectedSegment.trackId) {
            const track = getTrackById(selectedSegment.trackId);
            if (track) seg = track.segments[selectedSegment.index];
          }
          if (!seg) {
            const segs = getSegmentsForTrack(selectedSegment.track);
            seg = segs[selectedSegment.index];
          }
          if (seg) populateObjectList(selectedSegment.track, seg);
        }
        
        // Re-select and repopulate
        populateFractalTreeParams(tree, sceneIdx);
        const item = document.querySelector('.obj-item[data-type="fractalTree"]');
        if (item) item.classList.add('selected');
        
        scheduleAutoSave(sceneIdx);
        console.log(`Rebuilt fractal tree with depth ${tree.maxDepth}`);
      });
    }
  }
  
  // =========================
  // SIERPINSKI PYRAMID PARAMS
  // =========================
  function populateSierpinskiParams(sierpinski, sceneIdx) {
    currentEditingSceneIdx = sceneIdx;
    const sceneObj = getSceneByIdx(sceneIdx);
    if (!sceneObj || !sierpinski) return;
    
    const uniforms = sceneObj.sierpinskiMesh?.userData?.tetraMesh?.material?.uniforms;
    
    let html = `<div class="param-group"><div class="param-group-title">Structure (requires rebuild)</div>`;
    html += createParamRow('Max Depth', createSlider('sierp_max_depth', sierpinski.maxDepth ?? 5, 2, 7, 1), null);
    html += createParamRow('Base Size', createSlider('sierp_base_size', sierpinski.baseSize ?? 4.0, 1, 10, 0.5), null);
    html += `<div style="padding-top: 10px;">
      <button id="sierp_rebuild" class="rebuild-btn">Rebuild Pyramid</button>
    </div>`;
    html += `</div>`;
    
    html += `<div class="param-group"><div class="param-group-title">Animation</div>`;
    html += createParamRow('Explosion', createSlider('sierp_explosion', uniforms?.uExplosion?.value ?? sierpinski.explosionFactor ?? 0, 0, 2, 0.05), 'sierp_explosion');
    html += createParamRow('Rotation Speed', createSlider('sierp_rot_speed', uniforms?.uRotationSpeed?.value ?? sierpinski.animation?.rotationSpeed ?? 0.2, 0, 1, 0.02), 'sierp_rot_speed');
    html += createParamRow('Pulse Speed', createSlider('sierp_pulse_speed', uniforms?.uPulseSpeed?.value ?? sierpinski.animation?.pulseSpeed ?? 1.0, 0.1, 5, 0.1), 'sierp_pulse_speed');
    html += createParamRow('Pulse Amount', createSlider('sierp_pulse_amt', uniforms?.uPulseAmount?.value ?? sierpinski.animation?.pulseAmount ?? 0.1, 0, 0.5, 0.02), 'sierp_pulse_amt');
    html += createParamRow('Color Cycle', createSlider('sierp_color_cycle', uniforms?.uColorCycleSpeed?.value ?? sierpinski.animation?.colorCycleSpeed ?? 0.5, 0, 2, 0.1), 'sierp_color_cycle');
    html += `</div>`;
    
    html += `<div class="param-group"><div class="param-group-title">Colors</div>`;
    const baseCol = uniforms?.uBaseColor?.value ? '#' + uniforms.uBaseColor.value.getHexString() : (sierpinski.colors?.base || '#ff4488');
    const midCol = uniforms?.uMidColor?.value ? '#' + uniforms.uMidColor.value.getHexString() : (sierpinski.colors?.mid || '#44ff88');
    const tipCol = uniforms?.uTipColor?.value ? '#' + uniforms.uTipColor.value.getHexString() : (sierpinski.colors?.tip || '#4488ff');
    const emissiveCol = uniforms?.uEmissiveColor?.value ? '#' + uniforms.uEmissiveColor.value.getHexString() : (sierpinski.colors?.emissive || '#220033');
    html += createParamRow('Base', createColorPicker('sierp_base_color', baseCol), null);
    html += createParamRow('Mid', createColorPicker('sierp_mid_color', midCol), null);
    html += createParamRow('Tip', createColorPicker('sierp_tip_color', tipCol), null);
    html += createParamRow('Emissive', createColorPicker('sierp_emissive_color', emissiveCol), null);
    html += createParamRow('Emissive Int', createSlider('sierp_emissive_int', uniforms?.uEmissiveIntensity?.value ?? sierpinski.colors?.emissiveIntensity ?? 0.5, 0, 2, 0.1), 'sierp_emissive_int');
    html += `</div>`;
    
    html += `<div class="param-group"><div class="param-group-title">Material</div>`;
    html += createParamRow('Metalness', createSlider('sierp_metalness', uniforms?.uMetalness?.value ?? sierpinski.material?.metalness ?? 0.8, 0, 1, 0.05), 'sierp_metalness');
    html += createParamRow('Roughness', createSlider('sierp_roughness', uniforms?.uRoughness?.value ?? sierpinski.material?.roughness ?? 0.2, 0, 1, 0.05), 'sierp_roughness');
    html += createParamRow('Reflection', createSlider('sierp_envmap', uniforms?.uEnvMapIntensity?.value ?? sierpinski.material?.envMapIntensity ?? 0.8, 0, 2, 0.05), 'sierp_envmap');
    html += createParamRow('Env Map', createTextureSelect('sierp_envmap_tex', sierpinski.material?.envMap), null);
    html += `</div>`;
    
    paramContent.innerHTML = html;
    
    ['sierp_max_depth', 'sierp_base_size', 'sierp_explosion', 'sierp_rot_speed',
     'sierp_pulse_speed', 'sierp_pulse_amt', 'sierp_color_cycle',
     'sierp_emissive_int', 'sierp_metalness', 'sierp_roughness', 'sierp_envmap'
    ].forEach(linkSliderToNumber);
    
    addSierpinskiParamHandlers(sceneIdx);
  }
  
  function addSierpinskiParamHandlers(sceneIdx) {
    const sceneObj = getSceneByIdx(sceneIdx);
    if (!sceneObj?.sierpinskiMesh) return;
    
    const uniforms = sceneObj.sierpinskiMesh.userData?.tetraMesh?.material?.uniforms;
    if (!uniforms) return;
    
    function addRangeHandler(inputId, uniformName) {
      const input = document.getElementById(inputId);
      if (input && uniforms[uniformName]) {
        input.addEventListener('focus', beginParamEdit);
        input.addEventListener('mousedown', beginParamEdit);
        input.addEventListener('input', () => {
          uniforms[uniformName].value = parseFloat(input.value);
          updateSliderThumb(input);
          scheduleAutoSave(sceneIdx);
        });
        input.addEventListener('blur', endParamEdit);
        input.addEventListener('mouseup', () => setTimeout(endParamEdit, 100));
      }
    }
    
    function addColorHandler(inputId, uniformName) {
      const input = document.getElementById(inputId);
      if (input && uniforms[uniformName]) {
        input.addEventListener('focus', beginParamEdit);
        input.addEventListener('mousedown', beginParamEdit);
        input.addEventListener('input', () => {
          uniforms[uniformName].value.set(input.value);
          scheduleAutoSave(sceneIdx);
        });
        input.addEventListener('change', endParamEdit);
      }
    }
    
    addRangeHandler('sierp_explosion', 'uExplosion');
    addRangeHandler('sierp_rot_speed', 'uRotationSpeed');
    addRangeHandler('sierp_pulse_speed', 'uPulseSpeed');
    addRangeHandler('sierp_pulse_amt', 'uPulseAmount');
    addRangeHandler('sierp_color_cycle', 'uColorCycleSpeed');
    addColorHandler('sierp_base_color', 'uBaseColor');
    addColorHandler('sierp_mid_color', 'uMidColor');
    addColorHandler('sierp_tip_color', 'uTipColor');
    addColorHandler('sierp_emissive_color', 'uEmissiveColor');
    addRangeHandler('sierp_emissive_int', 'uEmissiveIntensity');
    addRangeHandler('sierp_metalness', 'uMetalness');
    addRangeHandler('sierp_roughness', 'uRoughness');
    addRangeHandler('sierp_envmap', 'uEnvMapIntensity');
    
    // Env map texture picker
    setupIFSEnvMapPicker('sierp_envmap_tex', sceneObj, 'sierpinskiMesh', 'tetraMesh', sceneIdx);
    
    // Rebuild button
    const rebuildBtn = document.getElementById('sierp_rebuild');
    if (rebuildBtn) {
      rebuildBtn.addEventListener('click', () => {
        const currentSceneObj = getSceneByIdx(sceneIdx);
        if (!currentSceneObj) return;
        
        const sierpinski = currentSceneObj.data.sierpinskiPyramid;
        if (!sierpinski) return;

        saveUndoStateForAction();
        
        sierpinski.maxDepth = parseInt(document.getElementById('sierp_max_depth')?.value) || 5;
        sierpinski.baseSize = parseFloat(document.getElementById('sierp_base_size')?.value) || 4.0;
        
        // Preserve current uniform values
        const oldUniforms = currentSceneObj.sierpinskiMesh?.userData?.tetraMesh?.material?.uniforms;
        if (oldUniforms) {
          sierpinski.explosionFactor = oldUniforms.uExplosion?.value;
          sierpinski.animation = sierpinski.animation || {};
          sierpinski.animation.rotationSpeed = oldUniforms.uRotationSpeed?.value;
          sierpinski.animation.pulseSpeed = oldUniforms.uPulseSpeed?.value;
          sierpinski.animation.pulseAmount = oldUniforms.uPulseAmount?.value;
          sierpinski.animation.colorCycleSpeed = oldUniforms.uColorCycleSpeed?.value;
          sierpinski.colors = sierpinski.colors || {};
          if (oldUniforms.uBaseColor?.value) sierpinski.colors.base = '#' + oldUniforms.uBaseColor.value.getHexString();
          if (oldUniforms.uMidColor?.value) sierpinski.colors.mid = '#' + oldUniforms.uMidColor.value.getHexString();
          if (oldUniforms.uTipColor?.value) sierpinski.colors.tip = '#' + oldUniforms.uTipColor.value.getHexString();
          if (oldUniforms.uEmissiveColor?.value) sierpinski.colors.emissive = '#' + oldUniforms.uEmissiveColor.value.getHexString();
          sierpinski.colors.emissiveIntensity = oldUniforms.uEmissiveIntensity?.value;
          sierpinski.material = sierpinski.material || {};
          sierpinski.material.metalness = oldUniforms.uMetalness?.value;
          sierpinski.material.roughness = oldUniforms.uRoughness?.value;
          sierpinski.material.envMapIntensity = oldUniforms.uEnvMapIntensity?.value;
        }
        
        if (currentSceneObj.sierpinskiMesh) {
          currentSceneObj.threeScene.remove(currentSceneObj.sierpinskiMesh);
          currentSceneObj.sierpinskiMesh = null;
        }
        
        currentSceneObj.sierpinskiMesh = createSierpinskiPyramid(sierpinski, currentSceneObj.threeScene);
        
        if (selectedSegment) {
          let seg = null;
          if (selectedSegment.trackId) {
            const track = getTrackById(selectedSegment.trackId);
            if (track) seg = track.segments[selectedSegment.index];
          }
          if (!seg) {
            const segs = getSegmentsForTrack(selectedSegment.track);
            seg = segs[selectedSegment.index];
          }
          if (seg) populateObjectList(selectedSegment.track, seg);
        }
        
        populateSierpinskiParams(sierpinski, sceneIdx);
        const item = document.querySelector('.obj-item[data-type="sierpinskiPyramid"]');
        if (item) item.classList.add('selected');
        
        scheduleAutoSave(sceneIdx);
        console.log(`Rebuilt sierpinski pyramid with depth ${sierpinski.maxDepth}`);
      });
    }
  }
  
  // =========================
  // MENGER SPONGE PARAMS
  // =========================
  function populateMengerSpongeParams(menger, sceneIdx) {
    currentEditingSceneIdx = sceneIdx;
    const sceneObj = getSceneByIdx(sceneIdx);
    if (!sceneObj || !menger) return;
    
    const uniforms = sceneObj.mengerSpongeMesh?.userData?.cubeMesh?.material?.uniforms;
    
    let html = `<div class="param-group"><div class="param-group-title">Structure (requires rebuild)</div>`;
    html += createParamRow('Max Depth', createSlider('menger_max_depth', menger.maxDepth ?? 3, 1, 4, 1), null);
    html += createParamRow('Base Size', createSlider('menger_base_size', menger.baseSize ?? 4.0, 1, 10, 0.5), null);
    html += createParamRow('Gap', createSlider('menger_gap', menger.gap ?? 0.02, 0, 0.1, 0.005), null);
    html += `<div style="padding-top: 10px;">
      <button id="menger_rebuild" class="rebuild-btn">Rebuild Sponge</button>
    </div>`;
    html += `</div>`;
    
    html += `<div class="param-group"><div class="param-group-title">Animation</div>`;
    html += createParamRow('Assembly', createSlider('menger_assembly', uniforms?.uAssembly?.value ?? menger.animation?.assemblyProgress ?? 1.0, 0, 1, 0.01), 'menger_assembly');
    html += createParamRow('Rotation Speed', createSlider('menger_rot_speed', uniforms?.uRotationSpeed?.value ?? menger.animation?.rotationSpeed ?? 0.1, 0, 0.5, 0.02), 'menger_rot_speed');
    html += createParamRow('Breath Amount', createSlider('menger_breath_amt', uniforms?.uBreathAmount?.value ?? menger.animation?.breathAmount ?? 0.0, 0, 0.3, 0.02), 'menger_breath_amt');
    html += createParamRow('Breath Speed', createSlider('menger_breath_speed', uniforms?.uBreathSpeed?.value ?? menger.animation?.breathSpeed ?? 1.0, 0.1, 3, 0.1), 'menger_breath_speed');
    html += `</div>`;
    
    html += `<div class="param-group"><div class="param-group-title">Colors</div>`;
    const innerCol = uniforms?.uInnerColor?.value ? '#' + uniforms.uInnerColor.value.getHexString() : (menger.colors?.inner || '#ff6600');
    const outerCol = uniforms?.uOuterColor?.value ? '#' + uniforms.uOuterColor.value.getHexString() : (menger.colors?.outer || '#0066ff');
    const emissiveCol = uniforms?.uEmissiveColor?.value ? '#' + uniforms.uEmissiveColor.value.getHexString() : (menger.colors?.emissive || '#331100');
    html += createParamRow('Inner', createColorPicker('menger_inner_color', innerCol), null);
    html += createParamRow('Outer', createColorPicker('menger_outer_color', outerCol), null);
    html += createParamRow('Emissive', createColorPicker('menger_emissive_color', emissiveCol), null);
    html += createParamRow('Emissive Int', createSlider('menger_emissive_int', uniforms?.uEmissiveIntensity?.value ?? menger.colors?.emissiveIntensity ?? 0.3, 0, 2, 0.1), 'menger_emissive_int');
    html += `</div>`;
    
    html += `<div class="param-group"><div class="param-group-title">Material</div>`;
    html += createParamRow('Metalness', createSlider('menger_metalness', uniforms?.uMetalness?.value ?? menger.material?.metalness ?? 0.7, 0, 1, 0.05), 'menger_metalness');
    html += createParamRow('Roughness', createSlider('menger_roughness', uniforms?.uRoughness?.value ?? menger.material?.roughness ?? 0.3, 0, 1, 0.05), 'menger_roughness');
    html += createParamRow('Reflection', createSlider('menger_envmap', uniforms?.uEnvMapIntensity?.value ?? menger.material?.envMapIntensity ?? 0.6, 0, 2, 0.05), 'menger_envmap');
    html += createParamRow('Env Map', createTextureSelect('menger_envmap_tex', menger.material?.envMap), null);
    html += `</div>`;
    
    paramContent.innerHTML = html;
    
    ['menger_max_depth', 'menger_base_size', 'menger_gap', 'menger_assembly',
     'menger_rot_speed', 'menger_breath_amt', 'menger_breath_speed',
     'menger_emissive_int', 'menger_metalness', 'menger_roughness', 'menger_envmap'
    ].forEach(linkSliderToNumber);
    
    addMengerSpongeParamHandlers(sceneIdx);
  }
  
  function addMengerSpongeParamHandlers(sceneIdx) {
    const sceneObj = getSceneByIdx(sceneIdx);
    if (!sceneObj?.mengerSpongeMesh) return;
    
    const uniforms = sceneObj.mengerSpongeMesh.userData?.cubeMesh?.material?.uniforms;
    if (!uniforms) return;
    
    function addRangeHandler(inputId, uniformName) {
      const input = document.getElementById(inputId);
      if (input && uniforms[uniformName]) {
        input.addEventListener('focus', beginParamEdit);
        input.addEventListener('mousedown', beginParamEdit);
        input.addEventListener('input', () => {
          uniforms[uniformName].value = parseFloat(input.value);
          updateSliderThumb(input);
          scheduleAutoSave(sceneIdx);
        });
        input.addEventListener('blur', endParamEdit);
        input.addEventListener('mouseup', () => setTimeout(endParamEdit, 100));
      }
    }
    
    function addColorHandler(inputId, uniformName) {
      const input = document.getElementById(inputId);
      if (input && uniforms[uniformName]) {
        input.addEventListener('focus', beginParamEdit);
        input.addEventListener('mousedown', beginParamEdit);
        input.addEventListener('input', () => {
          uniforms[uniformName].value.set(input.value);
          scheduleAutoSave(sceneIdx);
        });
        input.addEventListener('change', endParamEdit);
      }
    }
    
    addRangeHandler('menger_assembly', 'uAssembly');
    addRangeHandler('menger_rot_speed', 'uRotationSpeed');
    addRangeHandler('menger_breath_amt', 'uBreathAmount');
    addRangeHandler('menger_breath_speed', 'uBreathSpeed');
    addColorHandler('menger_inner_color', 'uInnerColor');
    addColorHandler('menger_outer_color', 'uOuterColor');
    addColorHandler('menger_emissive_color', 'uEmissiveColor');
    addRangeHandler('menger_emissive_int', 'uEmissiveIntensity');
    addRangeHandler('menger_metalness', 'uMetalness');
    addRangeHandler('menger_roughness', 'uRoughness');
    addRangeHandler('menger_envmap', 'uEnvMapIntensity');
    
    // Env map texture picker
    setupIFSEnvMapPicker('menger_envmap_tex', sceneObj, 'mengerSpongeMesh', 'cubeMesh', sceneIdx);
    
    // Rebuild button
    const rebuildBtn = document.getElementById('menger_rebuild');
    if (rebuildBtn) {
      rebuildBtn.addEventListener('click', () => {
        const currentSceneObj = getSceneByIdx(sceneIdx);
        if (!currentSceneObj) return;
        
        const menger = currentSceneObj.data.mengerSponge;
        if (!menger) return;

        saveUndoStateForAction();
        
        menger.maxDepth = parseInt(document.getElementById('menger_max_depth')?.value) || 3;
        menger.baseSize = parseFloat(document.getElementById('menger_base_size')?.value) || 4.0;
        menger.gap = parseFloat(document.getElementById('menger_gap')?.value) || 0.02;
        
        const oldUniforms = currentSceneObj.mengerSpongeMesh?.userData?.cubeMesh?.material?.uniforms;
        if (oldUniforms) {
          menger.animation = menger.animation || {};
          menger.animation.assemblyProgress = oldUniforms.uAssembly?.value;
          menger.animation.rotationSpeed = oldUniforms.uRotationSpeed?.value;
          menger.animation.breathAmount = oldUniforms.uBreathAmount?.value;
          menger.animation.breathSpeed = oldUniforms.uBreathSpeed?.value;
          menger.colors = menger.colors || {};
          if (oldUniforms.uInnerColor?.value) menger.colors.inner = '#' + oldUniforms.uInnerColor.value.getHexString();
          if (oldUniforms.uOuterColor?.value) menger.colors.outer = '#' + oldUniforms.uOuterColor.value.getHexString();
          if (oldUniforms.uEmissiveColor?.value) menger.colors.emissive = '#' + oldUniforms.uEmissiveColor.value.getHexString();
          menger.colors.emissiveIntensity = oldUniforms.uEmissiveIntensity?.value;
          menger.material = menger.material || {};
          menger.material.metalness = oldUniforms.uMetalness?.value;
          menger.material.roughness = oldUniforms.uRoughness?.value;
          menger.material.envMapIntensity = oldUniforms.uEnvMapIntensity?.value;
        }
        
        if (currentSceneObj.mengerSpongeMesh) {
          currentSceneObj.threeScene.remove(currentSceneObj.mengerSpongeMesh);
          currentSceneObj.mengerSpongeMesh = null;
        }
        
        currentSceneObj.mengerSpongeMesh = createMengerSponge(menger, currentSceneObj.threeScene);
        
        if (selectedSegment) {
          let seg = null;
          if (selectedSegment.trackId) {
            const track = getTrackById(selectedSegment.trackId);
            if (track) seg = track.segments[selectedSegment.index];
          }
          if (!seg) {
            const segs = getSegmentsForTrack(selectedSegment.track);
            seg = segs[selectedSegment.index];
          }
          if (seg) populateObjectList(selectedSegment.track, seg);
        }
        
        populateMengerSpongeParams(menger, sceneIdx);
        const item = document.querySelector('.obj-item[data-type="mengerSponge"]');
        if (item) item.classList.add('selected');
        
        scheduleAutoSave(sceneIdx);
        console.log(`Rebuilt menger sponge with depth ${menger.maxDepth}`);
      });
    }
  }
  
  // =========================
  // CLOTH SIMULATION PARAMS
  // =========================
  function populateClothSimulationParams(cloth, sceneIdx) {
    currentEditingSceneIdx = sceneIdx;
    const sceneObj = getSceneByIdx(sceneIdx);
    if (!sceneObj || !cloth) return;
    const physics = cloth.physics ||= {};
    const wind = cloth.wind ||= {};
    const animation = cloth.animation ||= {};
    const collision = cloth.collision ||= {};
    const colors = cloth.colors ||= {};
    const material = cloth.material ||= {};
    const position = cloth.position ||= {};
    const rotation = cloth.rotation ||= {};
    const gravity = physics.gravity ||= { x: 0, y: -9.8, z: 0 };
    const windDirection = wind.direction ||= { x: 0.15, y: 0.05, z: 1 };
    const collisionPosition = collision.position ||= { x: 0, y: 0, z: 0 };

    let html = `<div class="param-group rebuild-required-group"><div class="param-group-title">Cloth Topology</div>`;
    html += createParamRow('Width', createSlider('cloth_width', cloth.width ?? 10, 2, 24, 0.1), null);
    html += createParamRow('Height', createSlider('cloth_height', cloth.height ?? 7, 2, 18, 0.1), null);
    html += createParamRow('Segments X', createSlider('cloth_segments_x', cloth.segmentsX ?? 24, 4, 48, 1), null);
    html += createParamRow('Segments Y', createSlider('cloth_segments_y', cloth.segmentsY ?? 18, 4, 48, 1), null);
    html += createParamRow('Pinning', `<select id="cloth_pin_mode" class="param-select">
      <option value="top" ${(cloth.pinMode || 'top') === 'top' ? 'selected' : ''}>Top Edge</option>
      <option value="topCorners" ${cloth.pinMode === 'topCorners' ? 'selected' : ''}>Top Corners</option>
      <option value="corners" ${cloth.pinMode === 'corners' ? 'selected' : ''}>Four Corners</option>
      <option value="left" ${cloth.pinMode === 'left' ? 'selected' : ''}>Left Edge</option>
      <option value="topCenter" ${cloth.pinMode === 'topCenter' ? 'selected' : ''}>Top Center</option>
      <option value="none" ${cloth.pinMode === 'none' ? 'selected' : ''}>Free</option>
    </select>`, null);
    html += createParamRow('Prewarm Steps', createSlider('cloth_prewarm', cloth.prewarmSteps ?? 0, 0, 60, 1), null);
    html += `<button id="cloth_rebuild" class="rebuild-btn">Rebuild Cloth</button></div>`;

    html += `<div class="param-group"><div class="param-group-title">Placement</div>`;
    html += createParamRow('Offset X', createSlider('cloth_pos_x', position.x ?? 0, -20, 20, 0.1), 'cloth_pos_x');
    html += createParamRow('Offset Y', createSlider('cloth_pos_y', position.y ?? 0, -14, 14, 0.1), 'cloth_pos_y');
    html += createParamRow('Offset Z', createSlider('cloth_pos_z', position.z ?? 0, -20, 20, 0.1), 'cloth_pos_z');
    html += createParamRow('Rotation X', createSlider('cloth_rot_x', rotation.x ?? 0, -180, 180, 1), 'cloth_rot_x');
    html += createParamRow('Rotation Y', createSlider('cloth_rot_y', rotation.y ?? 0, -180, 180, 1), 'cloth_rot_y');
    html += createParamRow('Rotation Z', createSlider('cloth_rot_z', rotation.z ?? 0, -180, 180, 1), 'cloth_rot_z');
    html += `</div>`;

    html += `<div class="param-group"><div class="param-group-title">Position-Based Physics</div>`;
    html += createParamRow('Gravity X', createSlider('cloth_gravity_x', gravity.x ?? 0, -20, 20, 0.1), 'cloth_gravity_x');
    html += createParamRow('Gravity Y', createSlider('cloth_gravity_y', gravity.y ?? -9.8, -25, 25, 0.1), 'cloth_gravity_y');
    html += createParamRow('Gravity Z', createSlider('cloth_gravity_z', gravity.z ?? 0, -20, 20, 0.1), 'cloth_gravity_z');
    html += createParamRow('Damping', createSlider('cloth_damping', physics.damping ?? 0.018, 0, 0.2, 0.001), 'cloth_damping');
    html += createParamRow('Iterations', createSlider('cloth_iterations', physics.iterations ?? 5, 1, 12, 1), 'cloth_iterations');
    html += createParamRow('Substeps', createSlider('cloth_substeps', physics.substeps ?? 1, 1, 4, 1), 'cloth_substeps');
    html += createParamRow('Stretch', createSlider('cloth_stretch', physics.stretchStiffness ?? 0.92, 0, 1, 0.01), 'cloth_stretch');
    html += createParamRow('Shear', createSlider('cloth_shear', physics.shearStiffness ?? 0.72, 0, 1, 0.01), 'cloth_shear');
    html += createParamRow('Bend', createSlider('cloth_bend', physics.bendStiffness ?? 0.28, 0, 1, 0.01), 'cloth_bend');
    html += createParamRow('Rest Scale', createSlider('cloth_rest_scale', physics.restScale ?? 1, 0.75, 1.25, 0.001), 'cloth_rest_scale');
    html += `</div>`;

    html += `<div class="param-group"><div class="param-group-title">Wind & Anchors</div>`;
    html += createParamRow('Wind Enabled', `<input type="checkbox" id="cloth_wind_enabled" ${wind.enabled !== false ? 'checked' : ''}>`, null);
    html += createParamRow('Strength', createSlider('cloth_wind_strength', wind.strength ?? 7, 0, 30, 0.1), 'cloth_wind_strength');
    html += createParamRow('Speed', createSlider('cloth_wind_speed', wind.speed ?? 1, 0, 6, 0.01), 'cloth_wind_speed');
    html += createParamRow('Turbulence', createSlider('cloth_turbulence', wind.turbulence ?? 0.5, 0, 3, 0.01), 'cloth_turbulence');
    html += createParamRow('Frequency', createSlider('cloth_wind_frequency', wind.frequency ?? 0.8, 0.05, 4, 0.01), 'cloth_wind_frequency');
    html += createParamRow('Direction X', createSlider('cloth_wind_x', windDirection.x ?? 0.15, -1, 1, 0.01), 'cloth_wind_x');
    html += createParamRow('Direction Y', createSlider('cloth_wind_y', windDirection.y ?? 0.05, -1, 1, 0.01), 'cloth_wind_y');
    html += createParamRow('Direction Z', createSlider('cloth_wind_z', windDirection.z ?? 1, -1, 1, 0.01), 'cloth_wind_z');
    html += createParamRow('Bass Wind', createSlider('cloth_wind_audio', wind.audioAmount ?? 0, 0, 3, 0.01), 'cloth_wind_audio');
    html += createParamRow('Anchor Motion', createSlider('cloth_anchor_amp', animation.anchorAmplitude ?? 0, 0, 3, 0.01), 'cloth_anchor_amp');
    html += createParamRow('Anchor Speed', createSlider('cloth_anchor_speed', animation.anchorSpeed ?? 0.7, 0, 5, 0.01), 'cloth_anchor_speed');
    html += createParamRow('Anchor Frequency', createSlider('cloth_anchor_frequency', animation.anchorFrequency ?? 1.5, 0, 5, 0.01), 'cloth_anchor_frequency');
    html += createParamRow('Anchor Axis', `<select id="cloth_anchor_axis" class="param-select"><option value="x" ${animation.anchorAxis === 'x' ? 'selected' : ''}>X</option><option value="y" ${(animation.anchorAxis || 'y') === 'y' ? 'selected' : ''}>Y</option><option value="z" ${animation.anchorAxis === 'z' ? 'selected' : ''}>Z</option></select>`, null);
    html += `</div>`;

    html += `<div class="param-group"><div class="param-group-title">Sphere Collision</div>`;
    html += createParamRow('Enabled', `<input type="checkbox" id="cloth_collision_enabled" ${collision.sphereEnabled ? 'checked' : ''}>`, null);
    html += createParamRow('Visible Collider', `<input type="checkbox" id="cloth_collision_visible" ${collision.visible ? 'checked' : ''}>`, null);
    html += createParamRow('Radius', createSlider('cloth_collision_radius', collision.radius ?? 2, 0.1, 8, 0.05), 'cloth_collision_radius');
    html += createParamRow('Position X', createSlider('cloth_collision_x', collisionPosition.x ?? 0, -10, 10, 0.1), 'cloth_collision_x');
    html += createParamRow('Position Y', createSlider('cloth_collision_y', collisionPosition.y ?? 0, -10, 10, 0.1), 'cloth_collision_y');
    html += createParamRow('Position Z', createSlider('cloth_collision_z', collisionPosition.z ?? 0, -10, 10, 0.1), 'cloth_collision_z');
    html += `</div>`;

    html += `<div class="param-group"><div class="param-group-title">Surface Texture</div>`;
    html += createParamRow('Media', createTextureSelect('cloth_surface_map', material.map), null);
    html += createParamRow('Palette Tint', `<input type="checkbox" id="cloth_texture_tint" ${material.textureTint !== false ? 'checked' : ''}>`, null);
    html += createParamRow('Repeat X', createSlider('cloth_texture_repeat_x', material.textureRepeatX ?? 1, 0.01, 20, 0.01), 'cloth_texture_repeat_x');
    html += createParamRow('Repeat Y', createSlider('cloth_texture_repeat_y', material.textureRepeatY ?? 1, 0.01, 20, 0.01), 'cloth_texture_repeat_y');
    html += createParamRow('Offset X', createSlider('cloth_texture_offset_x', material.textureOffsetX ?? 0, -10, 10, 0.01), 'cloth_texture_offset_x');
    html += createParamRow('Offset Y', createSlider('cloth_texture_offset_y', material.textureOffsetY ?? 0, -10, 10, 0.01), 'cloth_texture_offset_y');
    html += createParamRow('Rotation', createSlider('cloth_texture_rotation', material.textureRotation ?? 0, -180, 180, 1), 'cloth_texture_rotation');
    html += createParamRow('Video Speed', createSlider('cloth_texture_speed', material.texturePlaybackRate ?? 1, 0.05, 4, 0.01), 'cloth_texture_speed');
    html += createParamRow('Video Offset', createSlider('cloth_texture_time', material.textureTimeOffset ?? 0, -60, 60, 0.01), 'cloth_texture_time');
    html += `</div>`;

    html += `<div class="param-group"><div class="param-group-title">Fabric Palette</div>`;
    html += createParamRow('Primary', createColorPicker('cloth_color_primary', colors.primary || '#ff5fbf'), null);
    html += createParamRow('Secondary', createColorPicker('cloth_color_secondary', colors.secondary || '#6de8ff'), null);
    html += createParamRow('Accent', createColorPicker('cloth_color_accent', colors.accent || '#fff1b8'), null);
    html += createParamRow('Stripe Count', createSlider('cloth_stripe_frequency', colors.stripeFrequency ?? 4, 0, 20, 0.1), 'cloth_stripe_frequency');
    html += createParamRow('Stripe Mix', createSlider('cloth_stripe_strength', colors.stripeStrength ?? 0.35, 0, 1, 0.01), 'cloth_stripe_strength');
    html += `</div>`;

    html += `<div class="param-group"><div class="param-group-title">Physical Fabric</div>`;
    html += createParamRow('Metalness', createSlider('cloth_metalness', material.metalness ?? 0.2, 0, 1, 0.01), 'cloth_metalness');
    html += createParamRow('Roughness', createSlider('cloth_roughness', material.roughness ?? 0.28, 0, 1, 0.01), 'cloth_roughness');
    html += createParamRow('Clearcoat', createSlider('cloth_clearcoat', material.clearcoat ?? 0.75, 0, 1, 0.01), 'cloth_clearcoat');
    html += createParamRow('Coat Roughness', createSlider('cloth_coat_rough', material.clearcoatRoughness ?? 0.12, 0, 1, 0.01), 'cloth_coat_rough');
    html += createParamRow('Sheen', createSlider('cloth_sheen', material.sheen ?? 0.5, 0, 1, 0.01), 'cloth_sheen');
    html += createParamRow('Transmission', createSlider('cloth_transmission', material.transmission ?? 0, 0, 1, 0.01), 'cloth_transmission');
    html += createParamRow('Thickness', createSlider('cloth_thickness', material.thickness ?? 0.25, 0, 3, 0.01), 'cloth_thickness');
    html += createParamRow('Iridescence', createSlider('cloth_iridescence', material.iridescence ?? 0, 0, 1, 0.01), 'cloth_iridescence');
    html += createParamRow('Reflection', createSlider('cloth_env', material.envMapIntensity ?? 1.8, 0, 6, 0.05), 'cloth_env');
    html += createParamRow('Emissive', createColorPicker('cloth_emissive', material.emissive || colors.emissive || '#16051f'), null);
    html += createParamRow('Emissive Int', createSlider('cloth_emissive_int', material.emissiveIntensity ?? 0.12, 0, 3, 0.01), 'cloth_emissive_int');
    html += createParamRow('Opacity', createSlider('cloth_opacity', material.opacity ?? 1, 0, 1, 0.01), 'cloth_opacity');
    html += createParamRow('Wireframe', `<input type="checkbox" id="cloth_wireframe" ${material.wireframe ? 'checked' : ''}>`, null);
    html += `<button id="cloth_reset" class="rebuild-btn">Reset Simulation</button></div>`;

    paramContent.innerHTML = html;
    [
      'cloth_width', 'cloth_height', 'cloth_segments_x', 'cloth_segments_y', 'cloth_prewarm',
      'cloth_pos_x', 'cloth_pos_y', 'cloth_pos_z', 'cloth_rot_x', 'cloth_rot_y', 'cloth_rot_z',
      'cloth_gravity_x', 'cloth_gravity_y', 'cloth_gravity_z', 'cloth_damping', 'cloth_iterations',
      'cloth_substeps', 'cloth_stretch', 'cloth_shear', 'cloth_bend', 'cloth_rest_scale',
      'cloth_wind_strength', 'cloth_wind_speed', 'cloth_turbulence', 'cloth_wind_frequency',
      'cloth_wind_x', 'cloth_wind_y', 'cloth_wind_z', 'cloth_wind_audio', 'cloth_anchor_amp',
      'cloth_anchor_speed', 'cloth_anchor_frequency', 'cloth_collision_radius', 'cloth_collision_x',
      'cloth_collision_y', 'cloth_collision_z', 'cloth_texture_repeat_x', 'cloth_texture_repeat_y',
      'cloth_texture_offset_x', 'cloth_texture_offset_y', 'cloth_texture_rotation',
      'cloth_texture_speed', 'cloth_texture_time', 'cloth_stripe_frequency', 'cloth_stripe_strength',
      'cloth_metalness', 'cloth_roughness', 'cloth_clearcoat', 'cloth_coat_rough', 'cloth_sheen',
      'cloth_transmission', 'cloth_thickness', 'cloth_iridescence', 'cloth_env',
      'cloth_emissive_int', 'cloth_opacity',
    ].forEach(linkSliderToNumber);

    const bindings = {
      cloth_pos_x: ['position', 'x'], cloth_pos_y: ['position', 'y'], cloth_pos_z: ['position', 'z'],
      cloth_rot_x: ['rotation', 'x'], cloth_rot_y: ['rotation', 'y'], cloth_rot_z: ['rotation', 'z'],
      cloth_gravity_x: ['physics', 'gravity', 'x'], cloth_gravity_y: ['physics', 'gravity', 'y'], cloth_gravity_z: ['physics', 'gravity', 'z'],
      cloth_damping: ['physics', 'damping'], cloth_iterations: ['physics', 'iterations'],
      cloth_substeps: ['physics', 'substeps'], cloth_stretch: ['physics', 'stretchStiffness'],
      cloth_shear: ['physics', 'shearStiffness'], cloth_bend: ['physics', 'bendStiffness'],
      cloth_rest_scale: ['physics', 'restScale'], cloth_wind_strength: ['wind', 'strength'],
      cloth_wind_speed: ['wind', 'speed'], cloth_turbulence: ['wind', 'turbulence'],
      cloth_wind_frequency: ['wind', 'frequency'], cloth_wind_x: ['wind', 'direction', 'x'],
      cloth_wind_y: ['wind', 'direction', 'y'], cloth_wind_z: ['wind', 'direction', 'z'],
      cloth_wind_audio: ['wind', 'audioAmount'], cloth_anchor_amp: ['animation', 'anchorAmplitude'],
      cloth_anchor_speed: ['animation', 'anchorSpeed'], cloth_anchor_frequency: ['animation', 'anchorFrequency'],
      cloth_collision_radius: ['collision', 'radius'], cloth_collision_x: ['collision', 'position', 'x'],
      cloth_collision_y: ['collision', 'position', 'y'], cloth_collision_z: ['collision', 'position', 'z'],
      cloth_texture_repeat_x: ['material', 'textureRepeatX'], cloth_texture_repeat_y: ['material', 'textureRepeatY'],
      cloth_texture_offset_x: ['material', 'textureOffsetX'], cloth_texture_offset_y: ['material', 'textureOffsetY'],
      cloth_texture_rotation: ['material', 'textureRotation'], cloth_texture_speed: ['material', 'texturePlaybackRate'],
      cloth_texture_time: ['material', 'textureTimeOffset'],
      cloth_stripe_frequency: ['colors', 'stripeFrequency'], cloth_stripe_strength: ['colors', 'stripeStrength'],
      cloth_metalness: ['material', 'metalness'], cloth_roughness: ['material', 'roughness'],
      cloth_clearcoat: ['material', 'clearcoat'], cloth_coat_rough: ['material', 'clearcoatRoughness'],
      cloth_sheen: ['material', 'sheen'], cloth_transmission: ['material', 'transmission'],
      cloth_thickness: ['material', 'thickness'], cloth_iridescence: ['material', 'iridescence'],
      cloth_env: ['material', 'envMapIntensity'], cloth_emissive_int: ['material', 'emissiveIntensity'],
      cloth_opacity: ['material', 'opacity'],
    };
    const setNested = (target, path, value) => {
      let cursor = target;
      for (let i = 0; i < path.length - 1; i++) cursor = cursor[path[i]] ||= {};
      cursor[path[path.length - 1]] = value;
    };
    Object.entries(bindings).forEach(([id, path]) => {
      document.getElementById(id)?.addEventListener('input', event => {
        setNested(cloth, path, parseFloat(event.target.value));
        applyClothSimulationConfig(THREE, cloth, getSceneByIdx(sceneIdx)?.clothSimulationMesh);
        scheduleAutoSave(sceneIdx);
      });
    });
    document.getElementById('cloth_anchor_axis')?.addEventListener('change', event => {
      cloth.animation.anchorAxis = event.target.value;
      scheduleAutoSave(sceneIdx);
    });
    [['cloth_color_primary', 'primary'], ['cloth_color_secondary', 'secondary'], ['cloth_color_accent', 'accent']].forEach(([id, key]) => {
      document.getElementById(id)?.addEventListener('input', event => {
        cloth.colors[key] = event.target.value;
        applyClothSimulationConfig(THREE, cloth, getSceneByIdx(sceneIdx)?.clothSimulationMesh);
        scheduleAutoSave(sceneIdx);
      });
    });
    document.getElementById('cloth_emissive')?.addEventListener('input', event => {
      cloth.material.emissive = event.target.value;
      applyClothSimulationConfig(THREE, cloth, getSceneByIdx(sceneIdx)?.clothSimulationMesh);
      scheduleAutoSave(sceneIdx);
    });
    [
      ['cloth_wind_enabled', 'wind', 'enabled'],
      ['cloth_wireframe', 'material', 'wireframe'],
      ['cloth_texture_tint', 'material', 'textureTint'],
      ['cloth_collision_enabled', 'collision', 'sphereEnabled'],
    ].forEach(([id, section, key]) => {
      document.getElementById(id)?.addEventListener('change', event => {
        cloth[section][key] = event.target.checked;
        applyClothSimulationConfig(THREE, cloth, getSceneByIdx(sceneIdx)?.clothSimulationMesh);
        scheduleAutoSave(sceneIdx);
      });
    });
    const updateClothSurfaceTexture = async texturePath => {
      saveUndoStateForAction();
      cloth.material.map = texturePath || null;
      scheduleAutoSave(sceneIdx);
      await setClothSimulationTexture(
        THREE,
        getSceneByIdx(sceneIdx)?.clothSimulationMesh,
        cloth.material.map,
      );
      populateClothSimulationParams(cloth, sceneIdx);
      document.querySelector('.obj-item[data-type="clothSimulation"]')?.classList.add('selected');
    };
    document.getElementById('cloth_surface_map_btn')?.addEventListener('click', () => {
      openAssetBrowser('media', asset => {
        if (asset?.path) updateClothSurfaceTexture(asset.path);
      });
    });
    document.getElementById('cloth_surface_map_clear')?.addEventListener('click', () => {
      updateClothSurfaceTexture(null);
    });
    document.getElementById('cloth_collision_visible')?.addEventListener('change', event => {
      cloth.collision.visible = event.target.checked;
      document.getElementById('cloth_rebuild')?.click();
    });
    document.getElementById('cloth_reset')?.addEventListener('click', () => {
      resetClothSimulation(getSceneByIdx(sceneIdx)?.clothSimulationMesh, getCurrentTime());
    });
    document.getElementById('cloth_rebuild')?.addEventListener('click', () => {
      const current = getSceneByIdx(sceneIdx);
      const config = current?.data?.clothSimulation;
      if (!current || !config) return;
      saveUndoStateForAction();
      const numeric = (id, fallback) => {
        const value = parseFloat(document.getElementById(id)?.value);
        return Number.isFinite(value) ? value : fallback;
      };
      config.width = numeric('cloth_width', 10);
      config.height = numeric('cloth_height', 7);
      config.segmentsX = Math.round(numeric('cloth_segments_x', 24));
      config.segmentsY = Math.round(numeric('cloth_segments_y', 18));
      config.pinMode = document.getElementById('cloth_pin_mode')?.value || 'top';
      config.prewarmSteps = Math.round(numeric('cloth_prewarm', 0));
      disposeSceneSystemObject(current.clothSimulationMesh);
      current.clothSimulationMesh = createClothSimulationSystem(THREE, config, current.threeScene);
      populateObjectList('scenes', getCurrentSegment());
      populateClothSimulationParams(config, sceneIdx);
      document.querySelector('.obj-item[data-type="clothSimulation"]')?.classList.add('selected');
      scheduleAutoSave(sceneIdx);
    });
  }

  // =========================
  // INSTANCED HEART FIELD PARAMS
  // =========================
  function populateHeartFieldParams(hearts, sceneIdx) {
    currentEditingSceneIdx = sceneIdx;
    const sceneObj = getSceneByIdx(sceneIdx);
    if (!sceneObj || !hearts) return;

    const animation = hearts.animation || {};
    const colors = hearts.colors || {};
    const material = hearts.material || {};
    let html = `<div class="param-group rebuild-required-group"><div class="param-group-title">Geometry & Instances</div>`;
    html += createParamRow('Heart Count', `<input type="number" id="heart_count" value="${hearts.count ?? 600}" min="1" max="3000" step="50" class="rebuild-input">`);
    html += createParamRow('Random Seed', `<input type="number" id="heart_seed" value="${hearts.seed ?? 2001}" min="1" max="999999" step="1" class="rebuild-input">`);
    html += createParamRow('Extrusion', createSlider('heart_depth', hearts.depth ?? 0.34, 0.08, 0.8, 0.01), null);
    html += createParamRow('Bevel', createSlider('heart_bevel', hearts.bevel ?? 0.09, 0, 0.25, 0.01), null);
    html += createParamRow('Curve Detail', createSlider('heart_detail', hearts.curveSegments ?? 16, 6, 32, 1), null);
    html += createParamRow('Palette A', createColorPicker('heart_color_primary', colors.primary || '#ff2bbd'), null);
    html += createParamRow('Palette B', createColorPicker('heart_color_secondary', colors.secondary || '#7048ff'), null);
    html += createParamRow('Palette C', createColorPicker('heart_color_accent', colors.accent || '#36dcff'), null);
    html += `<button id="heart_rebuild" class="rebuild-btn">Rebuild Heart Field</button></div>`;

    html += `<div class="param-group"><div class="param-group-title">Field & Screen Placement</div>`;
    html += createParamRow('Offset X', createSlider('heart_offset_x', hearts.offsetX ?? 0, -15, 15, 0.1), 'heart_offset_x');
    html += createParamRow('Offset Y', createSlider('heart_offset_y', hearts.offsetY ?? 0, -10, 10, 0.1), 'heart_offset_y');
    html += createParamRow('Offset Z', createSlider('heart_offset_z', hearts.offsetZ ?? 0, -15, 15, 0.1), 'heart_offset_z');
    html += createParamRow('Rotation X', createSlider('heart_rotation_x', hearts.rotationX ?? 0, -180, 180, 0.1), 'heart_rotation_x');
    html += createParamRow('Rotation Y', createSlider('heart_rotation_y', hearts.rotationY ?? 0, -180, 180, 0.1), 'heart_rotation_y');
    html += createParamRow('Rotation Z', createSlider('heart_rotation_z', hearts.rotationZ ?? 0, -180, 180, 0.1), 'heart_rotation_z');
    html += createParamRow('Field Width', createSlider('heart_width', hearts.width ?? 17, 1, 35, 0.25), 'heart_width');
    html += createParamRow('Field Height', createSlider('heart_height', hearts.height ?? 11, 1, 25, 0.25), 'heart_height');
    html += createParamRow('Field Depth', createSlider('heart_field_depth', hearts.fieldDepth ?? 7, 0, 20, 0.25), 'heart_field_depth');
    html += createParamRow('Heart Size', createSlider('heart_size', hearts.size ?? 0.48, 0.05, 2, 0.01), 'heart_size');
    html += createParamRow('Size Variation', createSlider('heart_size_var', hearts.sizeVariation ?? 0.5, 0, 1, 0.01), 'heart_size_var');
    html += `</div>`;

    html += `<div class="param-group"><div class="param-group-title">Floating Motion</div>`;
    html += createParamRow('Direction', `<select id="heart_direction" class="param-select"><option value="1" ${(animation.direction ?? 1) >= 0 ? 'selected' : ''}>Up</option><option value="-1" ${(animation.direction ?? 1) < 0 ? 'selected' : ''}>Down</option></select>`, null);
    html += createParamRow('Speed', createSlider('heart_speed', animation.speed ?? 0.55, 0, 5, 0.01), 'heart_speed');
    html += createParamRow('Side Drift', createSlider('heart_drift', animation.drift ?? 0.55, 0, 4, 0.01), 'heart_drift');
    html += createParamRow('Drift Speed', createSlider('heart_drift_speed', animation.driftSpeed ?? 0.7, 0, 5, 0.01), 'heart_drift_speed');
    html += createParamRow('Tumble', createSlider('heart_tumble', animation.tumble ?? 0.34, 0, 3, 0.01), 'heart_tumble');
    html += createParamRow('Bass Pulse', createSlider('heart_audio_pulse', animation.audioPulse ?? 0.25, 0, 2, 0.01), 'heart_audio_pulse');
    html += `</div>`;

    html += `<div class="param-group"><div class="param-group-title">TPOLM Letters</div>`;
    html += createParamRow('Letter Variants', createSlider('heart_symbol_count', hearts.symbolCount ?? 5, 1, 5, 1), null);
    html += createParamRow('Letter Color', createColorPicker('heart_symbol_color', colors.symbol || '#fff6ff'), null);
    html += createParamRow('Letter Opacity', createSlider('heart_symbol_opacity', hearts.symbolOpacity ?? 0.92, 0, 1, 0.01), 'heart_symbol_opacity');
    html += `</div>`;

    html += `<div class="param-group"><div class="param-group-title">Glossy Material</div>`;
    html += createParamRow('Metalness', createSlider('heart_metalness', material.metalness ?? 0.68, 0, 1, 0.01), 'heart_metalness');
    html += createParamRow('Roughness', createSlider('heart_roughness', material.roughness ?? 0.18, 0, 1, 0.01), 'heart_roughness');
    html += createParamRow('Clearcoat', createSlider('heart_clearcoat', material.clearcoat ?? 1, 0, 1, 0.01), 'heart_clearcoat');
    html += createParamRow('Coat Roughness', createSlider('heart_coat_rough', material.clearcoatRoughness ?? 0.08, 0, 1, 0.01), 'heart_coat_rough');
    html += createParamRow('Reflection', createSlider('heart_env_int', material.envMapIntensity ?? 1.8, 0, 5, 0.05), 'heart_env_int');
    html += createParamRow('Emissive Color', createColorPicker('heart_emissive', colors.emissive || '#26003d'), null);
    html += createParamRow('Emissive Int', createSlider('heart_emissive_int', material.emissiveIntensity ?? 0.18, 0, 3, 0.01), 'heart_emissive_int');
    html += `</div>`;

    paramContent.innerHTML = html;
    [
      'heart_depth', 'heart_bevel', 'heart_detail', 'heart_offset_x', 'heart_offset_y',
      'heart_offset_z', 'heart_rotation_x', 'heart_rotation_y', 'heart_rotation_z',
      'heart_width', 'heart_height', 'heart_field_depth', 'heart_size',
      'heart_size_var', 'heart_speed', 'heart_drift', 'heart_drift_speed', 'heart_tumble',
      'heart_audio_pulse', 'heart_symbol_count', 'heart_symbol_opacity', 'heart_metalness',
      'heart_roughness', 'heart_clearcoat', 'heart_coat_rough', 'heart_env_int', 'heart_emissive_int',
    ].forEach(linkSliderToNumber);

    const liveBindings = {
      heart_offset_x: ['offsetX'], heart_offset_y: ['offsetY'], heart_offset_z: ['offsetZ'],
      heart_rotation_x: ['rotationX'], heart_rotation_y: ['rotationY'], heart_rotation_z: ['rotationZ'],
      heart_width: ['width'], heart_height: ['height'], heart_field_depth: ['fieldDepth'],
      heart_size: ['size'], heart_size_var: ['sizeVariation'], heart_symbol_opacity: ['symbolOpacity'],
      heart_speed: ['animation', 'speed'], heart_drift: ['animation', 'drift'],
      heart_drift_speed: ['animation', 'driftSpeed'], heart_tumble: ['animation', 'tumble'],
      heart_audio_pulse: ['animation', 'audioPulse'], heart_metalness: ['material', 'metalness'],
      heart_roughness: ['material', 'roughness'], heart_clearcoat: ['material', 'clearcoat'],
      heart_coat_rough: ['material', 'clearcoatRoughness'], heart_env_int: ['material', 'envMapIntensity'],
      heart_emissive_int: ['material', 'emissiveIntensity'],
    };

    const setNested = (target, path, value) => {
      let cursor = target;
      for (let i = 0; i < path.length - 1; i++) cursor = cursor[path[i]] ||= {};
      cursor[path[path.length - 1]] = value;
    };
    Object.entries(liveBindings).forEach(([id, path]) => {
      const input = document.getElementById(id);
      input?.addEventListener('input', () => {
        const current = getSceneByIdx(sceneIdx);
        if (!current?.data?.heartField) return;
        setNested(current.data.heartField, path, parseFloat(input.value));
        applyHeartFieldMaterial(current.data.heartField, current.heartFieldMesh);
        scheduleAutoSave(sceneIdx);
      });
    });

    document.getElementById('heart_direction')?.addEventListener('change', event => {
      hearts.animation ||= {};
      hearts.animation.direction = parseFloat(event.target.value);
      scheduleAutoSave(sceneIdx);
    });
    [['heart_symbol_color', 'symbol'], ['heart_emissive', 'emissive']].forEach(([id, key]) => {
      document.getElementById(id)?.addEventListener('input', event => {
        hearts.colors ||= {};
        hearts.colors[key] = event.target.value;
        applyHeartFieldMaterial(hearts, getSceneByIdx(sceneIdx)?.heartFieldMesh);
        scheduleAutoSave(sceneIdx);
      });
    });

    document.getElementById('heart_rebuild')?.addEventListener('click', () => {
      const current = getSceneByIdx(sceneIdx);
      const config = current?.data?.heartField;
      if (!current || !config) return;
      saveUndoStateForAction();
      const numeric = (id, fallback) => {
        const value = parseFloat(document.getElementById(id)?.value);
        return Number.isFinite(value) ? value : fallback;
      };
      config.count = Math.round(numeric('heart_count', 600));
      config.seed = Math.round(numeric('heart_seed', 2001));
      config.depth = numeric('heart_depth', 0.34);
      config.bevel = numeric('heart_bevel', 0.09);
      config.curveSegments = Math.round(numeric('heart_detail', 16));
      config.symbolCount = Math.round(numeric('heart_symbol_count', 5));
      config.colors ||= {};
      config.colors.primary = document.getElementById('heart_color_primary')?.value;
      config.colors.secondary = document.getElementById('heart_color_secondary')?.value;
      config.colors.accent = document.getElementById('heart_color_accent')?.value;
      disposeSceneSystemObject(current.heartFieldMesh);
      current.heartFieldMesh = createHeartFieldSystem(THREE, config, current.threeScene);
      populateObjectList('scenes', getCurrentSegment());
      populateHeartFieldParams(config, sceneIdx);
      document.querySelector('.obj-item[data-type="heartField"]')?.classList.add('selected');
      scheduleAutoSave(sceneIdx);
    });
  }

  // =========================
  // PROCEDURAL LANDSCAPE PARAMS
  // =========================

  function populateLandscapeFieldParams(landscape, sceneIdx) {
    currentEditingSceneIdx = sceneIdx;
    const sceneObj = getSceneByIdx(sceneIdx);
    if (!sceneObj || !landscape) return;
    const terrain = landscape.terrain || {};
    const colors = landscape.colors || {};
    const material = landscape.material || {};
    const animation = landscape.animation || {};
    const tunnel = landscape.tunnel || {};
    const light = landscape.light || {};
    const fog = landscape.fog || {};

    let html = `<div class="param-group rebuild-required-group"><div class="param-group-title">Landscape Geometry</div>`;
    html += createParamRow('Render Mode', `<select id="land_mode" class="rebuild-select">
      <option value="surface" ${(landscape.mode || 'surface') === 'surface' ? 'selected' : ''}>Displaced Surface</option>
      <option value="voxel" ${landscape.mode === 'voxel' ? 'selected' : ''}>Instanced Voxels</option>
      <option value="tunnel" ${landscape.mode === 'tunnel' ? 'selected' : ''}>Wrapped Tunnel</option>
    </select>`, null);
    html += createParamRow('Width', createSlider('land_width', landscape.width ?? 18, 4, 500, 0.5), null);
    html += createParamRow('Depth / Length', createSlider('land_depth', landscape.depth ?? 14, 4, 320, 1), null);
    html += createParamRow('Resolution X', createSlider('land_res_x', landscape.resolutionX ?? 150, 12, 480, 1), null);
    html += createParamRow('Resolution Z', createSlider('land_res_z', landscape.resolutionZ ?? 110, 12, 640, 1), null);
    html += createParamRow('Voxel Gap', createSlider('land_voxel_gap', landscape.voxelGap ?? 0.12, 0, 0.8, 0.01), null);
    html += createParamRow('Tunnel Radius', createSlider('land_tunnel_radius', tunnel.radius ?? 7, 2, 24, 0.1), null);
    html += `<button id="land_rebuild" class="rebuild-btn">Rebuild Landscape</button></div>`;

    html += `<div class="param-group"><div class="param-group-title">Placement</div>`;
    html += createParamRow('Offset X', createSlider('land_offset_x', landscape.offsetX ?? 0, -20, 20, 0.1), 'land_offset_x');
    html += createParamRow('Offset Y', createSlider('land_offset_y', landscape.offsetY ?? 0, -12, 12, 0.1), 'land_offset_y');
    html += createParamRow('Offset Z', createSlider('land_offset_z', landscape.offsetZ ?? 0, -20, 20, 0.1), 'land_offset_z');
    html += createParamRow('Rotation X', createSlider('land_rotation_x', landscape.rotationX ?? 0, -180, 180, 0.1), 'land_rotation_x');
    html += createParamRow('Rotation Y', createSlider('land_rotation_y', landscape.rotationY ?? 0, -180, 180, 0.1), 'land_rotation_y');
    html += createParamRow('Rotation Z', createSlider('land_rotation_z', landscape.rotationZ ?? 0, -180, 180, 0.1), 'land_rotation_z');
    html += `</div>`;

    html += `<div class="param-group"><div class="param-group-title">Terrain Shape</div>`;
    html += createParamRow('Frequency', createSlider('land_frequency', terrain.frequency ?? 0.22, 0.02, 1.2, 0.01), 'land_frequency');
    html += createParamRow('Ridge Mix', createSlider('land_ridge', terrain.ridge ?? 0.45, 0, 1, 0.01), 'land_ridge');
    html += createParamRow('Domain Warp', createSlider('land_warp', terrain.warp ?? 0.75, 0, 3, 0.01), 'land_warp');
    html += createParamRow('Terracing', createSlider('land_terrace', terrain.terrace ?? 0, 0, 24, 1), 'land_terrace');
    html += `</div>`;

    html += `<div class="param-group"><div class="param-group-title">Animation</div>`;
    html += createParamRow('Base Height (No Audio)', createSlider('land_height', terrain.height ?? 3.8, 0, 12, 0.05), 'land_height');
    html += createParamRow('Flow Speed', createSlider('land_speed', animation.speed ?? 0.12, 0, 2, 0.01), 'land_speed');
    html += createParamRow('Bass Height Boost', createSlider('land_audio', animation.audioPulse ?? 0.3, 0, 2, 0.01), 'land_audio');
    html += createParamRow('Tunnel Twist', createSlider('land_tunnel_twist', tunnel.twist ?? 0, -2, 2, 0.01), 'land_tunnel_twist');
    html += createParamRow('Tunnel Rotation', createSlider('land_tunnel_rotate', tunnel.rotateSpeed ?? 0, -1, 1, 0.01), 'land_tunnel_rotate');
    html += `</div>`;

    html += `<div class="param-group"><div class="param-group-title">Palette</div>`;
    html += createParamRow('Low Color', createColorPicker('land_color_low', colors.low || '#18052b'), null);
    html += createParamRow('High Color', createColorPicker('land_color_high', colors.high || '#ff45bd'), null);
    html += createParamRow('Contour', createColorPicker('land_color_accent', colors.accent || '#67e8ff'), null);
    html += createParamRow('Emissive', createColorPicker('land_color_emissive', colors.emissive || '#25003d'), null);
    html += `</div>`;

    html += `<div class="param-group"><div class="param-group-title">Material</div>`;
    html += createParamRow('Metalness', createSlider('land_metal', material.metalness ?? 0.72, 0, 1, 0.01), 'land_metal');
    html += createParamRow('Roughness', createSlider('land_rough', material.roughness ?? 0.16, 0, 1, 0.01), 'land_rough');
    html += createParamRow('Reflection', createSlider('land_env', material.envMapIntensity ?? 1.8, 0, 5, 0.05), 'land_env');
    html += createParamRow('Emissive Int', createSlider('land_emissive_int', material.emissiveIntensity ?? 0.25, 0, 3, 0.01), 'land_emissive_int');
    html += createParamRow('Contour Count', createSlider('land_contour_density', material.contourDensity ?? 12, 1, 40, 1), 'land_contour_density');
    html += createParamRow('Contour Glow', createSlider('land_contour_strength', material.contourStrength ?? 0.3, 0, 3, 0.01), 'land_contour_strength');
    html += createParamRow('Ridge Smoothing', createSlider('land_normal_smoothing', material.normalSmoothing ?? 0.18, 0.01, 0.5, 0.01), 'land_normal_smoothing');
    html += createParamRow('Wireframe', `<input type="checkbox" id="land_wireframe" ${material.wireframe ? 'checked' : ''}>`, null);
    html += `</div>`;

    html += `<div class="param-group"><div class="param-group-title">Landscape Lighting</div>`;
    html += createParamRow('Key Color', createColorPicker('land_light_color', light.color || '#fff4ff'), null);
    html += createParamRow('Key Intensity', createSlider('land_light_int', light.intensity ?? 1.5, 0, 5, 0.01), 'land_light_int');
    html += createParamRow('Ambient Color', createColorPicker('land_ambient_color', light.ambientColor || '#251335'), null);
    html += createParamRow('Ambient Int', createSlider('land_ambient_int', light.ambientIntensity ?? 0.75, 0, 3, 0.01), 'land_ambient_int');
    html += `</div>`;

    html += `<div class="param-group"><div class="param-group-title">Fog</div>`;
    html += createParamRow('Fog Color', createColorPicker('land_fog_color', fog.color || '#08030f'), null);
    html += createParamRow('Fog Near', createSlider('land_fog_near', fog.near ?? 12, 0, 80, 0.5), 'land_fog_near');
    html += createParamRow('Fog Far', createSlider('land_fog_far', fog.far ?? 34, 2, 160, 0.5), 'land_fog_far');
    html += `</div>`;

    paramContent.innerHTML = html;
    [
      'land_width', 'land_depth', 'land_res_x', 'land_res_z', 'land_voxel_gap', 'land_tunnel_radius',
      'land_offset_x', 'land_offset_y', 'land_offset_z',
      'land_rotation_x', 'land_rotation_y', 'land_rotation_z', 'land_height', 'land_frequency',
      'land_ridge', 'land_warp', 'land_terrace', 'land_speed', 'land_audio',
      'land_tunnel_twist', 'land_tunnel_rotate',
      'land_metal', 'land_rough', 'land_env', 'land_emissive_int',
      'land_contour_density', 'land_contour_strength', 'land_light_int',
      'land_normal_smoothing',
      'land_ambient_int', 'land_fog_near', 'land_fog_far',
    ].forEach(linkSliderToNumber);

    const bindings = {
      land_offset_x: ['offsetX'], land_offset_y: ['offsetY'], land_offset_z: ['offsetZ'],
      land_rotation_x: ['rotationX'], land_rotation_y: ['rotationY'], land_rotation_z: ['rotationZ'],
      land_height: ['terrain', 'height'], land_frequency: ['terrain', 'frequency'],
      land_ridge: ['terrain', 'ridge'], land_warp: ['terrain', 'warp'],
      land_terrace: ['terrain', 'terrace'], land_speed: ['animation', 'speed'],
      land_audio: ['animation', 'audioPulse'],
      land_tunnel_twist: ['tunnel', 'twist'],
      land_tunnel_rotate: ['tunnel', 'rotateSpeed'],
      land_metal: ['material', 'metalness'],
      land_rough: ['material', 'roughness'], land_env: ['material', 'envMapIntensity'],
      land_emissive_int: ['material', 'emissiveIntensity'],
      land_contour_density: ['material', 'contourDensity'],
      land_contour_strength: ['material', 'contourStrength'],
      land_normal_smoothing: ['material', 'normalSmoothing'],
      land_light_int: ['light', 'intensity'], land_ambient_int: ['light', 'ambientIntensity'],
      land_fog_near: ['fog', 'near'], land_fog_far: ['fog', 'far'],
    };
    const setNested = (target, path, value) => {
      let cursor = target;
      for (let i = 0; i < path.length - 1; i++) cursor = cursor[path[i]] ||= {};
      cursor[path[path.length - 1]] = value;
    };
    Object.entries(bindings).forEach(([id, path]) => {
      const input = document.getElementById(id);
      input?.addEventListener('input', () => {
        const current = getSceneByIdx(sceneIdx);
        if (!current?.data?.landscapeField) return;
        setNested(current.data.landscapeField, path, parseFloat(input.value));
        applyLandscapeFieldConfig(current.data.landscapeField, current.landscapeMesh);
        scheduleAutoSave(sceneIdx);
      });
    });

    const colorBindings = {
      land_color_low: ['colors', 'low'], land_color_high: ['colors', 'high'],
      land_color_accent: ['colors', 'accent'], land_color_emissive: ['colors', 'emissive'],
      land_light_color: ['light', 'color'], land_ambient_color: ['light', 'ambientColor'],
      land_fog_color: ['fog', 'color'],
    };
    Object.entries(colorBindings).forEach(([id, path]) => {
      document.getElementById(id)?.addEventListener('input', event => {
        setNested(landscape, path, event.target.value);
        applyLandscapeFieldConfig(landscape, getSceneByIdx(sceneIdx)?.landscapeMesh);
        scheduleAutoSave(sceneIdx);
      });
    });
    document.getElementById('land_wireframe')?.addEventListener('change', event => {
      landscape.material ||= {};
      landscape.material.wireframe = event.target.checked;
      applyLandscapeFieldConfig(landscape, getSceneByIdx(sceneIdx)?.landscapeMesh);
      scheduleAutoSave(sceneIdx);
    });

    document.getElementById('land_rebuild')?.addEventListener('click', () => {
      const current = getSceneByIdx(sceneIdx);
      const config = current?.data?.landscapeField;
      if (!current || !config) return;
      saveUndoStateForAction();
      const numeric = (id, fallback) => {
        const value = parseFloat(document.getElementById(id)?.value);
        return Number.isFinite(value) ? value : fallback;
      };
      config.mode = document.getElementById('land_mode')?.value || 'surface';
      config.width = numeric('land_width', 18);
      config.depth = numeric('land_depth', 14);
      config.resolutionX = Math.round(numeric('land_res_x', 150));
      config.resolutionZ = Math.round(numeric('land_res_z', 110));
      config.voxelGap = numeric('land_voxel_gap', 0.12);
      config.tunnel ||= {};
      config.tunnel.radius = numeric('land_tunnel_radius', 7);
      disposeSceneSystemObject(current.landscapeMesh);
      current.landscapeMesh = createLandscapeFieldSystem(THREE, config, current.threeScene);
      populateObjectList('scenes', getCurrentSegment());
      populateLandscapeFieldParams(config, sceneIdx);
      document.querySelector('.obj-item[data-type="landscapeField"]')?.classList.add('selected');
      scheduleAutoSave(sceneIdx);
    });
  }

  // =========================
  // CORAL GROWTH PARAMS (IFS organic branching)
  // =========================

  function populateCoralGrowthParams(coral, sceneIdx) {
    currentEditingSceneIdx = sceneIdx;
    const sceneObj = getSceneByIdx(sceneIdx);
    if (!sceneObj || !coral) return;
    
    const branchUniforms = sceneObj.coralGrowthMesh?.userData?.branchMesh?.material?.uniforms;
    const polypUniforms = sceneObj.coralGrowthMesh?.userData?.polypMesh?.material?.uniforms;
    
    let html = `<div class="param-group"><div class="param-group-title">Structure (requires rebuild)</div>`;
    html += createParamRow('Max Depth', createSlider('coral_max_depth', coral.maxDepth ?? 6, 2, 8, 1), null);
    html += createParamRow('Trunk Length', createSlider('coral_trunk_len', coral.trunkLength ?? 1.5, 0.5, 3, 0.1), null);
    html += createParamRow('Trunk Radius', createSlider('coral_trunk_rad', coral.trunkRadius ?? 0.08, 0.02, 0.2, 0.01), null);
    html += createParamRow('Branch Angle Min', createSlider('coral_angle_min', coral.branchAngleMin ?? 15, 5, 45, 1), null);
    html += createParamRow('Branch Angle Max', createSlider('coral_angle_max', coral.branchAngleMax ?? 45, 20, 80, 1), null);
    html += createParamRow('Branch Length Ratio', createSlider('coral_len_ratio', coral.branchLengthRatio ?? 0.75, 0.5, 0.95, 0.05), null);
    html += createParamRow('Branch Radius Ratio', createSlider('coral_rad_ratio', coral.branchRadiusRatio ?? 0.7, 0.4, 0.9, 0.05), null);
    html += createParamRow('Branch Count Min', createSlider('coral_count_min', coral.branchCountMin ?? 2, 1, 4, 1), null);
    html += createParamRow('Branch Count Max', createSlider('coral_count_max', coral.branchCountMax ?? 4, 2, 6, 1), null);
    html += createParamRow('Base Count', createSlider('coral_base_count', coral.baseCount ?? 3, 1, 6, 1), null);
    html += createParamRow('Randomness', createSlider('coral_randomness', coral.randomness ?? 0.3, 0, 0.8, 0.05), null);
    html += createParamRow('Polyp Size', createSlider('coral_polyp_size', coral.polypSize ?? 0.05, 0.01, 0.15, 0.01), null);
    html += createParamRow('Polyp Density', createSlider('coral_polyp_density', coral.polypDensity ?? 0.7, 0, 1, 0.1), null);
    html += createParamRow('Seed', createSlider('coral_seed', coral.seed ?? 54321, 1, 100000, 1), null);
    html += `<div style="padding-top: 10px;">
      <button id="coral_rebuild" class="rebuild-btn">Rebuild Coral</button>
    </div>`;
    html += `</div>`;
    
    html += `<div class="param-group"><div class="param-group-title">Animation</div>`;
    html += createParamRow('Growth', createSlider('coral_growth', branchUniforms?.uGrowth?.value ?? 1.0, 0, 1, 0.01), 'coral_growth');
    html += createParamRow('Sway Strength', createSlider('coral_sway_str', branchUniforms?.uSwayStrength?.value ?? coral.animation?.swayStrength ?? 0.4, 0, 1.5, 0.05), 'coral_sway_str');
    html += createParamRow('Sway Speed', createSlider('coral_sway_speed', branchUniforms?.uSwaySpeed?.value ?? coral.animation?.swaySpeed ?? 0.5, 0.1, 2, 0.1), 'coral_sway_speed');
    html += createParamRow('Wave Speed', createSlider('coral_wave_speed', branchUniforms?.uWaveSpeed?.value ?? coral.animation?.waveSpeed ?? 0.3, 0.1, 1, 0.05), 'coral_wave_speed');
    html += createParamRow('Polyp Pulse', createSlider('coral_pulse_speed', polypUniforms?.uPulseSpeed?.value ?? coral.animation?.pulseSpeed ?? 2.0, 0.5, 5, 0.5), 'coral_pulse_speed');
    html += `</div>`;
    
    html += `<div class="param-group"><div class="param-group-title">Colors</div>`;
    const baseCol = branchUniforms?.uBaseColor?.value ? '#' + branchUniforms.uBaseColor.value.getHexString() : (coral.colors?.base || '#ff6688');
    const tipCol = branchUniforms?.uTipColor?.value ? '#' + branchUniforms.uTipColor.value.getHexString() : (coral.colors?.tip || '#ffaacc');
    const polypCol = polypUniforms?.uPolypColor?.value ? '#' + polypUniforms.uPolypColor.value.getHexString() : (coral.colors?.polyp || '#ffffff');
    const emissiveCol = branchUniforms?.uEmissiveColor?.value ? '#' + branchUniforms.uEmissiveColor.value.getHexString() : (coral.colors?.emissive || '#330022');
    html += createParamRow('Base', createColorPicker('coral_base_color', baseCol), null);
    html += createParamRow('Tip', createColorPicker('coral_tip_color', tipCol), null);
    html += createParamRow('Polyp', createColorPicker('coral_polyp_color', polypCol), null);
    html += createParamRow('Emissive', createColorPicker('coral_emissive_color', emissiveCol), null);
    html += createParamRow('Emissive Int', createSlider('coral_emissive_int', branchUniforms?.uEmissiveIntensity?.value ?? coral.colors?.emissiveIntensity ?? 0.4, 0, 2, 0.1), 'coral_emissive_int');
    html += `</div>`;
    
    html += `<div class="param-group"><div class="param-group-title">Material</div>`;
    html += createParamRow('Metalness', createSlider('coral_metalness', branchUniforms?.uMetalness?.value ?? coral.material?.metalness ?? 0.2, 0, 1, 0.05), 'coral_metalness');
    html += createParamRow('Roughness', createSlider('coral_roughness', branchUniforms?.uRoughness?.value ?? coral.material?.roughness ?? 0.6, 0, 1, 0.05), 'coral_roughness');
    html += createParamRow('Reflection', createSlider('coral_envmap', branchUniforms?.uEnvMapIntensity?.value ?? coral.material?.envMapIntensity ?? 0.4, 0, 2, 0.05), 'coral_envmap');
    html += createParamRow('Env Map', createTextureSelect('coral_envmap_tex', coral.material?.envMap), null);
    html += `</div>`;
    
    paramContent.innerHTML = html;
    
    ['coral_max_depth', 'coral_trunk_len', 'coral_trunk_rad', 'coral_angle_min', 'coral_angle_max',
     'coral_len_ratio', 'coral_rad_ratio', 'coral_count_min', 'coral_count_max', 'coral_base_count',
     'coral_randomness', 'coral_polyp_size', 'coral_polyp_density', 'coral_seed',
     'coral_growth', 'coral_sway_str', 'coral_sway_speed', 'coral_wave_speed', 'coral_pulse_speed',
     'coral_emissive_int', 'coral_metalness', 'coral_roughness', 'coral_envmap'
    ].forEach(linkSliderToNumber);
    
    addCoralGrowthParamHandlers(sceneIdx);
  }
  
  function addCoralGrowthParamHandlers(sceneIdx) {
    const sceneObj = getSceneByIdx(sceneIdx);
    if (!sceneObj?.coralGrowthMesh) return;
    
    const branchUniforms = sceneObj.coralGrowthMesh.userData?.branchMesh?.material?.uniforms;
    const polypUniforms = sceneObj.coralGrowthMesh.userData?.polypMesh?.material?.uniforms;
    
    function addRangeHandler(inputId, uniformName, targetUniforms) {
      const input = document.getElementById(inputId);
      if (input && targetUniforms?.[uniformName]) {
        input.addEventListener('focus', beginParamEdit);
        input.addEventListener('mousedown', beginParamEdit);
        input.addEventListener('input', () => {
          targetUniforms[uniformName].value = parseFloat(input.value);
          updateSliderThumb(input);
          scheduleAutoSave(sceneIdx);
        });
        input.addEventListener('blur', endParamEdit);
        input.addEventListener('mouseup', () => setTimeout(endParamEdit, 100));
      }
    }
    
    function addColorHandler(inputId, uniformName, targetUniforms) {
      const input = document.getElementById(inputId);
      if (input && targetUniforms?.[uniformName]) {
        input.addEventListener('focus', beginParamEdit);
        input.addEventListener('mousedown', beginParamEdit);
        input.addEventListener('input', () => {
          targetUniforms[uniformName].value.set(input.value);
          scheduleAutoSave(sceneIdx);
        });
        input.addEventListener('change', endParamEdit);
      }
    }
    
    // Branch uniforms
    addRangeHandler('coral_growth', 'uGrowth', branchUniforms);
    addRangeHandler('coral_sway_str', 'uSwayStrength', branchUniforms);
    addRangeHandler('coral_sway_speed', 'uSwaySpeed', branchUniforms);
    addRangeHandler('coral_wave_speed', 'uWaveSpeed', branchUniforms);
    addColorHandler('coral_base_color', 'uBaseColor', branchUniforms);
    addColorHandler('coral_tip_color', 'uTipColor', branchUniforms);
    addColorHandler('coral_emissive_color', 'uEmissiveColor', branchUniforms);
    addRangeHandler('coral_emissive_int', 'uEmissiveIntensity', branchUniforms);
    
    // Polyp uniforms
    addRangeHandler('coral_pulse_speed', 'uPulseSpeed', polypUniforms);
    addColorHandler('coral_polyp_color', 'uPolypColor', polypUniforms);
    
    // Also sync polyp emissive
    const polypEmissiveInput = document.getElementById('coral_emissive_color');
    if (polypEmissiveInput && polypUniforms?.uEmissiveColor) {
      polypEmissiveInput.addEventListener('input', () => {
        polypUniforms.uEmissiveColor.value.set(polypEmissiveInput.value);
      });
    }
    const polypEmissiveIntInput = document.getElementById('coral_emissive_int');
    if (polypEmissiveIntInput && polypUniforms?.uEmissiveIntensity) {
      polypEmissiveIntInput.addEventListener('input', () => {
        polypUniforms.uEmissiveIntensity.value = parseFloat(polypEmissiveIntInput.value);
      });
    }
    
    // Material uniforms
    addRangeHandler('coral_metalness', 'uMetalness', branchUniforms);
    addRangeHandler('coral_roughness', 'uRoughness', branchUniforms);
    addRangeHandler('coral_envmap', 'uEnvMapIntensity', branchUniforms);
    
    // Env map texture picker
    setupIFSEnvMapPicker('coral_envmap_tex', sceneObj, 'coralGrowthMesh', 'branchMesh', sceneIdx);
    
    // Rebuild button
    const rebuildBtn = document.getElementById('coral_rebuild');
    if (rebuildBtn) {
      rebuildBtn.addEventListener('click', () => {
        const currentSceneObj = getSceneByIdx(sceneIdx);
        if (!currentSceneObj) return;
        
        const coral = currentSceneObj.data.coralGrowth;
        if (!coral) return;

        saveUndoStateForAction();
        
        // Update coral config from UI
        coral.maxDepth = parseInt(document.getElementById('coral_max_depth')?.value) || 6;
        coral.trunkLength = parseFloat(document.getElementById('coral_trunk_len')?.value) || 1.5;
        coral.trunkRadius = parseFloat(document.getElementById('coral_trunk_rad')?.value) || 0.08;
        coral.branchAngleMin = parseFloat(document.getElementById('coral_angle_min')?.value) || 15;
        coral.branchAngleMax = parseFloat(document.getElementById('coral_angle_max')?.value) || 45;
        coral.branchLengthRatio = parseFloat(document.getElementById('coral_len_ratio')?.value) || 0.75;
        coral.branchRadiusRatio = parseFloat(document.getElementById('coral_rad_ratio')?.value) || 0.7;
        coral.branchCountMin = parseInt(document.getElementById('coral_count_min')?.value) || 2;
        coral.branchCountMax = parseInt(document.getElementById('coral_count_max')?.value) || 4;
        coral.baseCount = parseInt(document.getElementById('coral_base_count')?.value) || 3;
        coral.randomness = parseFloat(document.getElementById('coral_randomness')?.value) || 0.3;
        coral.polypSize = parseFloat(document.getElementById('coral_polyp_size')?.value) || 0.05;
        coral.polypDensity = parseFloat(document.getElementById('coral_polyp_density')?.value) || 0.7;
        coral.seed = parseInt(document.getElementById('coral_seed')?.value) || 54321;
        
        // Preserve animation values
        const oldBranchUniforms = currentSceneObj.coralGrowthMesh?.userData?.branchMesh?.material?.uniforms;
        const oldPolypUniforms = currentSceneObj.coralGrowthMesh?.userData?.polypMesh?.material?.uniforms;
        
        if (oldBranchUniforms) {
          coral.animation = coral.animation || {};
          coral.animation.swayStrength = oldBranchUniforms.uSwayStrength?.value;
          coral.animation.swaySpeed = oldBranchUniforms.uSwaySpeed?.value;
          coral.animation.waveSpeed = oldBranchUniforms.uWaveSpeed?.value;
          
          coral.colors = coral.colors || {};
          if (oldBranchUniforms.uBaseColor) coral.colors.base = '#' + oldBranchUniforms.uBaseColor.value.getHexString();
          if (oldBranchUniforms.uTipColor) coral.colors.tip = '#' + oldBranchUniforms.uTipColor.value.getHexString();
          if (oldBranchUniforms.uEmissiveColor) coral.colors.emissive = '#' + oldBranchUniforms.uEmissiveColor.value.getHexString();
          if (oldBranchUniforms.uEmissiveIntensity) coral.colors.emissiveIntensity = oldBranchUniforms.uEmissiveIntensity.value;
          
          coral.material = coral.material || {};
          coral.material.metalness = oldBranchUniforms.uMetalness?.value;
          coral.material.roughness = oldBranchUniforms.uRoughness?.value;
          coral.material.envMapIntensity = oldBranchUniforms.uEnvMapIntensity?.value;
        }
        if (oldPolypUniforms) {
          coral.animation = coral.animation || {};
          coral.animation.pulseSpeed = oldPolypUniforms.uPulseSpeed?.value;
          coral.colors = coral.colors || {};
          if (oldPolypUniforms.uPolypColor) coral.colors.polyp = '#' + oldPolypUniforms.uPolypColor.value.getHexString();
        }
        
        // Remove old coral
        if (currentSceneObj.coralGrowthMesh) {
          currentSceneObj.threeScene.remove(currentSceneObj.coralGrowthMesh);
          currentSceneObj.coralGrowthMesh = null;
        }
        
        // Create new coral
        currentSceneObj.coralGrowthMesh = createCoralGrowth(coral, currentSceneObj.threeScene);
        
        // Update object list
        if (selectedSegment) {
          const trackObj = projectData.timeline?.tracks?.find(t => t.id === selectedSegment.track);
          const seg = trackObj?.segments?.find(s => s.id === selectedSegment.id);
          if (seg) populateObjectList(selectedSegment.track, seg);
        }
        
        populateCoralGrowthParams(coral, sceneIdx);
        const item = document.querySelector('.obj-item[data-type="coralGrowth"]');
        if (item) item.classList.add('selected');
        
        scheduleAutoSave(sceneIdx);
        console.log(`Rebuilt coral growth with depth ${coral.maxDepth}`);
      });
    }
  }
  
  // =========================
  // CRYSTAL LATTICE PARAMS (IFS geometric structure)
  // =========================
  
  // Auto-rebuild debounce timer for crystal lattice
  let crystalRebuildTimer = null;
  
  function populateCrystalLatticeParams(crystal, sceneIdx) {
    currentEditingSceneIdx = sceneIdx;
    const sceneObj = getSceneByIdx(sceneIdx);
    if (!sceneObj || !crystal) return;
    
    const mesh = sceneObj.crystalLatticeMesh;
    const uniforms = mesh?.userData?.crystalMesh?.material?.uniforms;
    
    // Get current transform from mesh or config
    const posX = mesh?.position?.x ?? crystal.position?.x ?? 0;
    const posY = mesh?.position?.y ?? crystal.position?.y ?? 0;
    const posZ = mesh?.position?.z ?? crystal.position?.z ?? 0;
    const rotX = mesh ? THREE.MathUtils.radToDeg(mesh.rotation.x) : (crystal.rotation?.x ?? 0);
    const rotY = mesh ? THREE.MathUtils.radToDeg(mesh.rotation.y) : (crystal.rotation?.y ?? 0);
    const rotZ = mesh ? THREE.MathUtils.radToDeg(mesh.rotation.z) : (crystal.rotation?.z ?? 0);
    const scale = mesh?.scale?.x ?? crystal.scale ?? 1.0;
    
    // Transform parameters
    let html = `<div class="param-group"><div class="param-group-title">Transform</div>`;
    html += createParamRow('Position X', createSlider('crystal_pos_x', posX, -20, 20, 0.1), 'crystal_pos_x');
    html += createParamRow('Position Y', createSlider('crystal_pos_y', posY, -20, 20, 0.1), 'crystal_pos_y');
    html += createParamRow('Position Z', createSlider('crystal_pos_z', posZ, -20, 20, 0.1), 'crystal_pos_z');
    html += createParamRow('Rotation X', createSlider('crystal_rot_x', rotX, -180, 180, 1), 'crystal_rot_x');
    html += createParamRow('Rotation Y', createSlider('crystal_rot_y', rotY, -180, 180, 1), 'crystal_rot_y');
    html += createParamRow('Rotation Z', createSlider('crystal_rot_z', rotZ, -180, 180, 1), 'crystal_rot_z');
    html += createParamRow('Scale', createSlider('crystal_scale', scale, 0.1, 5, 0.1), 'crystal_scale');
    html += `</div>`;
    
    html += `<div class="param-group"><div class="param-group-title">Structure (auto-rebuilds)</div>`;
    html += createParamRow('Max Depth', createSlider('crystal_max_depth', crystal.maxDepth ?? 4, 1, 5, 1), null);
    html += createParamRow('Base Size', createSlider('crystal_base_size', crystal.baseSize ?? 1.0, 0.3, 3, 0.1), null);
    html += createParamRow('Scale Falloff', createSlider('crystal_scale_fall', crystal.scaleFalloff ?? 0.5, 0.3, 0.8, 0.05), null);
    html += createParamRow('Connection Radius', createSlider('crystal_conn_rad', crystal.connectionRadius ?? 0.02, 0.005, 0.1, 0.005), null);
    html += createParamRow('Show Connections', `<input type="checkbox" id="crystal_show_conn" ${crystal.showConnections !== false ? 'checked' : ''}>`, null);
    html += `</div>`;
    
    html += `<div class="param-group"><div class="param-group-title">Animation</div>`;
    html += createParamRow('Formation', createSlider('crystal_formation', uniforms?.uFormation?.value ?? crystal.animation?.formationProgress ?? 1.0, 0, 1, 0.01), 'crystal_formation');
    html += createParamRow('Rotation Speed', createSlider('crystal_rot_speed', uniforms?.uRotationSpeed?.value ?? crystal.animation?.rotationSpeed ?? 0.1, 0, 0.5, 0.02), 'crystal_rot_speed');
    html += createParamRow('Shimmer Speed', createSlider('crystal_shimmer', uniforms?.uShimmerSpeed?.value ?? crystal.animation?.shimmerSpeed ?? 2.0, 0.5, 5, 0.5), 'crystal_shimmer');
    html += createParamRow('Pulse Speed', createSlider('crystal_pulse', uniforms?.uPulseSpeed?.value ?? crystal.animation?.pulseSpeed ?? 1.0, 0.2, 3, 0.2), 'crystal_pulse');
    html += `</div>`;
    
    html += `<div class="param-group"><div class="param-group-title">Colors</div>`;
    const crystalCol = uniforms?.uCrystalColor?.value ? '#' + uniforms.uCrystalColor.value.getHexString() : (crystal.colors?.crystal || '#88ccff');
    const coreCol = uniforms?.uCoreColor?.value ? '#' + uniforms.uCoreColor.value.getHexString() : (crystal.colors?.core || '#ffffff');
    const connCol = crystal.colors?.connection || '#4488cc';
    const emissiveCol = uniforms?.uEmissiveColor?.value ? '#' + uniforms.uEmissiveColor.value.getHexString() : (crystal.colors?.emissive || '#002244');
    html += createParamRow('Crystal', createColorPicker('crystal_crystal_color', crystalCol), null);
    html += createParamRow('Core', createColorPicker('crystal_core_color', coreCol), null);
    html += createParamRow('Connection', createColorPicker('crystal_conn_color', connCol), null);
    html += createParamRow('Emissive', createColorPicker('crystal_emissive_color', emissiveCol), null);
    html += createParamRow('Emissive Int', createSlider('crystal_emissive_int', uniforms?.uEmissiveIntensity?.value ?? crystal.colors?.emissiveIntensity ?? 0.6, 0, 2, 0.1), 'crystal_emissive_int');
    html += `</div>`;
    
    // Light parameters
    html += `<div class="param-group"><div class="param-group-title">Lighting</div>`;
    const lightCol = uniforms?.uLightColor?.value ? '#' + uniforms.uLightColor.value.getHexString() : (crystal.light?.color || '#ffffff');
    const ambientCol = uniforms?.uAmbientColor?.value ? '#' + uniforms.uAmbientColor.value.getHexString() : (crystal.light?.ambientColor || '#1a1a3a');
    html += createParamRow('Light Color', createColorPicker('crystal_light_color', lightCol), null);
    html += createParamRow('Light Intensity', createSlider('crystal_light_int', uniforms?.uLightIntensity?.value ?? crystal.light?.intensity ?? 1.0, 0, 3, 0.1), 'crystal_light_int');
    html += createParamRow('Ambient Color', createColorPicker('crystal_ambient_color', ambientCol), null);
    html += createParamRow('Ambient Intensity', createSlider('crystal_ambient_int', uniforms?.uAmbientIntensity?.value ?? crystal.light?.ambientIntensity ?? 0.4, 0, 2, 0.1), 'crystal_ambient_int');
    html += createParamRow('Specular Int', createSlider('crystal_specular_int', uniforms?.uSpecularIntensity?.value ?? crystal.material?.specularIntensity ?? 1.0, 0, 3, 0.1), 'crystal_specular_int');
    html += createParamRow('Use Scene Lights', `<input type="checkbox" id="crystal_use_scene_lights" ${crystal.useSceneLights ? 'checked' : ''}>`, null);
    html += `</div>`;
    
    html += `<div class="param-group"><div class="param-group-title">Material</div>`;
    html += createParamRow('Metalness', createSlider('crystal_metalness', uniforms?.uMetalness?.value ?? crystal.material?.metalness ?? 0.9, 0, 1, 0.05), 'crystal_metalness');
    html += createParamRow('Roughness', createSlider('crystal_roughness', uniforms?.uRoughness?.value ?? crystal.material?.roughness ?? 0.1, 0, 1, 0.05), 'crystal_roughness');
    html += createParamRow('Opacity', createSlider('crystal_opacity', uniforms?.uOpacity?.value ?? crystal.material?.opacity ?? 0.95, 0, 1, 0.05), 'crystal_opacity');
    html += createParamRow('Reflection', createSlider('crystal_envmap', uniforms?.uEnvMapIntensity?.value ?? crystal.material?.envMapIntensity ?? 1.0, 0, 2, 0.05), 'crystal_envmap');
    html += createParamRow('Fresnel Power', createSlider('crystal_fresnel', uniforms?.uFresnelPower?.value ?? crystal.material?.fresnelPower ?? 3.0, 0.5, 10, 0.5), 'crystal_fresnel');
    html += createParamRow('Fresnel Bias', createSlider('crystal_fresnel_bias', uniforms?.uFresnelBias?.value ?? crystal.material?.fresnelBias ?? 0.0, 0, 1, 0.05), 'crystal_fresnel_bias');
    html += createParamRow('Env Map', createTextureSelect('crystal_envmap_tex', crystal.material?.envMap), null);
    html += `</div>`;
    
    // Advanced material options (collapsible)
    html += `<div class="param-group"><div class="param-group-title">Advanced Material</div>`;
    html += createParamRow('Wireframe', `<input type="checkbox" id="crystal_wireframe" ${crystal.material?.wireframe ? 'checked' : ''}>`, null);
    html += createParamRow('Depth Write', `<input type="checkbox" id="crystal_depth_write" ${crystal.material?.depthWrite !== false ? 'checked' : ''}>`, null);
    html += createParamRow('Depth Test', `<input type="checkbox" id="crystal_depth_test" ${crystal.material?.depthTest !== false ? 'checked' : ''}>`, null);
    html += createParamRow('Side', `<select id="crystal_side" class="param-select">
      <option value="double" ${(crystal.material?.side || 'double') === 'double' ? 'selected' : ''}>Double</option>
      <option value="front" ${crystal.material?.side === 'front' ? 'selected' : ''}>Front</option>
      <option value="back" ${crystal.material?.side === 'back' ? 'selected' : ''}>Back</option>
    </select>`, null);
    html += createParamRow('Blending', `<select id="crystal_blending" class="param-select">
      <option value="normal" ${(crystal.material?.blending || 'normal') === 'normal' ? 'selected' : ''}>Normal</option>
      <option value="additive" ${crystal.material?.blending === 'additive' ? 'selected' : ''}>Additive</option>
      <option value="multiply" ${crystal.material?.blending === 'multiply' ? 'selected' : ''}>Multiply</option>
      <option value="subtractive" ${crystal.material?.blending === 'subtractive' ? 'selected' : ''}>Subtractive</option>
    </select>`, null);
    html += `</div>`;
    
    paramContent.innerHTML = html;
    
    ['crystal_pos_x', 'crystal_pos_y', 'crystal_pos_z', 'crystal_rot_x', 'crystal_rot_y', 'crystal_rot_z', 'crystal_scale',
     'crystal_max_depth', 'crystal_base_size', 'crystal_scale_fall', 'crystal_conn_rad',
     'crystal_formation', 'crystal_rot_speed', 'crystal_shimmer', 'crystal_pulse',
     'crystal_emissive_int', 'crystal_light_int', 'crystal_ambient_int', 'crystal_specular_int',
     'crystal_metalness', 'crystal_roughness', 'crystal_opacity', 'crystal_envmap', 'crystal_fresnel', 'crystal_fresnel_bias'
    ].forEach(linkSliderToNumber);
    
    addCrystalLatticeParamHandlers(sceneIdx);
  }
  
  function addCrystalLatticeParamHandlers(sceneIdx) {
    // Helper to get fresh scene object reference
    function getSceneObj() {
      return getSceneByIdx(sceneIdx);
    }
    
    // Helper to get fresh mesh reference
    function getMesh() {
      return getSceneObj()?.crystalLatticeMesh;
    }
    
    // Helper to get fresh uniforms reference
    function getUniforms() {
      return getMesh()?.userData?.crystalMesh?.material?.uniforms;
    }
    
    // Helper to get fresh material reference
    function getMaterial() {
      return getMesh()?.userData?.crystalMesh?.material;
    }
    
    const initialSceneObj = getSceneObj();
    if (!initialSceneObj?.crystalLatticeMesh) {
      console.warn('[CrystalLattice] No crystalLatticeMesh found');
      return;
    }
    
    // Range handler - attaches to slider and updates uniform
    function addRangeHandler(inputId, uniformName) {
      const input = document.getElementById(inputId);
      if (!input) {
        console.warn(`[CrystalLattice] Input not found: ${inputId}`);
        return;
      }
        input.addEventListener('focus', beginParamEdit);
        input.addEventListener('mousedown', beginParamEdit);
        input.addEventListener('input', () => {
        const uniforms = getUniforms();
        if (uniforms?.[uniformName]) {
          uniforms[uniformName].value = parseFloat(input.value);
        } else {
          console.warn(`[CrystalLattice] Uniform not found: ${uniformName}`);
        }
          updateSliderThumb(input);
          scheduleAutoSave(sceneIdx);
        });
        input.addEventListener('blur', endParamEdit);
        input.addEventListener('mouseup', () => setTimeout(endParamEdit, 100));
    }
    
    // Color handler - attaches to color picker and updates uniform
    function addColorHandler(inputId, uniformName) {
      const input = document.getElementById(inputId);
      if (!input) {
        console.warn(`[CrystalLattice] Color input not found: ${inputId}`);
        return;
      }
        input.addEventListener('focus', beginParamEdit);
        input.addEventListener('mousedown', beginParamEdit);
        input.addEventListener('input', () => {
        const uniforms = getUniforms();
        if (uniforms?.[uniformName]?.value) {
          uniforms[uniformName].value.set(input.value);
        } else {
          console.warn(`[CrystalLattice] Color uniform not found: ${uniformName}`);
        }
          scheduleAutoSave(sceneIdx);
        });
        input.addEventListener('change', endParamEdit);
      }
    
    // Transform handlers - directly update mesh transform
    function addTransformHandler(inputId, property, isRotation = false) {
      const input = document.getElementById(inputId);
      if (!input) {
        console.warn(`[CrystalLattice] Transform input not found: ${inputId}`);
        return;
      }
      input.addEventListener('focus', beginParamEdit);
      input.addEventListener('mousedown', beginParamEdit);
      input.addEventListener('input', () => {
        const currentMesh = getMesh();
        if (!currentMesh) return;
        const val = parseFloat(input.value);
        if (property === 'scale') {
          currentMesh.scale.set(val, val, val);
        } else if (isRotation) {
          currentMesh.rotation[property] = val * Math.PI / 180;
        } else {
          currentMesh.position[property] = val;
        }
        updateSliderThumb(input);
        scheduleAutoSave(sceneIdx);
      });
      input.addEventListener('blur', endParamEdit);
      input.addEventListener('mouseup', () => setTimeout(endParamEdit, 100));
    }
    
    // === TRANSFORM HANDLERS ===
    addTransformHandler('crystal_pos_x', 'x', false);
    addTransformHandler('crystal_pos_y', 'y', false);
    addTransformHandler('crystal_pos_z', 'z', false);
    addTransformHandler('crystal_rot_x', 'x', true);
    addTransformHandler('crystal_rot_y', 'y', true);
    addTransformHandler('crystal_rot_z', 'z', true);
    addTransformHandler('crystal_scale', 'scale', false);
    
    // === ANIMATION HANDLERS ===
    addRangeHandler('crystal_formation', 'uFormation');
    addRangeHandler('crystal_rot_speed', 'uRotationSpeed');
    addRangeHandler('crystal_shimmer', 'uShimmerSpeed');
    addRangeHandler('crystal_pulse', 'uPulseSpeed');
    
    // === COLOR HANDLERS ===
    addColorHandler('crystal_crystal_color', 'uCrystalColor');
    addColorHandler('crystal_core_color', 'uCoreColor');
    addColorHandler('crystal_emissive_color', 'uEmissiveColor');
    addRangeHandler('crystal_emissive_int', 'uEmissiveIntensity');
    
    // === LIGHT HANDLERS ===
    addColorHandler('crystal_light_color', 'uLightColor');
    addRangeHandler('crystal_light_int', 'uLightIntensity');
    addColorHandler('crystal_ambient_color', 'uAmbientColor');
    addRangeHandler('crystal_ambient_int', 'uAmbientIntensity');
    addRangeHandler('crystal_specular_int', 'uSpecularIntensity');
    
    // === MATERIAL HANDLERS ===
    addRangeHandler('crystal_metalness', 'uMetalness');
    addRangeHandler('crystal_roughness', 'uRoughness');
    addRangeHandler('crystal_opacity', 'uOpacity');
    addRangeHandler('crystal_envmap', 'uEnvMapIntensity');
    addRangeHandler('crystal_fresnel', 'uFresnelPower');
    addRangeHandler('crystal_fresnel_bias', 'uFresnelBias');
    
    // Env map texture picker
    setupIFSEnvMapPicker('crystal_envmap_tex', initialSceneObj, 'crystalLatticeMesh', 'crystalMesh', sceneIdx);
    
    // === CONNECTION COLOR HANDLER ===
    const connColorInput = document.getElementById('crystal_conn_color');
    if (connColorInput) {
      connColorInput.addEventListener('focus', beginParamEdit);
      connColorInput.addEventListener('mousedown', beginParamEdit);
      connColorInput.addEventListener('input', () => {
        const currentMesh = getMesh();
        const connMesh = currentMesh?.userData?.connMesh;
        if (connMesh?.material) {
          connMesh.material.color.set(connColorInput.value);
        }
        scheduleAutoSave(sceneIdx);
      });
      connColorInput.addEventListener('change', endParamEdit);
    }
    
    // === USE SCENE LIGHTS CHECKBOX ===
    const useSceneLightsCheckbox = document.getElementById('crystal_use_scene_lights');
    if (useSceneLightsCheckbox) {
      useSceneLightsCheckbox.addEventListener('change', () => {
        const sceneObj = getSceneObj();
        const crystal = sceneObj?.data?.crystalLattice;
        if (crystal) {
          crystal.useSceneLights = useSceneLightsCheckbox.checked;
          if (crystal.useSceneLights) {
            syncCrystalLightsFromScene(sceneObj);
          }
          scheduleAutoSave(sceneIdx);
        }
      });
    }
    
    // === ADVANCED MATERIAL HANDLERS ===
    const wireframeCheckbox = document.getElementById('crystal_wireframe');
    if (wireframeCheckbox) {
      wireframeCheckbox.addEventListener('change', () => {
        const mat = getMaterial();
        if (mat) {
          mat.wireframe = wireframeCheckbox.checked;
          scheduleAutoSave(sceneIdx);
        }
      });
    }
    
    const depthWriteCheckbox = document.getElementById('crystal_depth_write');
    if (depthWriteCheckbox) {
      depthWriteCheckbox.addEventListener('change', () => {
        const mat = getMaterial();
        if (mat) {
          mat.depthWrite = depthWriteCheckbox.checked;
          scheduleAutoSave(sceneIdx);
        }
      });
    }
    
    const depthTestCheckbox = document.getElementById('crystal_depth_test');
    if (depthTestCheckbox) {
      depthTestCheckbox.addEventListener('change', () => {
        const mat = getMaterial();
        if (mat) {
          mat.depthTest = depthTestCheckbox.checked;
          scheduleAutoSave(sceneIdx);
        }
      });
    }
    
    const sideSelect = document.getElementById('crystal_side');
    if (sideSelect) {
      sideSelect.addEventListener('change', () => {
        const mat = getMaterial();
        if (mat) {
          const val = sideSelect.value;
          if (val === 'front') mat.side = THREE.FrontSide;
          else if (val === 'back') mat.side = THREE.BackSide;
          else mat.side = THREE.DoubleSide;
          mat.needsUpdate = true;
          scheduleAutoSave(sceneIdx);
        }
      });
    }
    
    const blendingSelect = document.getElementById('crystal_blending');
    if (blendingSelect) {
      blendingSelect.addEventListener('change', () => {
        const mat = getMaterial();
        if (mat) {
          const val = blendingSelect.value;
          if (val === 'additive') mat.blending = THREE.AdditiveBlending;
          else if (val === 'multiply') mat.blending = THREE.MultiplyBlending;
          else if (val === 'subtractive') mat.blending = THREE.SubtractiveBlending;
          else mat.blending = THREE.NormalBlending;
          mat.needsUpdate = true;
          scheduleAutoSave(sceneIdx);
        }
      });
    }
    
    // === AUTO-REBUILD FOR STRUCTURAL PARAMETERS ===
    function scheduleAutoRebuild() {
      if (crystalRebuildTimer) clearTimeout(crystalRebuildTimer);
      crystalRebuildTimer = setTimeout(() => {
        rebuildCrystalLattice(sceneIdx);
      }, 300);
    }
    
    ['crystal_max_depth', 'crystal_base_size', 'crystal_scale_fall', 'crystal_conn_rad'].forEach(inputId => {
      const input = document.getElementById(inputId);
      if (input) {
        input.addEventListener('input', () => {
          updateSliderThumb(input);
          scheduleAutoRebuild();
        });
      }
    });
    
    const showConnCheckbox = document.getElementById('crystal_show_conn');
    if (showConnCheckbox) {
      showConnCheckbox.addEventListener('change', scheduleAutoRebuild);
    }
  }
  
  // Helper function to sync crystal lights from scene lights
  function syncCrystalLightsFromScene(sceneObj) {
    if (!sceneObj?.crystalLatticeMesh) return;
    const uniforms = sceneObj.crystalLatticeMesh.userData?.crystalMesh?.material?.uniforms;
    if (!uniforms) return;
    
    // Find directional/point light in scene
    let foundLight = null;
    let foundAmbient = null;
    sceneObj.threeScene?.traverse(obj => {
      if (obj.isDirectionalLight || obj.isPointLight) {
        if (!foundLight) foundLight = obj;
      }
      if (obj.isAmbientLight) {
        if (!foundAmbient) foundAmbient = obj;
      }
    });
    
    if (foundLight && uniforms.uLightDir && uniforms.uLightIntensity && uniforms.uLightColor) {
      const lightPos = foundLight.position.clone().normalize();
      uniforms.uLightDir.value.copy(lightPos);
      uniforms.uLightIntensity.value = foundLight.intensity;
      uniforms.uLightColor.value.copy(foundLight.color);
    }
    
    if (foundAmbient && uniforms.uAmbientIntensity && uniforms.uAmbientColor) {
      uniforms.uAmbientIntensity.value = foundAmbient.intensity;
      uniforms.uAmbientColor.value.copy(foundAmbient.color);
    }
  }
  
  // Rebuild crystal lattice preserving all current values
  function rebuildCrystalLattice(sceneIdx) {
        const currentSceneObj = getSceneByIdx(sceneIdx);
        if (!currentSceneObj) return;
        
        const crystal = currentSceneObj.data.crystalLattice;
        if (!crystal) return;
    
    // Get current mesh transform
    const oldMesh = currentSceneObj.crystalLatticeMesh;
    const oldPos = oldMesh?.position?.clone();
    const oldRot = oldMesh?.rotation?.clone();
    const oldScale = oldMesh?.scale?.clone();
        
        // Update crystal config from UI
        crystal.maxDepth = parseInt(document.getElementById('crystal_max_depth')?.value) || 4;
        crystal.baseSize = parseFloat(document.getElementById('crystal_base_size')?.value) || 1.0;
        crystal.scaleFalloff = parseFloat(document.getElementById('crystal_scale_fall')?.value) || 0.5;
        crystal.connectionRadius = parseFloat(document.getElementById('crystal_conn_rad')?.value) || 0.02;
        crystal.showConnections = document.getElementById('crystal_show_conn')?.checked !== false;
        
    // Preserve all current uniform values
    const oldUniforms = oldMesh?.userData?.crystalMesh?.material?.uniforms;
    const oldMaterial = oldMesh?.userData?.crystalMesh?.material;
        if (oldUniforms) {
          crystal.animation = crystal.animation || {};
          crystal.animation.formationProgress = oldUniforms.uFormation?.value ?? 1.0;
          crystal.animation.rotationSpeed = oldUniforms.uRotationSpeed?.value;
          crystal.animation.shimmerSpeed = oldUniforms.uShimmerSpeed?.value;
          crystal.animation.pulseSpeed = oldUniforms.uPulseSpeed?.value;
          
          crystal.colors = crystal.colors || {};
          if (oldUniforms.uCrystalColor) crystal.colors.crystal = '#' + oldUniforms.uCrystalColor.value.getHexString();
          if (oldUniforms.uCoreColor) crystal.colors.core = '#' + oldUniforms.uCoreColor.value.getHexString();
          if (oldUniforms.uEmissiveColor) crystal.colors.emissive = '#' + oldUniforms.uEmissiveColor.value.getHexString();
          if (oldUniforms.uEmissiveIntensity) crystal.colors.emissiveIntensity = oldUniforms.uEmissiveIntensity.value;
          
          crystal.material = crystal.material || {};
          if (oldUniforms.uMetalness) crystal.material.metalness = oldUniforms.uMetalness.value;
          if (oldUniforms.uRoughness) crystal.material.roughness = oldUniforms.uRoughness.value;
          if (oldUniforms.uEnvMapIntensity) crystal.material.envMapIntensity = oldUniforms.uEnvMapIntensity.value;
      if (oldUniforms.uOpacity) crystal.material.opacity = oldUniforms.uOpacity.value;
      if (oldUniforms.uFresnelPower) crystal.material.fresnelPower = oldUniforms.uFresnelPower.value;
      if (oldUniforms.uFresnelBias) crystal.material.fresnelBias = oldUniforms.uFresnelBias.value;
      if (oldUniforms.uSpecularIntensity) crystal.material.specularIntensity = oldUniforms.uSpecularIntensity.value;
      
      // Preserve light settings
      crystal.light = crystal.light || {};
      if (oldUniforms.uLightColor) crystal.light.color = '#' + oldUniforms.uLightColor.value.getHexString();
      if (oldUniforms.uLightIntensity) crystal.light.intensity = oldUniforms.uLightIntensity.value;
      if (oldUniforms.uAmbientColor) crystal.light.ambientColor = '#' + oldUniforms.uAmbientColor.value.getHexString();
      if (oldUniforms.uAmbientIntensity) crystal.light.ambientIntensity = oldUniforms.uAmbientIntensity.value;
      if (oldUniforms.uLightDir) {
        crystal.light.direction = {
          x: oldUniforms.uLightDir.value.x,
          y: oldUniforms.uLightDir.value.y,
          z: oldUniforms.uLightDir.value.z
        };
      }
      
      // Preserve advanced material settings
      if (oldMaterial) {
        crystal.material.wireframe = oldMaterial.wireframe;
        crystal.material.depthWrite = oldMaterial.depthWrite;
        crystal.material.depthTest = oldMaterial.depthTest;
        if (oldMaterial.side === THREE.FrontSide) crystal.material.side = 'front';
        else if (oldMaterial.side === THREE.BackSide) crystal.material.side = 'back';
        else crystal.material.side = 'double';
        if (oldMaterial.blending === THREE.AdditiveBlending) crystal.material.blending = 'additive';
        else if (oldMaterial.blending === THREE.MultiplyBlending) crystal.material.blending = 'multiply';
        else if (oldMaterial.blending === THREE.SubtractiveBlending) crystal.material.blending = 'subtractive';
        else crystal.material.blending = 'normal';
      }
        }
        
        // Get connection color from UI
        const connCol = document.getElementById('crystal_conn_color')?.value;
        if (connCol) {
          crystal.colors = crystal.colors || {};
          crystal.colors.connection = connCol;
        }
        
        // Remove old crystal
        if (currentSceneObj.crystalLatticeMesh) {
          currentSceneObj.threeScene.remove(currentSceneObj.crystalLatticeMesh);
          currentSceneObj.crystalLatticeMesh = null;
        }
        
        // Create new crystal
        currentSceneObj.crystalLatticeMesh = createCrystalLattice(crystal, currentSceneObj.threeScene);
        
    // Restore transform
    if (oldPos) currentSceneObj.crystalLatticeMesh.position.copy(oldPos);
    if (oldRot) currentSceneObj.crystalLatticeMesh.rotation.copy(oldRot);
    if (oldScale) currentSceneObj.crystalLatticeMesh.scale.copy(oldScale);
    
    // Re-add handlers (don't repopulate full UI to avoid losing focus)
    const newUniforms = currentSceneObj.crystalLatticeMesh?.userData?.crystalMesh?.material?.uniforms;
    if (newUniforms) {
      // Just update internal handlers, UI sliders stay as-is
    }
        
        scheduleAutoSave(sceneIdx);
    console.log(`Auto-rebuilt crystal lattice with depth ${crystal.maxDepth}`);
  }

  // =========================
  // GALACTIC 3D PERSPECTIVE CRAWL
  // =========================
  let starCrawlRedrawTimer = null;

  function populateStarCrawlParams(crawl, sceneIdx) {
    currentEditingSceneIdx = sceneIdx;
    const sceneObj = getSceneByIdx(sceneIdx);
    if (!sceneObj?.starCrawlMesh || !crawl) return;
    const typography = crawl.typography ||= {};
    const appearance = crawl.appearance ||= {};
    const placement = crawl.placement ||= {};
    const position = placement.position ||= {};
    const animation = crawl.animation ||= {};
    const texture = crawl.texture ||= {};
    const normalizedTexture = sceneObj.starCrawlMesh.userData?.normalized?.texture || {
      width: texture.width ?? 1024,
      height: texture.height ?? 2048,
      renderScale: texture.renderScale ?? 2,
    };
    const stars = crawl.stars ||= {};
    const selectedCrawlFont = ['Arial Black', 'Archivo Black'].includes(typography.fontFamily)
      ? 'Archivo Black'
      : (typography.fontFamily || 'Archivo Black');
    const fontOptions = [
      ['Arial', 'Arial / Helvetica'],
      ['Archivo Black', 'Archivo Black (bundled)'],
      ['Trebuchet MS', 'Trebuchet'],
      ['Georgia', 'Georgia'],
      ['Impact', 'Impact'],
      ['sans-serif', 'System Sans'],
      ['serif', 'System Serif'],
      ['monospace', 'Monospace'],
    ].map(([value, label]) =>
      `<option value="${value}" ${selectedCrawlFont === value ? 'selected' : ''}>${label}</option>`
    ).join('');

    let html = `<div class="param-group"><div class="param-group-title">Crawl Text</div>`;
    html += `
      <textarea id="crawl_text" rows="11" spellcheck="true"
        style="box-sizing:border-box;width:100%;resize:vertical;min-height:150px;padding:9px 10px;border:1px solid var(--border);border-radius:5px;background:var(--panel-2);color:var(--text);font:11px/1.45 var(--mono);">${escapeHtml(crawl.text || '')}</textarea>
      <div style="margin-top:7px;color:var(--muted);font-size:10px;line-height:1.4;">
        Blank lines create paragraph spacing. Use &lt;h1&gt;Title&lt;/h1&gt; for the title colour.
      </div>
    </div>`;

    html += `<div class="param-group"><div class="param-group-title">Typography & Layout</div>`;
    html += createParamRow('Font', `<select id="crawl_font" class="param-select">${fontOptions}</select>`, null);
    html += createParamRow('Weight', `<select id="crawl_weight" class="param-select">
      ${[300, 400, 500, 600, 700, 800, 900].map(weight =>
        `<option value="${weight}" ${(typography.fontWeight ?? 700) === weight ? 'selected' : ''}>${weight}</option>`
      ).join('')}
    </select>`, null);
    html += createParamRow('Alignment', `<select id="crawl_align" class="param-select">
      ${['left', 'center', 'right'].map(align =>
        `<option value="${align}" ${(typography.align || 'center') === align ? 'selected' : ''}>${align[0].toUpperCase() + align.slice(1)}</option>`
      ).join('')}
    </select>`, null);
    html += createParamRow('Uppercase', `<input type="checkbox" id="crawl_uppercase" ${typography.uppercase ? 'checked' : ''}>`, null);
    html += createParamRow('Font Size', createSlider('crawl_font_size', typography.fontSize ?? 76, 18, 220, 1), 'crawl_font_size');
    html += createParamRow('Paragraph Width', createSlider('crawl_text_width', typography.textWidth ?? 0.76, 0.2, 2, 0.01), 'crawl_text_width');
    html += createParamRow('Line Height', createSlider('crawl_line_height', typography.lineHeight ?? 1.22, 0.8, 2.5, 0.01), 'crawl_line_height');
    html += createParamRow('Letter Spacing', createSlider('crawl_letter_spacing', typography.letterSpacing ?? 1.5, -3, 20, 0.1), 'crawl_letter_spacing');
    html += createParamRow('Paragraph Gap', createSlider('crawl_paragraph_gap', typography.paragraphSpacing ?? 0.72, 0, 4, 0.05), 'crawl_paragraph_gap');
    html += `<div style="margin:-2px 0 8px;color:var(--muted);font-size:9px;line-height:1.35;">Values above 1 widen the text column and perspective plane together.</div></div>`;

    html += `<div class="param-group"><div class="param-group-title">Motion</div>`;
    html += createParamRow('Speed', createSlider('crawl_speed', animation.speed ?? 0.12, 0, 0.4, 0.001), 'crawl_speed');
    html += createParamRow('Direction', `<select id="crawl_direction" class="param-select">
      <option value="1" ${(animation.direction ?? 1) >= 0 ? 'selected' : ''}>Toward horizon</option>
      <option value="-1" ${(animation.direction ?? 1) < 0 ? 'selected' : ''}>Toward viewer</option>
    </select>`, null);
    html += createParamRow('Start Offset', createSlider('crawl_start_offset', animation.startOffset ?? -0.5, -2, 2, 0.01), 'crawl_start_offset');
    html += createParamRow('Loop Within Clip', `<input type="checkbox" id="crawl_loop" ${animation.loop ? 'checked' : ''} title="Repeat the crawl only while this timeline clip is active">`, null);
    html += `</div>`;

    html += `<div class="param-group"><div class="param-group-title">Perspective & Placement</div>`;
    html += createParamRow('Offset X', createSlider('crawl_pos_x', position.x ?? 0, -30, 30, 0.1), 'crawl_pos_x');
    html += createParamRow('Offset Y', createSlider('crawl_pos_y', position.y ?? -1.2, -30, 30, 0.1), 'crawl_pos_y');
    html += createParamRow('Depth Z', createSlider('crawl_pos_z', position.z ?? -7, -60, 20, 0.1), 'crawl_pos_z');
    html += createParamRow('Crawl Width', createSlider('crawl_width', placement.width ?? 13, 1, 40, 0.1), 'crawl_width');
    html += createParamRow('Crawl Length', createSlider('crawl_length', placement.length ?? 30, 3, 100, 0.5), 'crawl_length');
    html += createParamRow('Perspective Tilt', createSlider('crawl_tilt', placement.tilt ?? 67, 0, 89, 0.1), 'crawl_tilt');
    html += createParamRow('Roll', createSlider('crawl_roll', placement.rotationZ ?? 0, -45, 45, 0.1), 'crawl_roll');
    html += `</div>`;

    html += `<div class="param-group"><div class="param-group-title">Color, Glow & Fades</div>`;
    html += createParamRow('Text Color', createColorPicker('crawl_color', appearance.color || '#ffd85a'), null);
    html += createParamRow('Title Color', createColorPicker('crawl_title_color', appearance.titleColor || '#ff70d8'), null);
    html += createParamRow('Glow Color', createColorPicker('crawl_glow_color', appearance.glowColor || '#ffb21c'), null);
    html += createParamRow('Glow Strength', createSlider('crawl_glow_blur', appearance.glowBlur ?? 14, 0, 80, 1), 'crawl_glow_blur');
    html += createParamRow('Outline Color', createColorPicker('crawl_outline_color', appearance.outlineColor || '#6b2a00'), null);
    html += createParamRow('Outline', createSlider('crawl_outline_width', appearance.outlineWidth ?? 1.5, 0, 12, 0.1), 'crawl_outline_width');
    html += createParamRow('Opacity', createSlider('crawl_opacity', appearance.opacity ?? 1, 0, 1, 0.01), 'crawl_opacity');
    html += createParamRow('Horizon Fade', createSlider('crawl_horizon_fade', appearance.horizonFade ?? 0.2, 0, 0.8, 0.01), 'crawl_horizon_fade');
    html += createParamRow('Near Fade', createSlider('crawl_bottom_fade', appearance.bottomFade ?? 0.08, 0, 0.8, 0.01), 'crawl_bottom_fade');
    html += createParamRow('Texture Detail', `<select id="crawl_texture_quality" class="param-select">
      <option value="1" ${normalizedTexture.renderScale < 1.5 ? 'selected' : ''}>Standard</option>
      <option value="2" ${normalizedTexture.renderScale >= 1.5 ? 'selected' : ''}>High Definition (2×)</option>
    </select>`, null);
    html += `</div>`;

    html += `<div class="param-group rebuild-required-group"><div class="param-group-title">Starfield</div>`;
    html += createParamRow('Visible', `<input type="checkbox" id="crawl_stars_enabled" ${stars.enabled !== false ? 'checked' : ''}>`, null);
    html += createParamRow('Star Count', `<input type="number" id="crawl_star_count" value="${stars.count ?? 1400}" min="0" max="10000" step="100" class="rebuild-input">`, null);
    html += createParamRow('Random Seed', `<input type="number" id="crawl_star_seed" value="${stars.seed ?? 1977}" min="1" max="999999" step="1" class="rebuild-input">`, null);
    html += createParamRow('Spread', `<input type="number" id="crawl_star_spread" value="${stars.spread ?? 90}" min="1" max="300" step="1" class="rebuild-input">`, null);
    html += createParamRow('Depth', `<input type="number" id="crawl_star_depth" value="${stars.depth ?? 90}" min="1" max="300" step="1" class="rebuild-input">`, null);
    html += createParamRow('Star Color', createColorPicker('crawl_star_color', stars.color || '#dce9ff'), null);
    html += createParamRow('Accent', createColorPicker('crawl_star_accent', stars.accentColor || '#9bbcff'), null);
    html += createParamRow('Size', createSlider('crawl_star_size', stars.size ?? 2.1, 0.1, 12, 0.1), 'crawl_star_size');
    html += createParamRow('Opacity', createSlider('crawl_star_opacity', stars.opacity ?? 0.9, 0, 1, 0.01), 'crawl_star_opacity');
    html += createParamRow('Twinkle', createSlider('crawl_star_twinkle', stars.twinkle ?? 0.34, 0, 1, 0.01), 'crawl_star_twinkle');
    html += createParamRow('Drift', createSlider('crawl_star_drift', stars.drift ?? 0.008, -0.2, 0.2, 0.001), 'crawl_star_drift');
    html += `<button id="crawl_rebuild_stars" class="rebuild-btn">Rebuild Starfield</button></div>`;

    paramContent.innerHTML = html;
    const sliders = [
      'crawl_font_size', 'crawl_text_width', 'crawl_line_height', 'crawl_letter_spacing',
      'crawl_paragraph_gap', 'crawl_speed', 'crawl_start_offset', 'crawl_pos_x', 'crawl_pos_y',
      'crawl_pos_z', 'crawl_width', 'crawl_length', 'crawl_tilt', 'crawl_roll',
      'crawl_glow_blur', 'crawl_outline_width', 'crawl_opacity', 'crawl_horizon_fade',
      'crawl_bottom_fade', 'crawl_star_size', 'crawl_star_opacity', 'crawl_star_twinkle',
      'crawl_star_drift',
    ];
    sliders.forEach(linkSliderToNumber);

    const setNested = (target, path, value) => {
      let cursor = target;
      for (let index = 0; index < path.length - 1; index++) cursor = cursor[path[index]] ||= {};
      cursor[path[path.length - 1]] = value;
    };
    const refreshRuntime = redrawText => {
      const current = getSceneByIdx(sceneIdx);
      const config = current?.data?.starCrawl;
      if (!current?.starCrawlMesh || !config) return;
      applyStarCrawlConfig(THREE, config, current.starCrawlMesh, { redrawText });
      scheduleAutoSave(sceneIdx);
    };
    const scheduleTextRedraw = () => {
      clearTimeout(starCrawlRedrawTimer);
      starCrawlRedrawTimer = setTimeout(() => refreshRuntime(true), 90);
    };
    const bindNumeric = (id, path, { redrawText = false, integer = false } = {}) => {
      const input = document.getElementById(id);
      if (!input) return;
      input.addEventListener('focus', beginParamEdit);
      input.addEventListener('mousedown', beginParamEdit);
      input.addEventListener('input', () => {
        const current = getSceneByIdx(sceneIdx);
        const config = current?.data?.starCrawl;
        const value = parseFloat(input.value);
        if (!config || !Number.isFinite(value)) return;
        setNested(config, path, integer ? Math.round(value) : value);
        updateSliderThumb(input);
        refreshRuntime(false);
        if (redrawText) scheduleTextRedraw();
      });
      input.addEventListener('blur', endParamEdit);
      input.addEventListener('mouseup', () => setTimeout(endParamEdit, 100));
    };

    const numericBindings = {
      crawl_font_size: [['typography', 'fontSize'], true],
      crawl_text_width: [['typography', 'textWidth'], true],
      crawl_line_height: [['typography', 'lineHeight'], true],
      crawl_letter_spacing: [['typography', 'letterSpacing'], true],
      crawl_paragraph_gap: [['typography', 'paragraphSpacing'], true],
      crawl_speed: [['animation', 'speed'], false],
      crawl_start_offset: [['animation', 'startOffset'], false],
      crawl_pos_x: [['placement', 'position', 'x'], false],
      crawl_pos_y: [['placement', 'position', 'y'], false],
      crawl_pos_z: [['placement', 'position', 'z'], false],
      crawl_width: [['placement', 'width'], false],
      crawl_length: [['placement', 'length'], false],
      crawl_tilt: [['placement', 'tilt'], false],
      crawl_roll: [['placement', 'rotationZ'], false],
      crawl_glow_blur: [['appearance', 'glowBlur'], true],
      crawl_outline_width: [['appearance', 'outlineWidth'], true],
      crawl_opacity: [['appearance', 'opacity'], false],
      crawl_horizon_fade: [['appearance', 'horizonFade'], false],
      crawl_bottom_fade: [['appearance', 'bottomFade'], false],
      crawl_star_size: [['stars', 'size'], false],
      crawl_star_opacity: [['stars', 'opacity'], false],
      crawl_star_twinkle: [['stars', 'twinkle'], false],
      crawl_star_drift: [['stars', 'drift'], false],
    };
    Object.entries(numericBindings).forEach(([id, [path, redrawText]]) =>
      bindNumeric(id, path, { redrawText })
    );

    const textInput = document.getElementById('crawl_text');
    textInput?.addEventListener('focus', beginParamEdit);
    textInput?.addEventListener('input', () => {
      const config = getSceneByIdx(sceneIdx)?.data?.starCrawl;
      if (!config) return;
      config.text = textInput.value;
      scheduleTextRedraw();
      scheduleAutoSave(sceneIdx);
    });
    textInput?.addEventListener('blur', () => {
      refreshRuntime(true);
      endParamEdit();
    });

    const bindSelect = (id, path, transform = value => value, redrawText = false) => {
      document.getElementById(id)?.addEventListener('change', event => {
        const config = getSceneByIdx(sceneIdx)?.data?.starCrawl;
        if (!config) return;
        beginParamEdit();
        setNested(config, path, transform(event.target.value));
        refreshRuntime(redrawText);
        endParamEdit();
      });
    };
    bindSelect('crawl_font', ['typography', 'fontFamily'], value => value, true);
    bindSelect('crawl_weight', ['typography', 'fontWeight'], Number, true);
    bindSelect('crawl_align', ['typography', 'align'], value => value, true);
    bindSelect('crawl_direction', ['animation', 'direction'], Number, false);
    document.getElementById('crawl_texture_quality')?.addEventListener('change', event => {
      const config = getSceneByIdx(sceneIdx)?.data?.starCrawl;
      if (!config) return;
      beginParamEdit();
      config.texture ||= {};
      const currentTexture = getSceneByIdx(sceneIdx)?.starCrawlMesh?.userData?.normalized?.texture;
      config.texture.width = currentTexture?.width ?? 1024;
      config.texture.height = currentTexture?.height ?? 2048;
      config.texture.renderScale = Number(event.target.value);
      refreshRuntime(true);
      endParamEdit();
    });

    const bindCheckbox = (id, path, redrawText = false) => {
      document.getElementById(id)?.addEventListener('change', event => {
        const config = getSceneByIdx(sceneIdx)?.data?.starCrawl;
        if (!config) return;
        beginParamEdit();
        setNested(config, path, event.target.checked);
        refreshRuntime(redrawText);
        endParamEdit();
      });
    };
    bindCheckbox('crawl_uppercase', ['typography', 'uppercase'], true);
    bindCheckbox('crawl_loop', ['animation', 'loop'], false);
    bindCheckbox('crawl_stars_enabled', ['stars', 'enabled'], false);

    [
      ['crawl_color', ['appearance', 'color'], true],
      ['crawl_title_color', ['appearance', 'titleColor'], true],
      ['crawl_glow_color', ['appearance', 'glowColor'], true],
      ['crawl_outline_color', ['appearance', 'outlineColor'], true],
      ['crawl_star_color', ['stars', 'color'], false],
      ['crawl_star_accent', ['stars', 'accentColor'], false],
    ].forEach(([id, path, redrawText]) => {
      const input = document.getElementById(id);
      input?.addEventListener('focus', beginParamEdit);
      input?.addEventListener('mousedown', beginParamEdit);
      input?.addEventListener('input', () => {
        const config = getSceneByIdx(sceneIdx)?.data?.starCrawl;
        if (!config) return;
        setNested(config, path, input.value);
        refreshRuntime(false);
        if (redrawText) scheduleTextRedraw();
      });
      input?.addEventListener('change', endParamEdit);
    });

    document.getElementById('crawl_rebuild_stars')?.addEventListener('click', () => {
      const current = getSceneByIdx(sceneIdx);
      const config = current?.data?.starCrawl;
      if (!current || !config) return;
      saveUndoStateForAction();
      const readNumber = (id, fallback) => {
        const value = parseFloat(document.getElementById(id)?.value);
        return Number.isFinite(value) ? value : fallback;
      };
      config.stars ||= {};
      config.stars.count = Math.round(readNumber('crawl_star_count', 1400));
      config.stars.seed = Math.round(readNumber('crawl_star_seed', 1977));
      config.stars.spread = readNumber('crawl_star_spread', 90);
      config.stars.depth = readNumber('crawl_star_depth', 90);
      disposeSceneSystemObject(current.starCrawlMesh);
      current.starCrawlMesh = createStarCrawlSystem(THREE, config, current.threeScene, {
        documentTarget: document,
      });
      populateObjectList('scenes', getCurrentSegment());
      populateStarCrawlParams(config, sceneIdx);
      document.querySelector('.obj-item[data-type="starCrawl"]')?.classList.add('selected');
      scheduleAutoSave(sceneIdx);
    });
  }

  // =========================
  // GPU FIREWORKS DISPLAY
  // =========================
  function populateFireworksDisplayParams(fireworks, sceneIdx) {
    currentEditingSceneIdx = sceneIdx;
    const sceneObj = getSceneByIdx(sceneIdx);
    if (!sceneObj?.fireworksDisplayMesh || !fireworks) return;
    const area = fireworks.area ||= {};
    const timing = fireworks.timing ||= {};
    const motion = fireworks.motion ||= {};
    const appearance = fireworks.appearance ||= {};
    const launch = fireworks.launch ||= {};
    const patterns = Array.isArray(fireworks.patterns)
      ? fireworks.patterns.join(', ')
      : (fireworks.patterns || 'chrysanthemum, peony');
    const palette = Array.isArray(appearance.palette) && appearance.palette.length
      ? appearance.palette
      : ['#fff7c4', '#ffd447', '#ff7a18', '#ff315f'];

    let html = `<div class="param-group rebuild-required-group"><div class="param-group-title">Shell Choreography</div>`;
    html += createParamRow('Shells', `<input type="number" id="fw_shells" value="${fireworks.shells ?? 12}" min="1" max="80" step="1" class="rebuild-input">`, null);
    html += createParamRow('Sparks / Shell', `<input type="number" id="fw_sparks" value="${fireworks.sparksPerShell ?? 120}" min="8" max="600" step="8" class="rebuild-input">`, null);
    html += createParamRow('Trail Samples', `<input type="number" id="fw_trail_steps" value="${fireworks.trailSteps ?? 6}" min="1" max="16" step="1" class="rebuild-input">`, null);
    html += createParamRow('Launch Samples', `<input type="number" id="fw_launch_points" value="${fireworks.launchTrailPoints ?? 18}" min="2" max="48" step="1" class="rebuild-input">`, null);
    html += createParamRow('Random Seed', `<input type="number" id="fw_seed" value="${fireworks.seed ?? 2026}" min="1" max="4294967295" step="1" class="rebuild-input">`, null);
    html += createParamRow('Patterns', `<input type="text" id="fw_patterns" value="${escapeHtml(patterns)}" class="rebuild-input" title="chrysanthemum, peony, ring, willow, palm, heart, spiral">`, null);
    html += `<div style="margin:-2px 0 8px;color:var(--muted);font-size:9px;line-height:1.35;">Patterns: chrysanthemum, peony, ring, willow, palm, heart, spiral.</div>`;
    html += createParamRow('Start Delay', `<input type="number" id="fw_start_delay" value="${timing.startDelay ?? 1.15}" min="0" max="30" step="0.05" class="rebuild-input">`, null);
    html += createParamRow('Cadence', `<input type="number" id="fw_cadence" value="${timing.cadence ?? 0.72}" min="0.05" max="10" step="0.01" class="rebuild-input">`, null);
    html += createParamRow('Volley Size', `<input type="number" id="fw_volley_size" value="${timing.volleySize ?? 2}" min="1" max="16" step="1" class="rebuild-input">`, null);
    html += createParamRow('Volley Spread', `<input type="number" id="fw_intra_volley" value="${timing.intraVolley ?? 0.12}" min="0" max="3" step="0.01" class="rebuild-input">`, null);
    html += createParamRow('Timing Jitter', `<input type="number" id="fw_jitter" value="${timing.jitter ?? 0.12}" min="0" max="2" step="0.01" class="rebuild-input">`, null);
    html += `</div>`;

    html += `<div class="param-group rebuild-required-group"><div class="param-group-title">Display Volume</div>`;
    html += createParamRow('Width', `<input type="number" id="fw_area_width" value="${area.width ?? 18}" min="0.5" max="100" step="0.5" class="rebuild-input">`, null);
    html += createParamRow('Minimum Height', `<input type="number" id="fw_min_height" value="${area.minHeight ?? 3}" min="-30" max="50" step="0.25" class="rebuild-input">`, null);
    html += createParamRow('Maximum Height', `<input type="number" id="fw_max_height" value="${area.maxHeight ?? 10}" min="-30" max="50" step="0.25" class="rebuild-input">`, null);
    html += createParamRow('Depth', `<input type="number" id="fw_area_depth" value="${area.depth ?? 4}" min="0" max="50" step="0.25" class="rebuild-input">`, null);
    html += createParamRow('Ground Y', `<input type="number" id="fw_ground_y" value="${area.groundY ?? -7}" min="-40" max="10" step="0.25" class="rebuild-input">`, null);
    html += createParamRow('Burst Speed Min', `<input type="number" id="fw_speed_min" value="${motion.speedMin ?? 4.8}" min="0.1" max="30" step="0.1" class="rebuild-input">`, null);
    html += createParamRow('Burst Speed Max', `<input type="number" id="fw_speed_max" value="${motion.speedMax ?? 7.8}" min="0.1" max="30" step="0.1" class="rebuild-input">`, null);
    html += createParamRow('Direction Scatter', `<input type="number" id="fw_spread" value="${motion.spread ?? 0.1}" min="0" max="1" step="0.01" class="rebuild-input">`, null);
    html += createParamRow('Shell Lifetime', `<input type="number" id="fw_lifetime" value="${timing.lifetime ?? 3.1}" min="0.2" max="15" step="0.1" class="rebuild-input">`, null);
    html += createParamRow('Lifetime Variation', `<input type="number" id="fw_lifetime_var" value="${timing.lifetimeVariation ?? 0.22}" min="0" max="1" step="0.01" class="rebuild-input">`, null);
    html += `</div>`;

    html += `<div class="param-group"><div class="param-group-title">Timeline & Placement</div>`;
    html += createParamRow('Offset X', createSlider('fw_offset_x', fireworks.offsetX ?? 0, -30, 30, 0.1), null);
    html += createParamRow('Offset Y', createSlider('fw_offset_y', fireworks.offsetY ?? 0, -30, 30, 0.1), null);
    html += createParamRow('Offset Z', createSlider('fw_offset_z', fireworks.offsetZ ?? 0, -30, 30, 0.1), null);
    html += createParamRow('Display Duration', createSlider('fw_duration', fireworks.duration ?? 10, 1, 30, 0.1), null);
    html += createParamRow('Playback Speed', createSlider('fw_time_scale', fireworks.timeScale ?? 1, 0, 3, 0.01), null);
    html += createParamRow('Time Offset', createSlider('fw_time_offset', fireworks.timeOffset ?? 0, -10, 10, 0.01), null);
    html += createParamRow('Loop Within Clip', `<input type="checkbox" id="fw_loop" ${fireworks.loop !== false ? 'checked' : ''}>`, null);
    html += `</div>`;

    html += `<div class="param-group"><div class="param-group-title">Ballistics & Wind</div>`;
    html += createParamRow('Gravity', createSlider('fw_gravity', motion.gravity ?? -2.25, -10, 5, 0.01), null);
    html += createParamRow('Air Drag', createSlider('fw_drag', motion.drag ?? 0.13, 0, 1, 0.001), null);
    html += createParamRow('Wind X', createSlider('fw_wind_x', motion.windX ?? 0.08, -5, 5, 0.01), null);
    html += createParamRow('Wind Z', createSlider('fw_wind_z', motion.windZ ?? 0, -5, 5, 0.01), null);
    html += `</div>`;

    html += `<div class="param-group"><div class="param-group-title">Spark Material</div>`;
    html += createParamRow('Particle Size', createSlider('fw_particle_size', appearance.particleSize ?? 1.25, 0.05, 6, 0.01), null);
    html += createParamRow('Brightness', createSlider('fw_brightness', appearance.brightness ?? 1.35, 0, 5, 0.01), null);
    html += createParamRow('Opacity', createSlider('fw_opacity', appearance.opacity ?? 1, 0, 1, 0.01), null);
    html += createParamRow('Trail Spacing', `<input type="number" id="fw_trail_spacing" value="${appearance.trailSpacing ?? 0.045}" min="0.005" max="0.3" step="0.005" class="rebuild-input">`, null);
    html += createParamRow('Trail Fade', createSlider('fw_trail_fade', appearance.trailFade ?? 4.2, 0, 20, 0.1), null);
    html += createParamRow('Hot Core', createSlider('fw_core', appearance.coreStrength ?? 0.75, 0, 3, 0.01), null);
    html += createParamRow('Twinkle', createSlider('fw_twinkle', appearance.twinkle ?? 0.2, 0, 1, 0.01), null);
    html += createParamRow('Ember Color', createColorPicker('fw_end_color', appearance.endColor || '#ff7a18'), null);
    for (let index = 0; index < 4; index++) {
      html += createParamRow(
        `Palette ${index + 1}`,
        createColorPicker(`fw_palette_${index}`, palette[index % palette.length]),
        null,
      );
    }
    html += `</div>`;

    html += `<div class="param-group"><div class="param-group-title">Launch Comets</div>`;
    html += createParamRow('Visible', `<input type="checkbox" id="fw_launch_enabled" ${launch.enabled !== false ? 'checked' : ''}>`, null);
    html += createParamRow('Color', createColorPicker('fw_launch_color', launch.color || '#fff4cf'), null);
    html += createParamRow('Size', createSlider('fw_launch_size', launch.size ?? 1.25, 0.05, 6, 0.01), null);
    html += createParamRow('Trail Length', createSlider('fw_launch_window', launch.trailWindow ?? 0.3, 0.02, 1, 0.01), null);
    html += createParamRow('Launch Arc', createSlider('fw_launch_arc', launch.arc ?? 0.45, -5, 5, 0.01), null);
    html += createParamRow('Launch Duration', `<input type="number" id="fw_launch_duration" value="${timing.launchDuration ?? 1.05}" min="0.05" max="8" step="0.05" class="rebuild-input">`, null);
    html += `<button id="fw_rebuild" class="rebuild-btn">Rebuild Fireworks Display</button></div>`;

    paramContent.innerHTML = html;
    [
      'fw_offset_x', 'fw_offset_y', 'fw_offset_z', 'fw_duration', 'fw_time_scale',
      'fw_time_offset', 'fw_gravity', 'fw_drag', 'fw_wind_x', 'fw_wind_z',
      'fw_particle_size', 'fw_brightness', 'fw_opacity', 'fw_trail_fade',
      'fw_core', 'fw_twinkle', 'fw_launch_size', 'fw_launch_window', 'fw_launch_arc',
    ].forEach(linkSliderToNumber);

    const setNested = (target, path, value) => {
      let cursor = target;
      for (let index = 0; index < path.length - 1; index++) cursor = cursor[path[index]] ||= {};
      cursor[path[path.length - 1]] = value;
    };
    const refresh = () => {
      const current = getSceneByIdx(sceneIdx);
      if (!current?.data?.fireworksDisplay || !current.fireworksDisplayMesh) return;
      applyFireworksConfig(THREE, current.data.fireworksDisplay, current.fireworksDisplayMesh);
      scheduleAutoSave(sceneIdx);
    };
    const liveBindings = {
      fw_offset_x: ['offsetX'],
      fw_offset_y: ['offsetY'],
      fw_offset_z: ['offsetZ'],
      fw_duration: ['duration'],
      fw_time_scale: ['timeScale'],
      fw_time_offset: ['timeOffset'],
      fw_gravity: ['motion', 'gravity'],
      fw_drag: ['motion', 'drag'],
      fw_wind_x: ['motion', 'windX'],
      fw_wind_z: ['motion', 'windZ'],
      fw_particle_size: ['appearance', 'particleSize'],
      fw_brightness: ['appearance', 'brightness'],
      fw_opacity: ['appearance', 'opacity'],
      fw_trail_fade: ['appearance', 'trailFade'],
      fw_core: ['appearance', 'coreStrength'],
      fw_twinkle: ['appearance', 'twinkle'],
      fw_launch_size: ['launch', 'size'],
      fw_launch_window: ['launch', 'trailWindow'],
      fw_launch_arc: ['launch', 'arc'],
    };
    Object.entries(liveBindings).forEach(([id, path]) => {
      const input = document.getElementById(id);
      input?.addEventListener('focus', beginParamEdit);
      input?.addEventListener('mousedown', beginParamEdit);
      input?.addEventListener('input', () => {
        const config = getSceneByIdx(sceneIdx)?.data?.fireworksDisplay;
        const value = parseFloat(input.value);
        if (!config || !Number.isFinite(value)) return;
        setNested(config, path, value);
        updateSliderThumb(input);
        refresh();
      });
      input?.addEventListener('blur', endParamEdit);
      input?.addEventListener('mouseup', () => setTimeout(endParamEdit, 100));
    });
    document.getElementById('fw_loop')?.addEventListener('change', event => {
      const config = getSceneByIdx(sceneIdx)?.data?.fireworksDisplay;
      if (!config) return;
      config.loop = event.target.checked;
      refresh();
    });
    document.getElementById('fw_end_color')?.addEventListener('input', event => {
      const config = getSceneByIdx(sceneIdx)?.data?.fireworksDisplay;
      if (!config) return;
      config.appearance ||= {};
      config.appearance.endColor = event.target.value;
      refresh();
    });

    document.getElementById('fw_rebuild')?.addEventListener('click', () => {
      const current = getSceneByIdx(sceneIdx);
      const config = current?.data?.fireworksDisplay;
      if (!current || !config) return;
      saveUndoStateForAction();
      const readNumber = (id, fallback) => {
        const value = parseFloat(document.getElementById(id)?.value);
        return Number.isFinite(value) ? value : fallback;
      };
      config.shells = Math.round(readNumber('fw_shells', 12));
      config.sparksPerShell = Math.round(readNumber('fw_sparks', 120));
      config.trailSteps = Math.round(readNumber('fw_trail_steps', 6));
      config.launchTrailPoints = Math.round(readNumber('fw_launch_points', 18));
      config.seed = Math.round(readNumber('fw_seed', 2026));
      config.patterns = (document.getElementById('fw_patterns')?.value || '')
        .split(',')
        .map(value => value.trim())
        .filter(Boolean);
      config.area ||= {};
      config.area.width = readNumber('fw_area_width', 18);
      config.area.minHeight = readNumber('fw_min_height', 3);
      config.area.maxHeight = readNumber('fw_max_height', 10);
      config.area.depth = readNumber('fw_area_depth', 4);
      config.area.groundY = readNumber('fw_ground_y', -7);
      config.timing ||= {};
      config.timing.startDelay = readNumber('fw_start_delay', 1.15);
      config.timing.cadence = readNumber('fw_cadence', 0.72);
      config.timing.volleySize = Math.round(readNumber('fw_volley_size', 2));
      config.timing.intraVolley = readNumber('fw_intra_volley', 0.12);
      config.timing.jitter = readNumber('fw_jitter', 0.12);
      config.timing.launchDuration = readNumber('fw_launch_duration', 1.05);
      config.timing.lifetime = readNumber('fw_lifetime', 3.1);
      config.timing.lifetimeVariation = readNumber('fw_lifetime_var', 0.22);
      config.motion ||= {};
      config.motion.speedMin = readNumber('fw_speed_min', 4.8);
      config.motion.speedMax = readNumber('fw_speed_max', 7.8);
      config.motion.spread = readNumber('fw_spread', 0.1);
      config.appearance ||= {};
      config.appearance.trailSpacing = readNumber('fw_trail_spacing', 0.045);
      config.appearance.palette = [0, 1, 2, 3].map(index =>
        document.getElementById(`fw_palette_${index}`)?.value
      ).filter(Boolean);
      config.launch ||= {};
      config.launch.enabled = document.getElementById('fw_launch_enabled')?.checked !== false;
      config.launch.color = document.getElementById('fw_launch_color')?.value || '#fff4cf';
      disposeSceneSystemObject(current.fireworksDisplayMesh);
      current.fireworksDisplayMesh = createFireworksDisplaySystem(
        THREE,
        config,
        current.threeScene,
      );
      populateObjectList('scenes', getCurrentSegment());
      populateFireworksDisplayParams(config, sceneIdx);
      document.querySelector('.obj-item[data-type="fireworksDisplay"]')?.classList.add('selected');
      scheduleAutoSave(sceneIdx);
    });
  }

  function populateSceneParams(sceneData, sceneIdx) {
    const fog = sceneData.fog || {};

    // Get current segment for opacity and blend mode (segment properties)
    const seg = getCurrentSegment();
    const segOpacity = seg?.opacity ?? 1.0;
    const segBlendMode = seg?.blendMode ?? 'normal';
    const transparentBackground = seg?.transparentBackground === true;
    
    // Blend mode options
    const blendModes = ['normal', 'multiply', 'screen', 'overlay', 'add', 'darken', 'lighten', 'difference'];
    const blendModeOptions = blendModes.map(mode => 
      `<option value="${mode}" ${segBlendMode === mode ? 'selected' : ''}>${mode.charAt(0).toUpperCase() + mode.slice(1)}</option>`
    ).join('');
    
    let html = `<div class="param-group"><div class="param-group-title">Blending</div>`;
    html += createParamRow('Opacity', createSlider('scene_opacity', segOpacity, 0, 1, 0.01), 'scene_opacity');
    html += createParamRow('Blend Mode', `<select id="scene_blend_mode" class="param-select">${blendModeOptions}</select>`, null);
    html += `</div>`;
    
    html += `<div class="param-group"><div class="param-group-title">Background</div>`;
    html += createParamRow(
      'Transparent',
      `<input type="checkbox" id="scene_transparent_background" ${transparentBackground ? 'checked' : ''} title="Composite this scene's geometry over lower scene or shader layers">`,
      null
    );
    html += createParamRow('Color', createColorPicker('scene_bg', sceneData.background || '#000000'), null);
    html += `</div>`;
    
    html += `<div class="param-group"><div class="param-group-title">Fog</div>`;
    html += createParamRow('Color', createColorPicker('scene_fog_color', fog.color || '#888888'), null);
    html += createParamRow('Near', createSlider('scene_fog_near', fog.near || 10, 0, 100, 1), 'scene_fog_near');
    html += createParamRow('Far', createSlider('scene_fog_far', fog.far || 100, 10, 500, 5), 'scene_fog_far');
    html += `</div>`;
    
    paramContent.innerHTML = html;
    ['scene_opacity', 'scene_fog_near', 'scene_fog_far'].forEach(linkSliderToNumber);
    
    addSceneParamHandlers(sceneIdx);
  }
  
  function addSceneParamHandlers(sceneIdx) {
    const handleChange = () => {
      // Get the scene object fresh each time
      const sceneObj = getSceneByIdx(sceneIdx);
      if (!sceneObj) return;
      
      const bgColor = document.getElementById('scene_bg')?.value;
      const fogColor = document.getElementById('scene_fog_color')?.value;
      const fogNear = parseFloat(document.getElementById('scene_fog_near')?.value);
      const fogFar = parseFloat(document.getElementById('scene_fog_far')?.value);
      const opacity = parseFloat(document.getElementById('scene_opacity')?.value);
      const blendMode = document.getElementById('scene_blend_mode')?.value;
      const transparentBackground = document.getElementById('scene_transparent_background')?.checked === true;
      
      if (bgColor) sceneObj.threeScene.background = new THREE.Color(bgColor);
      if (sceneObj.threeScene.fog) {
        if (fogColor) sceneObj.threeScene.fog.color.set(fogColor);
        if (!isNaN(fogNear)) sceneObj.threeScene.fog.near = fogNear;
        if (!isNaN(fogFar)) sceneObj.threeScene.fog.far = fogFar;
        
        // Sync fog to grass shader
        if (sceneObj.grassMesh?.material?.uniforms) {
          const gu = sceneObj.grassMesh.material.uniforms;
          if (fogColor && gu.uFogColor) gu.uFogColor.value.set(fogColor);
          if (!isNaN(fogNear) && gu.uFogNear) gu.uFogNear.value = fogNear;
          if (!isNaN(fogFar) && gu.uFogFar) gu.uFogFar.value = fogFar;
        }
      }
      
      // Update opacity and blend mode on the segment (not the scene)
      const seg = getCurrentSegment();
      if (seg) {
        if (!isNaN(opacity)) seg.opacity = opacity;
        if (blendMode) seg.blendMode = blendMode;
        seg.transparentBackground = transparentBackground;
      }
      
      // Auto-save to localStorage
      scheduleAutoSave(sceneIdx);
      scheduleTimelineAutoSave();
    };
    
    const inputs = ['scene_opacity', 'scene_opacity_num', 'scene_blend_mode', 'scene_transparent_background', 'scene_bg', 'scene_fog_color', 'scene_fog_near', 'scene_fog_far', 'scene_fog_near_num', 'scene_fog_far_num'];
    inputs.forEach(id => {
      const el = document.getElementById(id);
      if (el) {
        el.addEventListener('focus', beginParamEdit);
        el.addEventListener('mousedown', beginParamEdit);
        el.addEventListener('input', handleChange);
        el.addEventListener('change', handleChange); // For select elements
        el.addEventListener('blur', endParamEdit);
        el.addEventListener('mouseup', () => setTimeout(endParamEdit, 100));
      }
    });
    
    // Update slider states based on existing keyframes for THIS segment
    updateAllSliderKeyframeStates();
  }
  
  function populateAudioParams(seg) {
    const volumeBounds = seg.sliderBounds?.audio_volume || { min: 0, max: 1 };
    let html = `<div class="param-group"><div class="param-group-title">Playback</div>`;
    html += createParamRow(
      'Volume',
      createSlider(
        'audio_volume',
        seg.volume ?? 1,
        volumeBounds.min ?? 0,
        volumeBounds.max ?? 1,
        0.01,
      ),
      'audio_volume',
    );
    html += createParamRow('Muted', `
      <label style="display:flex;align-items:center;gap:8px;cursor:pointer;">
        <input type="checkbox" id="audio_muted" ${seg.muted ? 'checked' : ''}
               style="width:16px;height:16px;cursor:pointer;">
        <span style="color:var(--muted);font-size:11px;">${seg.muted ? 'On' : 'Off'}</span>
      </label>
    `);
    html += `</div>`;
    html += `<div class="param-group"><div class="param-group-title">Source</div>`;
    html += createParamRow(
      'Offset',
      `<span style="color:var(--text);font-size:11px;">${fmtTime(seg.sourceOffset || 0)}</span>`,
    );
    html += createParamRow(
      'Duration',
      `<span style="color:var(--text);font-size:11px;">${fmtTime(seg.sourceDuration || seg.end - seg.start)}</span>`,
    );
    html += `</div>`;
    paramContent.innerHTML = html;

    linkSliderToNumber('audio_volume');
    const volume = document.getElementById('audio_volume');
    if (volume) {
      const updateVolume = () => {
        applyAudioClipParameter(seg, 'audio_volume', volume.value);
        audioClipPlayback?.sync(timelineTracks, getCurrentTime(), silentPlaying);
        renderSegments();
        scheduleTimelineAutoSave();
      };
      volume.addEventListener('focus', beginParamEdit);
      volume.addEventListener('mousedown', beginParamEdit);
      volume.addEventListener('input', updateVolume);
      volume.addEventListener('blur', endParamEdit);
      volume.addEventListener('mouseup', () => setTimeout(endParamEdit, 100));
    }

    const muted = document.getElementById('audio_muted');
    muted?.addEventListener('change', () => {
      saveUndoState();
      seg.muted = muted.checked;
      const status = muted.parentElement?.querySelector('span');
      if (status) status.textContent = seg.muted ? 'On' : 'Off';
      audioClipPlayback?.sync(timelineTracks, getCurrentTime(), silentPlaying);
      scheduleTimelineAutoSave();
    });

    updateAllSliderKeyframeStates();
  }

  function populateImageParams(seg) {
    const isVideo = seg.isVideo || isVideoFile(seg.imageFile);
    const mediaType = isVideo ? 'Video' : 'Image';

    // Helper to get stored slider bounds or use defaults
    const getBounds = (paramId, defaultMin, defaultMax) => {
      const stored = seg.sliderBounds?.[paramId];
      return {
        min: stored?.min ?? defaultMin,
        max: stored?.max ?? defaultMax
      };
    };

    const xBounds = getBounds('img_x', 0, 1);
    const yBounds = getBounds('img_y', 0, 1);
    const scaleBounds = getBounds('img_scale', 0.1, 3.0);
    const rotBounds = getBounds('img_rotation', -180, 180);
    const opacityBounds = getBounds('img_opacity', 0, 1);

    let html = `<div class="param-group"><div class="param-group-title">Position</div>`;
    html += createParamRow('X', createSlider('img_x', seg.x ?? 0.5, xBounds.min, xBounds.max, 0.01), 'img_x');
    html += createParamRow('Y', createSlider('img_y', seg.y ?? 0.5, yBounds.min, yBounds.max, 0.01), 'img_y');
    html += `</div>`;

    html += `<div class="param-group"><div class="param-group-title">Transform</div>`;
    html += createParamRow('Scale', createSlider('img_scale', seg.scale ?? 1.0, scaleBounds.min, scaleBounds.max, 0.01), 'img_scale');
    html += createParamRow('Rotation', createSlider('img_rotation', seg.rotation ?? 0, rotBounds.min, rotBounds.max, 1), 'img_rotation');
    html += createParamRow('Opacity', createSlider('img_opacity', seg.opacity ?? 1.0, opacityBounds.min, opacityBounds.max, 0.01), 'img_opacity');
    html += `</div>`;
    
    // Blend mode dropdown
    const blendModes = [
      { value: 'source-over', label: 'Normal' },
      { value: 'multiply', label: 'Multiply' },
      { value: 'screen', label: 'Screen' },
      { value: 'overlay', label: 'Overlay' },
      { value: 'darken', label: 'Darken' },
      { value: 'lighten', label: 'Lighten' },
      { value: 'color-dodge', label: 'Color Dodge' },
      { value: 'color-burn', label: 'Color Burn' },
      { value: 'hard-light', label: 'Hard Light' },
      { value: 'soft-light', label: 'Soft Light' },
      { value: 'difference', label: 'Difference' },
      { value: 'exclusion', label: 'Exclusion' },
      { value: 'hue', label: 'Hue' },
      { value: 'saturation', label: 'Saturation' },
      { value: 'color', label: 'Color' },
      { value: 'luminosity', label: 'Luminosity' },
    ];
    const currentBlend = seg.blendMode || 'source-over';
    let blendOptions = blendModes.map(m => 
      `<option value="${m.value}" ${m.value === currentBlend ? 'selected' : ''}>${m.label}</option>`
    ).join('');
    
    html += `<div class="param-group"><div class="param-group-title">Blending</div>`;
    html += createParamRow('Blend Mode', `
      <select id="img_blend_mode" style="width:100%;padding:4px 8px;background:#1a1d24;border:1px solid rgba(255,255,255,0.2);border-radius:4px;color:#e0e0e0;font-size:12px;">
        ${blendOptions}
      </select>
    `);
    html += `</div>`;
    
    // Video-specific options (only show for video files)
    if (isVideo) {
      html += `<div class="param-group"><div class="param-group-title">Video Playback</div>`;
      html += createParamRow('Loop Video', `
        <label style="display:flex;align-items:center;gap:8px;cursor:pointer;">
          <input type="checkbox" id="img_loop" ${seg.loop ? 'checked' : ''} 
                 style="width:16px;height:16px;cursor:pointer;">
          <span style="color:var(--muted);font-size:11px;">${seg.loop ? 'On' : 'Off'}</span>
        </label>
      `);
      html += createParamRow('Ping Pong', `
        <label style="display:flex;align-items:center;gap:8px;cursor:pointer;">
          <input type="checkbox" id="img_ping_pong" ${seg.pingPong ? 'checked' : ''} 
                 style="width:16px;height:16px;cursor:pointer;">
          <span style="color:var(--muted);font-size:11px;">${seg.pingPong ? 'On' : 'Off'}</span>
        </label>
      `);
      html += `</div>`;
      
      html += `<div class="param-group"><div class="param-group-title">Video Audio</div>`;
      html += createParamRow('Audio Enabled', `
        <label style="display:flex;align-items:center;gap:8px;cursor:pointer;">
          <input type="checkbox" id="img_audio_enabled" ${seg.audioEnabled ? 'checked' : ''} 
                 style="width:16px;height:16px;cursor:pointer;">
          <span style="color:var(--muted);font-size:11px;">${seg.audioEnabled ? 'On' : 'Off'}</span>
        </label>
      `);
      const volBounds = getBounds('img_volume', 0, 1);
      html += createParamRow('Volume', createSlider('img_volume', seg.volume ?? 0.5, volBounds.min, volBounds.max, 0.01), 'img_volume');
      html += `</div>`;
    }
    
    // Post-FX options (show for all media)
    html += `<div class="param-group"><div class="param-group-title">Post Effects</div>`;
    html += createParamRow('Ignore Post FX', `
      <label style="display:flex;align-items:center;gap:8px;cursor:pointer;">
        <input type="checkbox" id="img_ignore_postfx" ${seg.ignorePostFx ? 'checked' : ''} 
               style="width:16px;height:16px;cursor:pointer;">
        <span style="color:var(--muted);font-size:11px;">${seg.ignorePostFx ? 'On' : 'Off'}</span>
      </label>
    `);
    html += `</div>`;
    
    paramContent.innerHTML = html;
    
    // Link sliders
    ['img_x', 'img_y', 'img_scale', 'img_rotation', 'img_opacity', 'img_fade_in', 'img_fade_out', 'img_volume'].forEach(linkSliderToNumber);
    
    // Add handlers
    addImageParamHandlers(seg);
  }
  
  function addImageParamHandlers(seg) {
    const handleChange = () => {
      const x = parseFloat(document.getElementById('img_x')?.value);
      const y = parseFloat(document.getElementById('img_y')?.value);
      const scale = parseFloat(document.getElementById('img_scale')?.value);
      const rotation = parseFloat(document.getElementById('img_rotation')?.value);
      const opacity = parseFloat(document.getElementById('img_opacity')?.value);
      
      if (!isNaN(x)) seg.x = x;
      if (!isNaN(y)) seg.y = y;
      if (!isNaN(scale)) seg.scale = scale;
      if (!isNaN(rotation)) seg.rotation = rotation;
      if (!isNaN(opacity)) seg.opacity = opacity;
      
      // Auto-save to localStorage
      scheduleTimelineAutoSave();
    };
    
    const inputs = [
      'img_x', 'img_x_num', 'img_y', 'img_y_num',
      'img_scale', 'img_scale_num', 'img_rotation', 'img_rotation_num',
      'img_opacity', 'img_opacity_num', 'img_fade_in', 'img_fade_in_num',
      'img_fade_out', 'img_fade_out_num'
    ];
    
    inputs.forEach(id => {
      const el = document.getElementById(id);
      if (el) {
        el.addEventListener('focus', beginParamEdit);
        el.addEventListener('mousedown', beginParamEdit);
        el.addEventListener('input', handleChange);
        el.addEventListener('blur', endParamEdit);
        el.addEventListener('mouseup', () => setTimeout(endParamEdit, 100));
      }
    });
    
    // Blend mode dropdown handler
    const blendSelect = document.getElementById('img_blend_mode');
    if (blendSelect) {
      blendSelect.addEventListener('change', () => {
        seg.blendMode = blendSelect.value;
        scheduleTimelineAutoSave();
      });
    }
    
    // Video loop checkbox handler
    const loopCheckbox = document.getElementById('img_loop');
    if (loopCheckbox) {
      loopCheckbox.addEventListener('change', () => {
        seg.loop = loopCheckbox.checked;
        // Update the label text
        const label = loopCheckbox.parentElement.querySelector('span');
        if (label) label.textContent = seg.loop ? 'On' : 'Off';
        scheduleTimelineAutoSave();
      });
    }
    
    // Video ping pong checkbox handler
    const pingPongCheckbox = document.getElementById('img_ping_pong');
    if (pingPongCheckbox) {
      pingPongCheckbox.addEventListener('change', () => {
        seg.pingPong = pingPongCheckbox.checked;
        // Update the label text
        const label = pingPongCheckbox.parentElement.querySelector('span');
        if (label) label.textContent = seg.pingPong ? 'On' : 'Off';
        // If ping pong is enabled, also enable loop
        if (seg.pingPong && !seg.loop) {
          seg.loop = true;
          if (loopCheckbox) {
            loopCheckbox.checked = true;
            const loopLabel = loopCheckbox.parentElement.querySelector('span');
            if (loopLabel) loopLabel.textContent = 'On';
          }
        }
        scheduleTimelineAutoSave();
      });
    }
    
    // Video audio enabled checkbox handler
    const audioEnabledCheckbox = document.getElementById('img_audio_enabled');
    if (audioEnabledCheckbox) {
      audioEnabledCheckbox.addEventListener('change', () => {
        seg.audioEnabled = audioEnabledCheckbox.checked;
        // Update the label text
        const label = audioEnabledCheckbox.parentElement.querySelector('span');
        if (label) label.textContent = seg.audioEnabled ? 'On' : 'Off';
        // Apply to the video element if it exists
        const video = loadedMedia[seg.imageFile];
        if (video && video.tagName === 'VIDEO') {
          video.muted = !seg.audioEnabled;
        }
        scheduleTimelineAutoSave();
      });
    }
    
    // Video volume slider handler
    const volumeSlider = document.getElementById('img_volume');
    if (volumeSlider) {
      volumeSlider.addEventListener('input', () => {
        const volume = parseFloat(volumeSlider.value);
        if (!isNaN(volume)) {
          seg.volume = volume;
          // Apply to the video element if it exists
          const video = loadedMedia[seg.imageFile];
          if (video && video.tagName === 'VIDEO') {
            video.volume = volume;
          }
          updateSliderThumb(volumeSlider);
        }
        scheduleTimelineAutoSave();
      });
      // Add focus/blur handlers for undo
      volumeSlider.addEventListener('focus', beginParamEdit);
      volumeSlider.addEventListener('mousedown', beginParamEdit);
      volumeSlider.addEventListener('blur', endParamEdit);
      volumeSlider.addEventListener('mouseup', () => setTimeout(endParamEdit, 100));
    }
    
    // Ignore post-fx checkbox handler
    const ignorePostFxCheckbox = document.getElementById('img_ignore_postfx');
    if (ignorePostFxCheckbox) {
      ignorePostFxCheckbox.addEventListener('change', () => {
        seg.ignorePostFx = ignorePostFxCheckbox.checked;
        // Update the label text
        const label = ignorePostFxCheckbox.parentElement.querySelector('span');
        if (label) label.textContent = seg.ignorePostFx ? 'On' : 'Off';
        scheduleTimelineAutoSave();
      });
    }
    
    // Update slider states based on existing keyframes for THIS segment
    updateAllSliderKeyframeStates();
  }

  function populatePostFxParams(seg) {
    currentEditingSceneIdx = -1;
    
    // Get shader info
    const shader = seg.shaderFile ? loadedPostFxShaders[seg.shaderFile] : null;
    const shaderParams = shader?.params || [];
    const textureParams = shader?.textureParams || [];
    const effectName = shader?.effectName || seg.name || 'Post Effect';
    
    // Initialize uniforms storage with shader defaults if not present
    if (!seg.uniforms) {
      seg.uniforms = {
        opacity: 1.0,
        intensity: 1.0,
      };
    }
    // Ensure opacity exists for older segments
    if (seg.uniforms.opacity === undefined) {
      seg.uniforms.opacity = 1.0;
    }
    
    // Initialize textures storage if not present
    if (!seg.textures) {
      seg.textures = {};
    }
    
    // Initialize shader-specific params with defaults
    for (const param of shaderParams) {
      if (seg.uniforms[param.uniform] === undefined) {
        seg.uniforms[param.uniform] = param.default;
      }
    }
    
    const u = seg.uniforms;
    
    // Opacity and Intensity (always shown at top)
    let html = `<div class="param-group"><div class="param-group-title">Main</div>`;
    html += createParamRow('Opacity', createSlider('pfx_opacity', u.opacity, 0, 1, 0.01), 'pfx_opacity');
    html += createParamRow('Intensity', createSlider('pfx_intensity', u.intensity, 0, 2, 0.01), 'pfx_intensity');
    html += `</div>`;
    
    // Texture parameters (if any)
    if (textureParams.length > 0) {
      // Group textures by their group name
      const texGroups = {};
      for (const texParam of textureParams) {
        const group = texParam.group || 'Textures';
        if (!texGroups[group]) texGroups[group] = [];
        texGroups[group].push(texParam);
      }
      
      for (const [groupName, texParams] of Object.entries(texGroups)) {
        html += `<div class="param-group"><div class="param-group-title">${groupName}</div>`;
        for (const texParam of texParams) {
          const texturePath = seg.textures?.[texParam.uniform] || null;
          const textureName = texturePath ? texturePath.split('/').pop() : 'None';
          const hasTexture = !!texturePath;
          const id = `pfx_tex_${texParam.uniform}`;
          
          html += createParamRow(texParam.name, `
            <div id="${id}_container" style="display:flex;gap:6px;align-items:center;flex:1;">
              <button class="btn texture-select-btn" id="${id}_btn" style="padding:4px 10px;font-size:11px;">Select</button>
              <span id="${id}_name" class="texture-name ${hasTexture ? 'has-texture' : ''}" style="flex:1;font-size:11px;color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${textureName}</span>
              ${hasTexture ? `<button class="btn texture-clear-btn" id="${id}_clear" title="Remove texture" style="padding:2px 6px;font-size:10px;">✕</button>` : ''}
            </div>
          `);
        }
        html += `</div>`;
      }
    }
    
    // Shader-specific parameters grouped by their group name
    if (shaderParams.length > 0) {
      const groups = {};
      for (const param of shaderParams) {
        const group = param.group || 'Effect';
        if (!groups[group]) groups[group] = [];
        groups[group].push(param);
      }
      
      for (const [groupName, params] of Object.entries(groups)) {
        html += `<div class="param-group"><div class="param-group-title">${groupName}</div>`;
        for (const param of params) {
          const value = u[param.uniform] ?? param.default;
          const id = `pfx_${param.uniform}`;
          html += createParamRow(param.name, createSlider(id, value, param.min, param.max, param.step), id);
        }
        html += `</div>`;
      }
    } else {
      // Fallback to default parameters if shader has no @param definitions
      html += `<div class="param-group"><div class="param-group-title">Bloom</div>`;
      html += createParamRow('Strength', createSlider('pfx_uBloomStrength', u.uBloomStrength ?? 0.6, 0, 2, 0.01), 'pfx_uBloomStrength');
      html += createParamRow('Threshold', createSlider('pfx_uBloomThreshold', u.uBloomThreshold ?? 0.3, 0, 1, 0.01), 'pfx_uBloomThreshold');
      html += `</div>`;
      
      html += `<div class="param-group"><div class="param-group-title">Effects</div>`;
      html += createParamRow('Vignette', createSlider('pfx_uVignetteStrength', u.uVignetteStrength ?? 0.5, 0, 1, 0.01), 'pfx_uVignetteStrength');
      html += createParamRow('Chromatic', createSlider('pfx_uChromaticAberration', u.uChromaticAberration ?? 0.003, 0, 0.02, 0.001), 'pfx_uChromaticAberration');
      html += `</div>`;
      
      html += `<div class="param-group"><div class="param-group-title">Color</div>`;
      html += createParamRow('Saturation', createSlider('pfx_uSaturation', u.uSaturation ?? 1.0, 0, 2, 0.01), 'pfx_uSaturation');
      html += createParamRow('Contrast', createSlider('pfx_uContrast', u.uContrast ?? 1.0, 0.5, 2, 0.01), 'pfx_uContrast');
      html += createParamRow('Brightness', createSlider('pfx_uBrightness', u.uBrightness ?? 1.0, 0.5, 2, 0.01), 'pfx_uBrightness');
      html += `</div>`;
    }
    
    paramContent.innerHTML = html;
    
    // Collect all slider IDs to link and add handlers
    const sliderIds = ['pfx_opacity', 'pfx_intensity'];
    
    if (shaderParams.length > 0) {
      for (const param of shaderParams) {
        sliderIds.push(`pfx_${param.uniform}`);
      }
    } else {
      sliderIds.push('pfx_uBloomStrength', 'pfx_uBloomThreshold', 'pfx_uVignetteStrength', 
                     'pfx_uChromaticAberration', 'pfx_uSaturation', 'pfx_uContrast', 'pfx_uBrightness');
    }
    
    // Link sliders to number inputs
    sliderIds.forEach(linkSliderToNumber);
    
    addPostFxParamHandlers(seg, shaderParams, textureParams);
  }
  
  function addPostFxParamHandlers(seg, shaderParams = [], textureParams = []) {
    const handleChange = () => {
      if (!seg.uniforms) seg.uniforms = {};
      
      const getValue = (id) => parseFloat(document.getElementById(id)?.value);
      
      // Always save opacity and intensity
      seg.uniforms.opacity = getValue('pfx_opacity') ?? 1.0;
      seg.uniforms.intensity = getValue('pfx_intensity') ?? 1.0;
      
      // Save shader-specific params
      if (shaderParams.length > 0) {
        for (const param of shaderParams) {
          const val = getValue(`pfx_${param.uniform}`);
          if (!isNaN(val)) {
            seg.uniforms[param.uniform] = val;
          }
        }
      } else {
        // Fallback defaults
        seg.uniforms.uBloomStrength = getValue('pfx_uBloomStrength') ?? 0.6;
        seg.uniforms.uBloomThreshold = getValue('pfx_uBloomThreshold') ?? 0.3;
        seg.uniforms.uVignetteStrength = getValue('pfx_uVignetteStrength') ?? 0.5;
        seg.uniforms.uChromaticAberration = getValue('pfx_uChromaticAberration') ?? 0.003;
        seg.uniforms.uSaturation = getValue('pfx_uSaturation') ?? 1.0;
        seg.uniforms.uContrast = getValue('pfx_uContrast') ?? 1.0;
        seg.uniforms.uBrightness = getValue('pfx_uBrightness') ?? 1.0;
      }
      
      // Auto-save to localStorage
      scheduleTimelineAutoSave();
    };
    
    // Build input IDs list
    const inputIds = ['pfx_opacity', 'pfx_opacity_num', 'pfx_intensity', 'pfx_intensity_num'];
    
    if (shaderParams.length > 0) {
      for (const param of shaderParams) {
        inputIds.push(`pfx_${param.uniform}`, `pfx_${param.uniform}_num`);
      }
    } else {
      inputIds.push(
        'pfx_uBloomStrength', 'pfx_uBloomStrength_num',
        'pfx_uBloomThreshold', 'pfx_uBloomThreshold_num',
        'pfx_uVignetteStrength', 'pfx_uVignetteStrength_num',
        'pfx_uChromaticAberration', 'pfx_uChromaticAberration_num',
        'pfx_uSaturation', 'pfx_uSaturation_num',
        'pfx_uContrast', 'pfx_uContrast_num',
        'pfx_uBrightness', 'pfx_uBrightness_num'
      );
    }
    
    inputIds.forEach(id => {
      const el = document.getElementById(id);
      if (el) {
        el.addEventListener('focus', beginParamEdit);
        el.addEventListener('mousedown', beginParamEdit);
        el.addEventListener('input', handleChange);
        el.addEventListener('blur', endParamEdit);
        el.addEventListener('mouseup', () => setTimeout(endParamEdit, 100));
      }
    });
    
    // Setup texture parameter handlers
    for (const texParam of textureParams) {
      const id = `pfx_tex_${texParam.uniform}`;
      const btn = document.getElementById(`${id}_btn`);
      const clearBtn = document.getElementById(`${id}_clear`);
      const nameEl = document.getElementById(`${id}_name`);
      const container = document.getElementById(`${id}_container`);
      
      if (btn) {
        btn.addEventListener('click', () => {
          openAssetBrowser('media', async (asset) => {
            if (!asset) return;
            
            // Save undo state before changing texture
            saveUndoState();
            
            // Store the texture path in the segment
            if (!seg.textures) seg.textures = {};
            seg.textures[texParam.uniform] = asset.path;
            
            // Load and apply the texture to the shader material
            await applyPostFxTexture(seg, texParam.uniform, asset.path);
            
            // Update the UI
            if (nameEl) {
              const fileName = asset.path.split('/').pop();
              const videoIcon = isVideoFile(asset.path) ? '🎬 ' : '';
              nameEl.textContent = videoIcon + fileName;
              nameEl.classList.add('has-texture');
            }
            
            // Add clear button if not present
            if (container && !document.getElementById(`${id}_clear`)) {
              const newClearBtn = document.createElement('button');
              newClearBtn.className = 'btn texture-clear-btn';
              newClearBtn.id = `${id}_clear`;
              newClearBtn.title = 'Remove texture';
              newClearBtn.textContent = '✕';
              newClearBtn.style.cssText = 'padding:2px 6px;font-size:10px;';
              newClearBtn.addEventListener('click', () => {
                // Save undo state before clearing texture
                saveUndoState();
                // Remove texture
                if (seg.textures) delete seg.textures[texParam.uniform];
                clearPostFxTexture(seg, texParam.uniform);
                if (nameEl) {
                  nameEl.textContent = 'None';
                  nameEl.classList.remove('has-texture');
                }
                newClearBtn.remove();
                scheduleTimelineAutoSave();
              });
              container.appendChild(newClearBtn);
            }
            
            scheduleTimelineAutoSave();
          });
        });
      }
      
      if (clearBtn) {
        clearBtn.addEventListener('click', () => {
          // Save undo state before clearing texture
          saveUndoState();
          // Remove texture
          if (seg.textures) delete seg.textures[texParam.uniform];
          clearPostFxTexture(seg, texParam.uniform);
          if (nameEl) {
            nameEl.textContent = 'None';
            nameEl.classList.remove('has-texture');
          }
          clearBtn.remove();
          scheduleTimelineAutoSave();
        });
      }
    }
    
    // Update slider states based on existing keyframes for THIS segment
    updateAllSliderKeyframeStates();
  }

  // =========================
  // SHADER TRACK PARAMETERS
  // =========================
  function populateShaderParams(seg) {
    currentEditingSceneIdx = -1;
    
    // Get shader info
    const shader = seg.shaderFile ? loadedShaders[seg.shaderFile] : null;
    const shaderParams = shader?.params || [];
    const textureParams = shader?.textureParams || [];
    const effectName = shader?.effectName || seg.name || 'Shader Effect';
    
    // Initialize uniforms storage if not present
    if (!seg.uniforms) {
      seg.uniforms = {};
    }
    // Ensure blend mode and opacity exist
    if (seg.blendMode === undefined) seg.blendMode = 'source-over';
    if (seg.opacity === undefined) seg.opacity = 1.0;
    const supportsPostFxBypass = shaderSupportsPostFxBypass(seg.shaderFile);
    if (supportsPostFxBypass && seg.ignorePostFx === undefined) seg.ignorePostFx = false;
    
    // Initialize shader-specific params with defaults
    for (const param of shaderParams) {
      if (seg.uniforms[param.uniform] === undefined) {
        seg.uniforms[param.uniform] = param.default;
      }
    }
    
    const u = seg.uniforms;
    
    // Blend mode dropdown (same as media)
    const blendModes = [
      { value: 'source-over', label: 'Normal' },
      { value: 'multiply', label: 'Multiply' },
      { value: 'screen', label: 'Screen' },
      { value: 'overlay', label: 'Overlay' },
      { value: 'darken', label: 'Darken' },
      { value: 'lighten', label: 'Lighten' },
      { value: 'color-dodge', label: 'Color Dodge' },
      { value: 'color-burn', label: 'Color Burn' },
      { value: 'hard-light', label: 'Hard Light' },
      { value: 'soft-light', label: 'Soft Light' },
      { value: 'difference', label: 'Difference' },
      { value: 'exclusion', label: 'Exclusion' },
      { value: 'hue', label: 'Hue' },
      { value: 'saturation', label: 'Saturation' },
      { value: 'color', label: 'Color' },
      { value: 'luminosity', label: 'Luminosity' },
    ];
    const currentBlend = seg.blendMode || 'source-over';
    let blendOptions = blendModes.map(m => 
      `<option value="${m.value}" ${m.value === currentBlend ? 'selected' : ''}>${m.label}</option>`
    ).join('');
    
    let html = `<div class="param-group"><div class="param-group-title">Blending</div>`;
    html += createParamRow('Blend Mode', `
      <select id="shader_blend_mode" style="width:100%;padding:4px 8px;background:#1a1d24;border:1px solid rgba(255,255,255,0.2);border-radius:4px;color:#e0e0e0;font-size:12px;">
        ${blendOptions}
      </select>
    `);
    html += createParamRow('Opacity', createSlider('shader_opacity', seg.opacity, 0, 1, 0.01), 'shader_opacity');
    if (supportsPostFxBypass) {
      html += createParamRow('Ignore Post FX', `
        <label style="display:flex;align-items:center;gap:8px;cursor:pointer;">
          <input type="checkbox" id="shader_ignore_postfx" ${seg.ignorePostFx ? 'checked' : ''}
                 style="width:16px;height:16px;cursor:pointer;">
          <span style="color:var(--muted);font-size:11px;">${seg.ignorePostFx ? 'On' : 'Off'}</span>
        </label>
      `);
    }
    html += `</div>`;
    
    // Texture parameters (if any)
    if (textureParams.length > 0) {
      // Group textures by their group name
      const texGroups = {};
      for (const texParam of textureParams) {
        const group = texParam.group || 'Textures';
        if (!texGroups[group]) texGroups[group] = [];
        texGroups[group].push(texParam);
      }
      
      for (const [groupName, texParams] of Object.entries(texGroups)) {
        html += `<div class="param-group"><div class="param-group-title">${groupName}</div>`;
        for (const texParam of texParams) {
          const texturePath = seg.textures?.[texParam.uniform] || null;
          const textureName = texturePath ? texturePath.split('/').pop() : 'None';
          const hasTexture = !!texturePath;
          const id = `shader_tex_${texParam.uniform}`;
          
          html += createParamRow(texParam.name, `
            <div id="${id}_container" style="display:flex;gap:6px;align-items:center;flex:1;">
              <button class="btn texture-select-btn" id="${id}_btn" style="padding:4px 10px;font-size:11px;">Select</button>
              <span id="${id}_name" class="texture-name ${hasTexture ? 'has-texture' : ''}" style="flex:1;font-size:11px;color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${textureName}</span>
              ${hasTexture ? `<button class="btn texture-clear-btn" id="${id}_clear" title="Remove texture" style="padding:2px 6px;font-size:10px;">✕</button>` : ''}
            </div>
          `);
        }
        html += `</div>`;
      }
    }
    
    // Shader-specific parameters grouped by their group name
    if (shaderParams.length > 0) {
      const groups = {};
      for (const param of shaderParams) {
        const group = param.group || 'Effect';
        if (!groups[group]) groups[group] = [];
        groups[group].push(param);
      }
      
      for (const [groupName, params] of Object.entries(groups)) {
        html += `<div class="param-group"><div class="param-group-title">${groupName}</div>`;
        for (const param of params) {
          const value = u[param.uniform] ?? param.default;
          const id = `shader_${param.uniform}`;
          html += createParamRow(param.name, createSlider(id, value, param.min, param.max, param.step), id);
        }
        html += `</div>`;
      }
    }
    
    paramContent.innerHTML = html;
    
    // Link sliders
    const sliderIds = ['shader_opacity'];
    for (const param of shaderParams) {
      sliderIds.push(`shader_${param.uniform}`);
    }
    sliderIds.forEach(linkSliderToNumber);
    
    // Add handlers
    addShaderParamHandlers(seg, shaderParams, textureParams);
  }
  
  function addShaderParamHandlers(seg, shaderParams = [], textureParams = []) {
    const handleChange = () => {
      const getValue = (id) => parseFloat(document.getElementById(id)?.value);
      
      // Save blend mode
      const blendSelect = document.getElementById('shader_blend_mode');
      if (blendSelect) {
        seg.blendMode = blendSelect.value;
      }
      
      // Save opacity
      seg.opacity = getValue('shader_opacity') ?? 1.0;
      
      // Save shader-specific params
      if (!seg.uniforms) seg.uniforms = {};
      for (const param of shaderParams) {
        const val = getValue(`shader_${param.uniform}`);
        if (!isNaN(val)) {
          seg.uniforms[param.uniform] = val;
        }
      }
      
      // Auto-save
      scheduleTimelineAutoSave();
    };
    
    // Blend mode dropdown
    const blendSelect = document.getElementById('shader_blend_mode');
    if (blendSelect) {
      blendSelect.addEventListener('change', handleChange);
    }

    const ignorePostFxCheckbox = document.getElementById('shader_ignore_postfx');
    if (ignorePostFxCheckbox) {
      ignorePostFxCheckbox.addEventListener('change', () => {
        saveUndoState();
        seg.ignorePostFx = ignorePostFxCheckbox.checked;
        const label = ignorePostFxCheckbox.parentElement.querySelector('span');
        if (label) label.textContent = seg.ignorePostFx ? 'On' : 'Off';
        scheduleTimelineAutoSave();
      });
    }
    
    // Build input IDs list
    const inputIds = ['shader_opacity', 'shader_opacity_num'];
    for (const param of shaderParams) {
      inputIds.push(`shader_${param.uniform}`, `shader_${param.uniform}_num`);
    }
    
    inputIds.forEach(id => {
      const el = document.getElementById(id);
      if (el) {
        el.addEventListener('focus', beginParamEdit);
        el.addEventListener('mousedown', beginParamEdit);
        el.addEventListener('input', handleChange);
        el.addEventListener('blur', endParamEdit);
        el.addEventListener('mouseup', () => setTimeout(endParamEdit, 100));
      }
    });
    
    // Setup texture parameter handlers
    for (const texParam of textureParams) {
      const id = `shader_tex_${texParam.uniform}`;
      const btn = document.getElementById(`${id}_btn`);
      const clearBtn = document.getElementById(`${id}_clear`);
      const nameEl = document.getElementById(`${id}_name`);
      const container = document.getElementById(`${id}_container`);
      
      if (btn) {
        btn.addEventListener('click', () => {
          openAssetBrowser('media', async (asset) => {
            if (!asset) return;
            
            // Save undo state before changing texture
            saveUndoState();
            
            // Store the texture path in the segment
            if (!seg.textures) seg.textures = {};
            seg.textures[texParam.uniform] = asset.path;
            
            // Load and apply the texture to the shader material
            await applyShaderTexture(seg, texParam.uniform, asset.path);
            
            // Update the UI
            if (nameEl) {
              const fileName = asset.path.split('/').pop();
              const videoIcon = isVideoFile(asset.path) ? '🎬 ' : '';
              nameEl.textContent = videoIcon + fileName;
              nameEl.classList.add('has-texture');
            }
            
            // Add clear button if not present
            if (container && !document.getElementById(`${id}_clear`)) {
              const newClearBtn = document.createElement('button');
              newClearBtn.className = 'btn texture-clear-btn';
              newClearBtn.id = `${id}_clear`;
              newClearBtn.title = 'Remove texture';
              newClearBtn.textContent = '✕';
              newClearBtn.style.cssText = 'padding:2px 6px;font-size:10px;';
              newClearBtn.addEventListener('click', () => {
                // Save undo state before clearing texture
                saveUndoState();
                // Remove texture
                if (seg.textures) delete seg.textures[texParam.uniform];
                clearShaderTexture(seg, texParam.uniform);
                if (nameEl) {
                  nameEl.textContent = 'None';
                  nameEl.classList.remove('has-texture');
                }
                newClearBtn.remove();
                scheduleTimelineAutoSave();
              });
              container.appendChild(newClearBtn);
            }
            
            scheduleTimelineAutoSave();
          });
        });
      }
      
      if (clearBtn) {
        clearBtn.addEventListener('click', () => {
          // Save undo state before clearing texture
          saveUndoState();
          // Remove texture
          if (seg.textures) delete seg.textures[texParam.uniform];
          clearShaderTexture(seg, texParam.uniform);
          if (nameEl) {
            nameEl.textContent = 'None';
            nameEl.classList.remove('has-texture');
          }
          clearBtn.remove();
          scheduleTimelineAutoSave();
        });
      }
    }
    
    // Update slider states based on existing keyframes for THIS segment
    updateAllSliderKeyframeStates();
  }

  // =========================
  // LUT COLOR GRADING PARAMETERS
  // =========================
  function populateLUTParams(seg) {
    currentEditingSceneIdx = -1;

    // Ensure LUT parameters have defaults
    if (seg.intensity === undefined) seg.intensity = 1.0;
    if (seg.liftMaster === undefined) seg.liftMaster = 0.0;
    if (seg.liftR === undefined) seg.liftR = 0.0;
    if (seg.liftG === undefined) seg.liftG = 0.0;
    if (seg.liftB === undefined) seg.liftB = 0.0;
    if (seg.gammaMaster === undefined) seg.gammaMaster = 1.0;
    if (seg.gammaR === undefined) seg.gammaR = 1.0;
    if (seg.gammaG === undefined) seg.gammaG = 1.0;
    if (seg.gammaB === undefined) seg.gammaB = 1.0;
    if (seg.gainMaster === undefined) seg.gainMaster = 1.0;
    if (seg.gainR === undefined) seg.gainR = 1.0;
    if (seg.gainG === undefined) seg.gainG = 1.0;
    if (seg.gainB === undefined) seg.gainB = 1.0;

    const lutName = seg.lutFile ? seg.lutFile.split('/').pop().replace('.cube', '') : 'None';

    let html = `
      <div class="param-group">
        <div class="param-group-title">LUT File</div>
        <div class="param-row" style="justify-content:space-between;">
          <span style="color:var(--text);font-size:12px;">${lutName}</span>
          <button id="changeLutBtn" class="btn" style="padding:4px 10px;font-size:11px;">Change LUT</button>
        </div>
      </div>

      <div class="param-group">
        <div class="param-group-title">Main</div>
        ${createParamRow('Intensity', createSlider('lut_intensity', seg.intensity, 0, 1, 0.01), 'lut_intensity')}
      </div>

      <div class="param-group">
        <div class="param-group-title">Lift (Shadows)</div>
        ${createParamRow('Master', createSlider('lut_lift_master', seg.liftMaster, -0.5, 0.5, 0.01), 'lut_lift_master')}
        ${createParamRow('Red', createSlider('lut_lift_r', seg.liftR, -0.5, 0.5, 0.01), 'lut_lift_r')}
        ${createParamRow('Green', createSlider('lut_lift_g', seg.liftG, -0.5, 0.5, 0.01), 'lut_lift_g')}
        ${createParamRow('Blue', createSlider('lut_lift_b', seg.liftB, -0.5, 0.5, 0.01), 'lut_lift_b')}
      </div>

      <div class="param-group">
        <div class="param-group-title">Gamma (Midtones)</div>
        ${createParamRow('Master', createSlider('lut_gamma_master', seg.gammaMaster, 0.5, 2.0, 0.01), 'lut_gamma_master')}
        ${createParamRow('Red', createSlider('lut_gamma_r', seg.gammaR, 0.5, 2.0, 0.01), 'lut_gamma_r')}
        ${createParamRow('Green', createSlider('lut_gamma_g', seg.gammaG, 0.5, 2.0, 0.01), 'lut_gamma_g')}
        ${createParamRow('Blue', createSlider('lut_gamma_b', seg.gammaB, 0.5, 2.0, 0.01), 'lut_gamma_b')}
      </div>

      <div class="param-group">
        <div class="param-group-title">Gain (Highlights)</div>
        ${createParamRow('Master', createSlider('lut_gain_master', seg.gainMaster, 0.5, 2.0, 0.01), 'lut_gain_master')}
        ${createParamRow('Red', createSlider('lut_gain_r', seg.gainR, 0.5, 2.0, 0.01), 'lut_gain_r')}
        ${createParamRow('Green', createSlider('lut_gain_g', seg.gainG, 0.5, 2.0, 0.01), 'lut_gain_g')}
        ${createParamRow('Blue', createSlider('lut_gain_b', seg.gainB, 0.5, 2.0, 0.01), 'lut_gain_b')}
      </div>

      <div class="param-group">
        <div class="param-group-title">Actions</div>
        <div class="param-row" style="flex-direction:column;gap:8px;">
          <button id="resetLutParamsBtn" class="btn" style="width:100%;padding:8px;font-size:12px;">Reset All Parameters</button>
        </div>
      </div>
    `;

    paramContent.innerHTML = html;

    // Add Change LUT button handler
    const changeLutBtn = document.getElementById('changeLutBtn');
    if (changeLutBtn) {
      changeLutBtn.addEventListener('click', () => {
        openAssetBrowser('lut', async (asset) => {
          if (!asset) return;
          saveUndoState();
          seg.lutFile = asset.path;
          // Reload the LUT texture
          await loadLUT(asset.path);
          populateLUTParams(seg);
          scheduleTimelineAutoSave();
        });
      });
    }

    // Add reset button handler
    const resetBtn = document.getElementById('resetLutParamsBtn');
    if (resetBtn) {
      resetBtn.addEventListener('click', () => {
        saveUndoState();
        seg.intensity = 1.0;
        seg.liftMaster = 0.0;
        seg.liftR = 0.0;
        seg.liftG = 0.0;
        seg.liftB = 0.0;
        seg.gammaMaster = 1.0;
        seg.gammaR = 1.0;
        seg.gammaG = 1.0;
        seg.gammaB = 1.0;
        seg.gainMaster = 1.0;
        seg.gainR = 1.0;
        seg.gainG = 1.0;
        seg.gainB = 1.0;
        populateLUTParams(seg);
        scheduleTimelineAutoSave();
      });
    }

    // Add slider handlers
    addLUTParamHandlers(seg);

    // Update slider states based on existing keyframes
    updateAllSliderKeyframeStates();
  }

  function addLUTParamHandlers(seg) {
    const handleChange = () => {
      const getValue = (id) => parseFloat(document.getElementById(id)?.value);

      // Read all values from sliders
      seg.intensity = getValue('lut_intensity') ?? 1.0;
      seg.liftMaster = getValue('lut_lift_master') ?? 0.0;
      seg.liftR = getValue('lut_lift_r') ?? 0.0;
      seg.liftG = getValue('lut_lift_g') ?? 0.0;
      seg.liftB = getValue('lut_lift_b') ?? 0.0;
      seg.gammaMaster = getValue('lut_gamma_master') ?? 1.0;
      seg.gammaR = getValue('lut_gamma_r') ?? 1.0;
      seg.gammaG = getValue('lut_gamma_g') ?? 1.0;
      seg.gammaB = getValue('lut_gamma_b') ?? 1.0;
      seg.gainMaster = getValue('lut_gain_master') ?? 1.0;
      seg.gainR = getValue('lut_gain_r') ?? 1.0;
      seg.gainG = getValue('lut_gain_g') ?? 1.0;
      seg.gainB = getValue('lut_gain_b') ?? 1.0;

      scheduleTimelineAutoSave();
    };

    // All slider and number input IDs
    const inputIds = [
      'lut_intensity', 'lut_intensity_num',
      'lut_lift_master', 'lut_lift_master_num',
      'lut_lift_r', 'lut_lift_r_num',
      'lut_lift_g', 'lut_lift_g_num',
      'lut_lift_b', 'lut_lift_b_num',
      'lut_gamma_master', 'lut_gamma_master_num',
      'lut_gamma_r', 'lut_gamma_r_num',
      'lut_gamma_g', 'lut_gamma_g_num',
      'lut_gamma_b', 'lut_gamma_b_num',
      'lut_gain_master', 'lut_gain_master_num',
      'lut_gain_r', 'lut_gain_r_num',
      'lut_gain_g', 'lut_gain_g_num',
      'lut_gain_b', 'lut_gain_b_num'
    ];

    // Link sliders to number inputs
    const sliderIds = inputIds.filter(id => !id.endsWith('_num'));
    sliderIds.forEach(linkSliderToNumber);

    // Add event listeners
    inputIds.forEach(id => {
      const el = document.getElementById(id);
      if (el) {
        el.addEventListener('focus', beginParamEdit);
        el.addEventListener('mousedown', beginParamEdit);
        el.addEventListener('input', handleChange);
        el.addEventListener('blur', endParamEdit);
        el.addEventListener('mouseup', () => setTimeout(endParamEdit, 100));
      }
    });

    // Reset button handler
    const resetBtn = document.getElementById('resetLutParamsBtn');
    if (resetBtn) {
      resetBtn.addEventListener('click', () => {
        seg.intensity = 1.0;
        seg.liftMaster = 0.0; seg.liftR = 0.0; seg.liftG = 0.0; seg.liftB = 0.0;
        seg.gammaMaster = 1.0; seg.gammaR = 1.0; seg.gammaG = 1.0; seg.gammaB = 1.0;
        seg.gainMaster = 1.0; seg.gainR = 1.0; seg.gainG = 1.0; seg.gainB = 1.0;
        populateLUTParams(seg);
        scheduleTimelineAutoSave();
      });
    }
  }

  // =========================
  // TEXT OVERLAY PARAMETERS
  // =========================
  function populateTextParams(seg) {
    currentEditingSceneIdx = -1;
    
    // Build custom font dropdown items (each item styled with its own font)
    const selectedFont = seg.fontFamily || 'Inter';
    const selectedFontObj = AVAILABLE_FONTS.find(f => f.name === selectedFont) || AVAILABLE_FONTS[0];
    let fontDropdownItems = AVAILABLE_FONTS.map(f =>
      `<div class="font-dropdown-item${f.name === selectedFont ? ' selected' : ''}" data-font="${f.name}" data-family="${f.family}" style="font-family: ${f.family}">${f.name}</div>`
    ).join('');
    
    // Build font weight options
    const currentFont = AVAILABLE_FONTS.find(f => f.name === seg.fontFamily) || AVAILABLE_FONTS[0];
    const weightLabels = { 400: 'Regular', 500: 'Medium', 600: 'Semibold', 700: 'Bold' };
    let weightOptions = currentFont.weights.map(w => 
      `<option value="${w}" ${w === seg.fontWeight ? 'selected' : ''}>${weightLabels[w] || w}</option>`
    ).join('');
    
    let html = `
      <div class="param-group">
        <div class="param-group-title">Text Content</div>
        <div class="param-row" style="flex-direction:column;align-items:stretch;">
          <textarea id="text_content"
                    style="width:100%;box-sizing:border-box;height:80px;padding:8px;background:#1a1d24;border:1px solid rgba(255,255,255,0.2);border-radius:4px;color:#e0e0e0;font-size:12px;font-family:inherit;resize:vertical;"
                    placeholder="Enter your text here...">${seg.text || ''}</textarea>
        </div>
      </div>
      
      <div class="param-group">
        <div class="param-group-title">Typography</div>
        ${createParamRow('Font', `
          <div class="font-dropdown" id="text_font_dropdown">
            <div class="font-dropdown-selected" data-family="${selectedFontObj.family}" style="font-family: ${selectedFontObj.family}">${selectedFont}</div>
            <div class="font-dropdown-list">
              ${fontDropdownItems}
            </div>
          </div>
          <input type="hidden" id="text_font" value="${selectedFont}">
        `)}
        ${createParamRow('Font Size', `
          <input type="number" id="text_font_size" value="${seg.fontSize || 48}" min="8" max="500" step="1"
                 style="width:70px;padding:6px 10px;background:#1a1d24;border:1px solid rgba(255,255,255,0.2);border-radius:4px;color:#e0e0e0;font-size:12px;">
          <span style="margin-left:8px;color:var(--muted);font-size:11px;">px</span>
        `)}
        ${createParamRow('Text Scale', createSlider('text_scale', (seg.textScale ?? 1.0) * 100, 25, 400, 5), 'text_scale')}
        ${createParamRow('Weight', `
          <select id="text_weight" style="width:100%;padding:4px 8px;background:#1a1d24;border:1px solid rgba(255,255,255,0.2);border-radius:4px;color:#e0e0e0;font-size:12px;">
            ${weightOptions}
          </select>
        `)}
        ${createParamRow('Style', `
          <div style="display:flex;gap:8px;">
            <button id="text_style_normal" class="btn" style="flex:1;padding:6px;font-size:11px;${seg.fontStyle === 'normal' ? 'background:var(--good);color:white;' : ''}">Normal</button>
            <button id="text_style_italic" class="btn" style="flex:1;padding:6px;font-size:11px;font-style:italic;${seg.fontStyle === 'italic' ? 'background:var(--good);color:white;' : ''}">Italic</button>
          </div>
        `)}
        ${createParamRow('Align', `
          <div style="display:flex;gap:4px;">
            <button id="text_align_left" class="btn" style="flex:1;padding:6px;${seg.textAlign === 'left' ? 'background:var(--good);color:white;' : ''}">
              <div style="display:flex;flex-direction:column;gap:2px;align-items:flex-start;">
                <div style="width:16px;height:2px;background:currentColor;"></div>
                <div style="width:10px;height:2px;background:currentColor;"></div>
                <div style="width:14px;height:2px;background:currentColor;"></div>
              </div>
            </button>
            <button id="text_align_center" class="btn" style="flex:1;padding:6px;${seg.textAlign === 'center' ? 'background:var(--good);color:white;' : ''}">
              <div style="display:flex;flex-direction:column;gap:2px;align-items:center;">
                <div style="width:16px;height:2px;background:currentColor;"></div>
                <div style="width:10px;height:2px;background:currentColor;"></div>
                <div style="width:14px;height:2px;background:currentColor;"></div>
              </div>
            </button>
            <button id="text_align_right" class="btn" style="flex:1;padding:6px;${seg.textAlign === 'right' ? 'background:var(--good);color:white;' : ''}">
              <div style="display:flex;flex-direction:column;gap:2px;align-items:flex-end;">
                <div style="width:16px;height:2px;background:currentColor;"></div>
                <div style="width:10px;height:2px;background:currentColor;"></div>
                <div style="width:14px;height:2px;background:currentColor;"></div>
              </div>
            </button>
          </div>
        `)}
        ${createParamRow('Line Height', createSlider('text_line_height', seg.lineHeight || 1.3, 0.8, 3.0, 0.1), 'text_line_height')}
        ${createParamRow('Letter Space', createSlider('text_letter_spacing', seg.letterSpacing || 0, -5, 20, 0.5), 'text_letter_spacing')}
      </div>
      
      <div class="param-group">
        <div class="param-group-title">Colors</div>
        ${createParamRow('Text Color', `
          <div style="display:flex;gap:8px;align-items:center;">
            <input type="color" id="text_color" value="${seg.textColor || '#ffffff'}" 
                   style="width:40px;height:28px;border:1px solid var(--border);border-radius:4px;cursor:pointer;">
            <input type="text" id="text_color_hex" value="${seg.textColor || '#ffffff'}" 
                   style="flex:1;padding:4px 8px;background:var(--surface);border:1px solid var(--border);border-radius:4px;color:var(--text);font-size:12px;font-family:monospace;">
          </div>
        `)}
        ${createParamRow('Background', `
          <div style="display:flex;gap:8px;align-items:center;">
            <input type="color" id="text_bg_color" value="${seg.backgroundColor === 'transparent' ? '#000000' : seg.backgroundColor}" 
                   style="width:40px;height:28px;border:1px solid var(--border);border-radius:4px;cursor:pointer;">
            <label style="display:flex;align-items:center;gap:4px;cursor:pointer;">
              <input type="checkbox" id="text_bg_enabled" ${seg.backgroundColor !== 'transparent' ? 'checked' : ''}>
              <span style="font-size:11px;color:var(--muted);">Enable</span>
            </label>
          </div>
        `)}
      </div>
      
      <div class="param-group">
        <div class="param-group-title">Text Area</div>
        ${createParamRow('X Position', createSlider('text_area_x', seg.areaX || 50, 0, 100, 1), 'text_area_x')}
        ${createParamRow('Y Position', createSlider('text_area_y', seg.areaY || 50, 0, 100, 1), 'text_area_y')}
        ${createParamRow('Width', createSlider('text_area_width', seg.areaWidth || 80, 10, 100, 1), 'text_area_width')}
        ${createParamRow('Height', createSlider('text_area_height', seg.areaHeight || 30, 5, 100, 1), 'text_area_height')}
        ${createParamRow('Rotation', createSlider('text_rotation', seg.rotation || 0, -180, 180, 1), 'text_rotation')}
        ${createParamRow('Padding', createSlider('text_padding', seg.padding || 20, 0, 100, 1), 'text_padding')}
      </div>
      
      <div class="param-group">
        <div class="param-group-title">Effects</div>
        ${createParamRow('Opacity', createSlider('text_opacity', seg.opacity || 1.0, 0, 1, 0.01), 'text_opacity')}
        ${createParamRow('Blend Mode', `
          <select id="text_blend_mode" style="width:100%;padding:4px 8px;background:#1a1d24;border:1px solid rgba(255,255,255,0.2);border-radius:4px;color:#e0e0e0;font-size:12px;">
            ${[
              { value: 'source-over', label: 'Normal' },
              { value: 'multiply', label: 'Multiply' },
              { value: 'screen', label: 'Screen' },
              { value: 'overlay', label: 'Overlay' },
              { value: 'darken', label: 'Darken' },
              { value: 'lighten', label: 'Lighten' },
              { value: 'color-dodge', label: 'Color Dodge' },
              { value: 'color-burn', label: 'Color Burn' },
              { value: 'hard-light', label: 'Hard Light' },
              { value: 'soft-light', label: 'Soft Light' },
              { value: 'difference', label: 'Difference' },
              { value: 'exclusion', label: 'Exclusion' },
              { value: 'hue', label: 'Hue' },
              { value: 'saturation', label: 'Saturation' },
              { value: 'color', label: 'Color' },
              { value: 'luminosity', label: 'Luminosity' },
            ].map(m => `<option value="${m.value}" ${m.value === (seg.blendMode || 'source-over') ? 'selected' : ''}>${m.label}</option>`).join('')}
          </select>
        `)}
      </div>
      
      <div class="param-group">
        <div class="param-group-title">Post Effects</div>
        ${createParamRow('Ignore Post FX', `
          <label style="display:flex;align-items:center;gap:8px;cursor:pointer;">
            <input type="checkbox" id="text_ignore_postfx" ${seg.ignorePostFx ? 'checked' : ''} 
                   style="width:16px;height:16px;cursor:pointer;">
            <span style="color:var(--muted);font-size:11px;">${seg.ignorePostFx ? 'On' : 'Off'}</span>
          </label>
        `)}
      </div>
    `;
    
    paramContent.innerHTML = html;
    
    // Link sliders to number inputs
    ['text_scale', 'text_line_height', 'text_letter_spacing',
     'text_area_x', 'text_area_y', 'text_area_width', 'text_area_height', 'text_rotation',
     'text_padding', 'text_opacity'].forEach(linkSliderToNumber);
    
    // Add event handlers
    addTextParamHandlers(seg);
    
    // Update slider states based on existing keyframes
    updateAllSliderKeyframeStates();
  }
  
  function addTextParamHandlers(seg) {
    // Text content textarea
    const textContent = document.getElementById('text_content');
    if (textContent) {
      textContent.addEventListener('input', () => {
        seg.text = textContent.value;
        seg.name = textContent.value.substring(0, 20) || 'Text';
        scheduleTimelineAutoSave();
        renderTracks(); // Update segment label
      });
      textContent.addEventListener('focus', beginParamEdit);
      textContent.addEventListener('blur', endParamEdit);
    }
    
    // Font family custom dropdown
    const fontDropdown = document.getElementById('text_font_dropdown');
    const fontInput = document.getElementById('text_font');
    if (fontDropdown && fontInput) {
      const fontSelected = fontDropdown.querySelector('.font-dropdown-selected');
      const fontList = fontDropdown.querySelector('.font-dropdown-list');

      // Toggle dropdown on click
      fontSelected.addEventListener('click', (e) => {
        e.stopPropagation();
        beginParamEdit();
        fontDropdown.classList.toggle('open');
      });

      // Handle item selection
      fontList.addEventListener('click', (e) => {
        const item = e.target.closest('.font-dropdown-item');
        if (!item) return;

        const fontName = item.dataset.font;
        const fontFamily = item.dataset.family;
        fontInput.value = fontName;
        fontSelected.textContent = fontName;
        fontSelected.style.fontFamily = fontFamily;
        fontSelected.dataset.family = fontFamily;

        // Update selected state
        fontList.querySelectorAll('.font-dropdown-item').forEach(i => i.classList.remove('selected'));
        item.classList.add('selected');

        // Close dropdown
        fontDropdown.classList.remove('open');

        // Apply font change
        const hasSelection = textEditMode && textEditSelection &&
                            textEditSelection.start !== textEditSelection.end;

        if (hasSelection) {
          const start = Math.min(textEditSelection.start, textEditSelection.end);
          const end = Math.max(textEditSelection.start, textEditSelection.end);
          applyFormatToSelection(seg, start, end, { fontFamily: fontName });
          calculateCharPositions(seg);
          renderTextOverlays();
        } else {
          seg.fontFamily = fontName;
          const font = AVAILABLE_FONTS.find(f => f.name === fontName);
          if (font) {
            const weightSelect = document.getElementById('text_weight');
            if (weightSelect) {
              const weightLabels = { 400: 'Regular', 500: 'Medium', 600: 'Semibold', 700: 'Bold' };
              weightSelect.innerHTML = font.weights.map(w =>
                `<option value="${w}" ${w === seg.fontWeight ? 'selected' : ''}>${weightLabels[w] || w}</option>`
              ).join('');
              if (!font.weights.includes(seg.fontWeight)) {
                seg.fontWeight = font.weights[0];
                weightSelect.value = seg.fontWeight;
              }
            }
          }
        }
        scheduleTimelineAutoSave();
        endParamEdit();
      });

      // Close dropdown when clicking outside
      document.addEventListener('click', (e) => {
        if (!fontDropdown.contains(e.target)) {
          fontDropdown.classList.remove('open');
        }
      });
    }

    // Font weight dropdown
    const weightSelect = document.getElementById('text_weight');
    if (weightSelect) {
      weightSelect.addEventListener('focus', beginParamEdit);
      weightSelect.addEventListener('change', () => {
        const hasSelection = textEditMode && textEditSelection &&
                            textEditSelection.start !== textEditSelection.end;

        if (hasSelection) {
          // Apply to selection as rich text
          const start = Math.min(textEditSelection.start, textEditSelection.end);
          const end = Math.max(textEditSelection.start, textEditSelection.end);
          applyFormatToSelection(seg, start, end, { fontWeight: weightSelect.value });
          calculateCharPositions(seg);
          renderTextOverlays();
        } else {
          // Apply to entire segment
          seg.fontWeight = parseInt(weightSelect.value);
        }
        scheduleTimelineAutoSave();
      });
      weightSelect.addEventListener('blur', endParamEdit);
    }

    // Font style buttons
    document.getElementById('text_style_normal')?.addEventListener('click', () => {
      saveUndoState();
      const hasSelection = textEditMode && textEditSelection &&
                          textEditSelection.start !== textEditSelection.end;

      if (hasSelection) {
        // Apply to selection as rich text
        const start = Math.min(textEditSelection.start, textEditSelection.end);
        const end = Math.max(textEditSelection.start, textEditSelection.end);
        applyFormatToSelection(seg, start, end, { fontStyle: 'normal' });
        calculateCharPositions(seg);
        renderTextOverlays();
      } else {
        // Apply to entire segment
        seg.fontStyle = 'normal';
        document.getElementById('text_style_normal').style.background = 'var(--good)';
        document.getElementById('text_style_normal').style.color = 'white';
        document.getElementById('text_style_italic').style.background = '';
        document.getElementById('text_style_italic').style.color = '';
      }
      scheduleTimelineAutoSave();
    });
    document.getElementById('text_style_italic')?.addEventListener('click', () => {
      saveUndoState();
      const hasSelection = textEditMode && textEditSelection &&
                          textEditSelection.start !== textEditSelection.end;

      if (hasSelection) {
        // Apply to selection as rich text
        const start = Math.min(textEditSelection.start, textEditSelection.end);
        const end = Math.max(textEditSelection.start, textEditSelection.end);
        applyFormatToSelection(seg, start, end, { fontStyle: 'italic' });
        calculateCharPositions(seg);
        renderTextOverlays();
      } else {
        // Apply to entire segment
        seg.fontStyle = 'italic';
        document.getElementById('text_style_italic').style.background = 'var(--good)';
        document.getElementById('text_style_italic').style.color = 'white';
        document.getElementById('text_style_normal').style.background = '';
        document.getElementById('text_style_normal').style.color = '';
      }
      scheduleTimelineAutoSave();
    });
    
    // Text alignment buttons
    ['left', 'center', 'right'].forEach(align => {
      document.getElementById(`text_align_${align}`)?.addEventListener('click', () => {
        saveUndoState();
        seg.textAlign = align;
        ['left', 'center', 'right'].forEach(a => {
          const btn = document.getElementById(`text_align_${a}`);
          if (btn) {
            btn.style.background = a === align ? 'var(--good)' : '';
            btn.style.color = a === align ? 'white' : '';
          }
        });
        scheduleTimelineAutoSave();
      });
    });
    
    // Slider handlers
    const textLayoutProps = ['fontSize', 'lineHeight', 'letterSpacing', 'padding', 'areaX', 'areaY', 'areaWidth', 'areaHeight'];
    const sliderHandler = (id, prop, parser = parseFloat) => {
      const el = document.getElementById(id);
      if (el) {
        el.addEventListener('input', () => {
          const val = parser(el.value);
          if (!isNaN(val)) {
            seg[prop] = val;
            // Recalculate cursor positions if in edit mode and property affects text layout
            if (textEditMode && textLayoutProps.includes(prop)) {
              calculateCharPositions(seg);
            }
            scheduleTimelineAutoSave();
          }
        });
        el.addEventListener('focus', beginParamEdit);
        el.addEventListener('mousedown', beginParamEdit);
        el.addEventListener('blur', endParamEdit);
        el.addEventListener('mouseup', () => setTimeout(endParamEdit, 100));
      }
    };

    // Font Size input - changes individual letter/selection size or base size
    const fontSizeEl = document.getElementById('text_font_size');
    if (fontSizeEl) {
      fontSizeEl.addEventListener('input', () => {
        const val = parseInt(fontSizeEl.value);
        if (isNaN(val) || val < 8) return;

        // Use viewportSelectedText.seg if in text edit mode to ensure we're editing the right segment
        const targetSeg = (textEditMode && viewportSelectedText) ? viewportSelectedText.seg : seg;

        const hasSelection = textEditMode && textEditSelection &&
                            textEditSelection.start !== textEditSelection.end;

        if (hasSelection) {
          // Apply fontSizeScale to selection (relative to base font size)
          const start = Math.min(textEditSelection.start, textEditSelection.end);
          const end = Math.max(textEditSelection.start, textEditSelection.end);
          const baseFontSize = targetSeg.fontSize || 48;
          const scale = val / baseFontSize;
          applyFormatToSelection(targetSeg, start, end, { fontSizeScale: scale });
          calculateCharPositions(targetSeg);
          // Auto-fit bounds to new text size
          autoFitTextBounds(targetSeg);
        } else {
          // No selection: adjust base font size
          targetSeg.fontSize = val;
          // Auto-fit bounds to new text size
          autoFitTextBounds(targetSeg);
          if (textEditMode) {
            calculateCharPositions(targetSeg);
          }
        }
        scheduleTimelineAutoSave();
      });
      fontSizeEl.addEventListener('focus', beginParamEdit);
      fontSizeEl.addEventListener('blur', endParamEdit);
    }

    // Text Scale slider - scales the entire text block proportionally
    const textScaleEl = document.getElementById('text_scale');
    if (textScaleEl) {
      textScaleEl.addEventListener('input', () => {
        const val = parseFloat(textScaleEl.value);
        if (isNaN(val)) return;
        // Use viewportSelectedText.seg if in text edit mode
        const targetSeg = (textEditMode && viewportSelectedText) ? viewportSelectedText.seg : seg;
        // Convert from percentage (25-400) to multiplier (0.25-4.0)
        targetSeg.textScale = val / 100;
        if (textEditMode) {
          calculateCharPositions(targetSeg);
        }
        scheduleTimelineAutoSave();
      });
      textScaleEl.addEventListener('focus', beginParamEdit);
      textScaleEl.addEventListener('mousedown', beginParamEdit);
      textScaleEl.addEventListener('blur', endParamEdit);
      textScaleEl.addEventListener('mouseup', () => setTimeout(endParamEdit, 100));
    }

    sliderHandler('text_line_height', 'lineHeight');

    // Letter Spacing - custom handler to support selection-based adjustment
    const letterSpacingEl = document.getElementById('text_letter_spacing');
    if (letterSpacingEl) {
      letterSpacingEl.addEventListener('input', () => {
        const val = parseFloat(letterSpacingEl.value);
        if (isNaN(val)) return;

        // Use viewportSelectedText.seg if in text edit mode
        const targetSeg = (textEditMode && viewportSelectedText) ? viewportSelectedText.seg : seg;

        const hasSelection = textEditMode && textEditSelection &&
                            textEditSelection.start !== textEditSelection.end;

        if (hasSelection) {
          // Apply letter spacing to selection via kerning array
          const start = Math.min(textEditSelection.start, textEditSelection.end);
          const end = Math.max(textEditSelection.start, textEditSelection.end);
          const text = targetSeg.text || '';

          // Initialize kerning array if needed
          if (!targetSeg.kerning) {
            targetSeg.kerning = new Array(text.length).fill(0);
          }
          // Ensure kerning array is long enough
          while (targetSeg.kerning.length < text.length) {
            targetSeg.kerning.push(0);
          }

          // Calculate the difference from base letter spacing
          const baseLetterSpacing = targetSeg.letterSpacing || 0;
          const kerningAdjustment = (val - baseLetterSpacing) * 50; // Convert to kerning units (1/1000 em scaled)

          // Apply kerning to each character in selection (except the last one)
          for (let i = start; i < end - 1; i++) {
            targetSeg.kerning[i] = kerningAdjustment;
          }

          calculateCharPositions(targetSeg);
        } else {
          // No selection: adjust base letter spacing for entire segment
          targetSeg.letterSpacing = val;
          if (textEditMode) {
            calculateCharPositions(targetSeg);
          }
        }
        scheduleTimelineAutoSave();
      });
      letterSpacingEl.addEventListener('focus', beginParamEdit);
      letterSpacingEl.addEventListener('mousedown', beginParamEdit);
      letterSpacingEl.addEventListener('blur', endParamEdit);
      letterSpacingEl.addEventListener('mouseup', () => setTimeout(endParamEdit, 100));
    }
    sliderHandler('text_area_x', 'areaX');
    sliderHandler('text_area_y', 'areaY');
    sliderHandler('text_area_width', 'areaWidth');
    sliderHandler('text_area_height', 'areaHeight');
    sliderHandler('text_rotation', 'rotation');
    sliderHandler('text_padding', 'padding', parseInt);
    sliderHandler('text_opacity', 'opacity');
    
    // Blend mode dropdown handler
    const blendSelect = document.getElementById('text_blend_mode');
    if (blendSelect) {
      blendSelect.addEventListener('change', () => {
        seg.blendMode = blendSelect.value;
        scheduleTimelineAutoSave();
      });
    }
    
    // Text color picker
    const textColor = document.getElementById('text_color');
    const textColorHex = document.getElementById('text_color_hex');
    if (textColor && textColorHex) {
      textColor.addEventListener('focus', beginParamEdit);
      textColor.addEventListener('input', () => {
        seg.textColor = textColor.value;
        textColorHex.value = textColor.value;
        scheduleTimelineAutoSave();
      });
      textColor.addEventListener('blur', endParamEdit);
      textColor.addEventListener('change', endParamEdit);
      
      textColorHex.addEventListener('focus', beginParamEdit);
      textColorHex.addEventListener('input', () => {
        if (/^#[0-9A-Fa-f]{6}$/.test(textColorHex.value)) {
          seg.textColor = textColorHex.value;
          textColor.value = textColorHex.value;
          scheduleTimelineAutoSave();
        }
      });
      textColorHex.addEventListener('blur', endParamEdit);
    }
    
    // Background color
    const bgColor = document.getElementById('text_bg_color');
    const bgEnabled = document.getElementById('text_bg_enabled');
    if (bgColor && bgEnabled) {
      const updateBgColor = () => {
        seg.backgroundColor = bgEnabled.checked ? bgColor.value : 'transparent';
        scheduleTimelineAutoSave();
      };
      bgColor.addEventListener('focus', beginParamEdit);
      bgColor.addEventListener('input', updateBgColor);
      bgColor.addEventListener('blur', endParamEdit);
      bgColor.addEventListener('change', endParamEdit);
      
      bgEnabled.addEventListener('change', () => {
        saveUndoState();
        updateBgColor();
      });
    }
    
    // Ignore post-fx checkbox handler
    const ignorePostFxCheckbox = document.getElementById('text_ignore_postfx');
    if (ignorePostFxCheckbox) {
      ignorePostFxCheckbox.addEventListener('change', () => {
        saveUndoState();
        seg.ignorePostFx = ignorePostFxCheckbox.checked;
        // Update the label text
        const label = ignorePostFxCheckbox.parentElement.querySelector('span');
        if (label) label.textContent = seg.ignorePostFx ? 'On' : 'Off';
        scheduleTimelineAutoSave();
      });
    }
  }

  // Drag handling
  let drag = null;

  function onSegPointerDown(e, el, trackOrId, index) {
    e.preventDefault();
    el.setPointerCapture(e.pointerId);

    // Handle both trackId (new system) and legacy track name
    const isTrackId = trackOrId && trackOrId.startsWith('track_');
    let segs, seg, legacyTrackName;
    
    if (isTrackId) {
      const track = getTrackById(trackOrId);
      if (!track) {
        console.warn('onSegPointerDown: track not found', { trackOrId, index });
        return;
      }
      segs = track.segments;
      seg = segs[index];
      legacyTrackName = track.type === 'media' ? 'images' : track.type + 's';
    } else {
      segs = getSegmentsForTrack(trackOrId);
      seg = segs[index];
      legacyTrackName = trackOrId;
    }
    
    // Guard against undefined segment
    if (!seg) {
      console.warn('onSegPointerDown: segment not found', { trackOrId, index });
      return;
    }

    const rect = el.getBoundingClientRect();
    const isLeft = (e.clientX - rect.left) < 10;
    const isRight = (rect.right - e.clientX) < 10;
    const type = isLeft ? "left" : isRight ? "right" : "move";

    // Use the appropriate selection function
    if (isTrackId) {
      selectSegmentInTrack(trackOrId, index);
    } else {
      selectSegment(legacyTrackName, index);
    }

    // Save state for undo before drag starts
    saveUndoState();

    const viewRect = timelineViewport.getBoundingClientRect();
    const x = e.clientX - viewRect.left + timelineViewport.scrollLeft;

    // Get source track type for cross-track dragging
    const sourceTrack = isTrackId ? getTrackById(trackOrId) : null;
    const sourceTrackType = sourceTrack ? sourceTrack.type : null;

    drag = {
      type, 
      track: legacyTrackName,  // Use legacy track name for drag operations
      trackId: isTrackId ? trackOrId : null,
      sourceTrackType,  // Track type for cross-track dragging validation
      index,
      x0: x,
      y0: e.clientY,  // Store initial Y for cross-track detection
      start0: seg.start,
      end0: seg.end,
      len: seg.end - seg.start,
      sourceOffset0: Number(seg.sourceOffset || 0),
      sourceDuration: Number(seg.sourceDuration || Infinity),
      targetTrackId: null,  // Will be set if dragging to a different track
    };

    window.addEventListener("pointermove", onDragMove);
    window.addEventListener("pointerup", onDragUp);
  }

  function snapTime(t) {
    return snapTimeToGrid(t, {
      enabled: snapChk.checked,
      bpm: bpmInput.value,
      grid: gridSel.value,
    });
  }

  function onDragMove(e) {
    if (!drag) return;
    e.preventDefault();

    const viewRect = timelineViewport.getBoundingClientRect();
    const x = e.clientX - viewRect.left + timelineViewport.scrollLeft;
    const dx = x - drag.x0;
    const dt = dx / pxPerSec;

    // Cross-track detection for "move" operations
    if (drag.type === "move" && drag.trackId && drag.sourceTrackType) {
      // Clear previous drop target highlights
      document.querySelectorAll('.trackRow.segment-drop-target').forEach(el => {
        el.classList.remove('segment-drop-target');
      });
      
      // Find which track row the cursor is over
      const trackRows = document.querySelectorAll('.trackRow[data-track-id]');
      let targetTrackId = null;
      
      for (const row of trackRows) {
        const rowRect = row.getBoundingClientRect();
        if (e.clientY >= rowRect.top && e.clientY <= rowRect.bottom) {
          const rowTrackId = row.dataset.trackId;
          const rowTrack = getTrackById(rowTrackId);
          
          // Only allow dropping on tracks of the same type (but different track)
          if (rowTrack && rowTrack.type === drag.sourceTrackType && rowTrackId !== drag.trackId) {
            row.classList.add('segment-drop-target');
            targetTrackId = rowTrackId;
          }
          break;
        }
      }
      
      drag.targetTrackId = targetTrackId;
    }

    // Get segments from specific track if available, otherwise use legacy aggregation
    let segs, seg;
    if (drag.trackId) {
      const track = getTrackById(drag.trackId);
      segs = track ? track.segments : [];
      seg = segs[drag.index];
    } else {
      segs = getSegmentsForTrack(drag.track);
      seg = segs[drag.index];
    }
    
    if (!seg) return;

    if (drag.type === "move") {
      let newStart = snapTime(drag.start0 + dt);
      newStart = clamp(newStart, 0, durationGuess - drag.len);
      
      // Get collision check segments - use target track if cross-track dragging
      let collisionSegs = segs;
      if (drag.targetTrackId) {
        const targetTrack = getTrackById(drag.targetTrackId);
        if (targetTrack) {
          collisionSegs = targetTrack.segments;
        }
      }
      
      // For scenes and shaders, prevent overlapping
      if (drag.track === 'scenes' || drag.track === 'shaders' || drag.track === 'audios') {
        newStart = moveSegmentStart({
          candidateStart: newStart,
          length: drag.len,
          duration: durationGuess,
          segments: collisionSegs,
          excludeIndex: drag.targetTrackId ? null : drag.index,
          originalStart: drag.start0,
        });
      }
      
      seg.start = newStart;
      seg.end = seg.start + drag.len;
    } else if (drag.type === "left") {
      let newStart = clamp(snapTime(drag.start0 + dt), 0, seg.end - 0.25);
      if (drag.track === 'audios') {
        newStart = Math.max(newStart, drag.start0 - drag.sourceOffset0);
      }
      
      // For scenes and shaders, prevent overlapping
      if (drag.track === 'scenes' || drag.track === 'shaders' || drag.track === 'audios') {
        newStart = resizeSegmentStart({
          candidateStart: newStart,
          end: seg.end,
          segments: segs,
          excludeIndex: drag.index,
        });
      }
      if (drag.track === 'audios') {
        const trimmed = trimAudioClipStart({
          start: drag.start0,
          end: drag.end0,
          sourceOffset: drag.sourceOffset0,
          sourceDuration: drag.sourceDuration,
        }, newStart);
        seg.start = trimmed.start;
        seg.sourceOffset = trimmed.sourceOffset;
      } else {
        seg.start = newStart;
      }
    } else if (drag.type === "right") {
      let newEnd = clamp(snapTime(drag.end0 + dt), seg.start + 0.25, durationGuess);
      
      // For scenes and shaders, prevent overlapping
      if (drag.track === 'scenes' || drag.track === 'shaders' || drag.track === 'audios') {
        newEnd = resizeSegmentEnd({
          candidateEnd: newEnd,
          start: seg.start,
          duration: durationGuess,
          segments: segs,
          excludeIndex: drag.index,
        });
      }
      if (drag.track === 'audios' && Number.isFinite(drag.sourceDuration)) {
        newEnd = Math.min(newEnd, seg.start + drag.sourceDuration - Number(seg.sourceOffset || 0));
      }
      seg.end = newEnd;
    }

    renderSegments();
    drawWaveAndRuler();
    audioClipPlayback?.sync(timelineTracks, getCurrentTime(), silentPlaying);
  }

  function onDragUp() {
    // Clear drop target highlights
    document.querySelectorAll('.trackRow.segment-drop-target').forEach(el => {
      el.classList.remove('segment-drop-target');
    });
    
    // Handle cross-track move
    if (drag && drag.type === "move" && drag.targetTrackId && drag.trackId !== drag.targetTrackId) {
      const sourceTrack = getTrackById(drag.trackId);
      const targetTrack = getTrackById(drag.targetTrackId);
      
      if (sourceTrack && targetTrack && sourceTrack.type === targetTrack.type) {
        // Get the segment from source track
        const segment = sourceTrack.segments[drag.index];
        
        if (segment) {
          moveSegmentState(
            [keyframeData, exposedParams, exposedParamsMeta, curveTypes, lfoData],
            legacyTrackName(sourceTrack.type),
            drag.trackId,
            drag.targetTrackId,
            segment,
            drag.index,
          );
          
          // Remove from source track
          sourceTrack.segments.splice(drag.index, 1);
          
          // Add to target track
          targetTrack.segments.push(segment);
          
          // Stable animation IDs do not depend on this destination index.
          const newIndex = targetTrack.segments.length - 1;
          
          // Update selection to point to new location
          selectSegmentInTrack(drag.targetTrackId, newIndex);
          
          // Re-render
          renderTracks();
          renderSegments();
        }
      }
    }
    
    drag = null;
    window.removeEventListener("pointermove", onDragMove);
    window.removeEventListener("pointerup", onDragUp);
    scheduleTimelineAutoSave();
    refreshPrimaryAudioState();
  }

  // Inspector changes
  selStart.addEventListener("change", () => {
    if (!selectedSegment) return;
    saveUndoState();
    const segs = getSegmentsForTrack(selectedSegment.track);
    segs[selectedSegment.index].start = parseFloat(selStart.value);
    renderSegments();
    scheduleTimelineAutoSave();
  });

  selEnd.addEventListener("change", () => {
    if (!selectedSegment) return;
    saveUndoState();
    const segs = getSegmentsForTrack(selectedSegment.track);
    segs[selectedSegment.index].end = parseFloat(selEnd.value);
    renderSegments();
    scheduleTimelineAutoSave();
  });

  selScene.addEventListener("change", () => {
    if (!selectedSegment) return;
    saveUndoState();
    const val = selScene.value;
    const segs = getSegmentsForTrack(selectedSegment.track);
    const seg = segs[selectedSegment.index];

    if (val.startsWith('scene:')) {
      seg.sceneIndex = parseInt(val.split(':')[1], 10);
      seg.name = `Scene ${seg.sceneIndex}`;
    }
    // PostFX changes are handled via double-click file dialog
    renderSegments();
  });

  // =========================
  // PLAYHEAD
  // =========================
  function updatePlayhead(force = false) {
    const t = getCurrentTime();
    const x = timeToX(t);
    playhead.style.transform = `translateX(${x}px)`;
    
    // Update wave playhead (now inside scrolling content, same as main playhead)
    wavePlayhead.style.transform = `translateX(${x}px)`;

    // Update time display
    timePill.textContent = `${fmtTime(t)} / ${fmtTime(durationGuess)}`;

    // Find current scene
    const sceneIdx = findSceneIndex(t);
    if (sceneIdx >= 0) {
      scenePill.textContent = `Scene: ${sceneIdx}`;
    }

    // Follow playhead (check both audio and silent mode)
    const isPlaying = silentPlaying;
    if (followChk.checked && isPlaying) {
      const { start, end, viewW } = visibleRange();
      if (t < start || t > end - 2) {
        timelineViewport.scrollLeft = Math.max(0, timeToX(t) - viewW * 0.2);
      }
    }
    
    // Update keyframe playhead if in keyframe mode
    if (editorMode === 'keyframe') {
      updateKeyframePlayhead();
    }
  }

  // Find active scene at time t
  // With multiple scene tracks, we return the LAST active scene (topmost in layer order)
  // In the future, we could support blending multiple scenes
  function findSceneIndex(t) {
    const sceneTracks = getTracksByType('scene');
    let activeSceneIdx = -1;
    
    // Iterate through tracks in order (later tracks = higher layer = shown on top)
    for (const track of sceneTracks) {
      if (!trackIsVisible(track)) continue;
      for (const seg of track.segments) {
        if (t >= seg.start && t < seg.end && seg.sceneIndex >= 0) {
          activeSceneIdx = seg.sceneIndex;
          // Don't break - keep looking for scenes in higher tracks
        }
      }
    }
    
    return activeSceneIdx;
  }
  
  // Find the active scene segment at time t (not just the sceneIndex)

  const timelinePointerController = createTimelinePointerController({
    waveCanvas,
    rulerCanvas,
    waveContainer: document.querySelector('.audio-track-waveform'),
    timelineViewport,
    headerWidth: TRACK_HEADER_WIDTH,
    windowTarget: window,
    getVisibleStart: () => visibleRange().start,
    getPixelsPerSecond: () => pxPerSec,
    getDuration: () => durationGuess,
    isLoopEnabled: () => loopEnabled,
    getLoopStart: () => loopStart,
    getLoopEnd: () => loopEnd,
    setLoopStart: value => { loopStart = value; },
    setLoopEnd: value => { loopEnd = value; },
    onSeek: time => {
      if (audioCtx.state === 'suspended') audioCtx.resume();
      silentTime = time;
      audioClipPlayback?.sync(timelineTracks, time, silentPlaying);
      updatePlayhead(true);
    },
    onLoopChange: drawWaveAndRuler,
  });
  let audioPlaybackController = null;
  let keyframeEditorController = null;
  window.addEventListener('beforeunload', () => {
    timelinePointerController.destroy();
    audioPlaybackController?.destroy();
    audioClipPlayback?.destroy();
    audioClipSources.destroy();
    keyframeEditorController?.destroy();
    disposeScenePreview();
    segmentSceneInstances.dispose();
    sceneRenderTargetPool.dispose();
    animationMixers.dispose();
    renderTargetA?.dispose();
    renderTargetB?.dispose();
    blendTempRT?.dispose();
    postFxQuad?.geometry?.dispose();
    postFxMaterial?.dispose();
    mediaOverlayTexture?.dispose();
    mediaOverlayQuad?.geometry?.dispose();
    mediaOverlayMaterial?.dispose();
    sceneCompositeQuad?.geometry?.dispose();
    sceneCompositeMaterial?.dispose();
    exportController?.destroy();
    ffmpegAdapter?.terminate();
    renderer.dispose();
  }, { once: true });
  // =========================
  // RENDER LOOP
  // =========================
  let lastTime = 0;
  let frameCount = 0;
  let fpsTime = 0;
  let compositionOutputSource = 'live';
  
  // Get current playback time (from audio or silent mode)
  function getCurrentTime() {
    return silentTime;
  }

  /**
   * Render a single frame at a specific time (for screenshots)
   * @param {number} t - Time in seconds
   */
  function renderCompositionAt(t, target = null, options = {}) {
    const previousOutputSource = compositionOutputSource;
    compositionOutputSource = options.source || 'live';
    try {
      // Apply keyframe animations
      applyKeyframesToScene(t);

      // Find all active effects (PostFX and LUTs) in track order
      const activeEffects = findActiveEffects(t);

      if (activeEffects.length > 0 && renderTargetA) {
      // === WITH EFFECTS (PostFX and/or LUTs in track order) ===
      renderUnifiedLayers(t, renderTargetA, { shaderPass: 'affected' });

      // Composite "affected" media into render target BEFORE effects processing
      const allMedia = findActiveImages(t);
      const allText = findActiveTextSegments(t);
      const hasAffectedOverlays = overlayPassHasContent(allMedia, allText, 'affected');

      if (hasAffectedOverlays && mediaOverlayMaterial) {
        renderer.setRenderTarget(null);
        compositeSceneTexture(renderTargetA.texture, 1.0, null);
        renderOverlayPass(t, 'affected', renderImageOverlays, renderTextOverlays, threeCanvas);
        mediaOverlayTexture.needsUpdate = true;
        mediaOverlayMaterial.uniforms.uBase.value = renderTargetA.texture;
        mediaOverlayMaterial.uniforms.uOverlay.value = mediaOverlayTexture;
        postFxScene.add(mediaOverlayQuad);
        postFxQuad.visible = false;
        mediaOverlayQuad.visible = true;
        renderer.setRenderTarget(renderTargetB);
        renderer.render(postFxScene, postFxCamera);
        mediaOverlayQuad.visible = false;
        postFxQuad.visible = true;
        postFxScene.remove(mediaOverlayQuad);
        [renderTargetA, renderTargetB] = [renderTargetB, renderTargetA];
      }

      // Apply unified effects chain (PostFX and LUTs interleaved by track order)
      applyEffectsChain(t, activeEffects);

      // Render shader details configured to bypass post effects.
      renderIgnoredShaderLayers(t);

      // Render "ignored" media and text on top (not affected by effects)
      renderOverlayPass(t, 'ignored', renderImageOverlays, renderTextOverlays, threeCanvas);

      } else {
      // === NO EFFECTS ===
      if (renderTargetA) {
        renderUnifiedLayers(t, renderTargetA);
        // Blit to screen
        renderer.setRenderTarget(null);
        renderer.setClearColor(0x000000, 1);
        renderer.clear();
        ensureCopyObjects();
        copyMat.map = renderTargetA.texture;
        renderer.render(copyScene, sceneCompositeCamera);
      } else {
        renderUnifiedLayers(t, null);
      }
      // Render all media and text overlays on top
      renderOverlayPass(t, 'all', renderImageOverlays, renderTextOverlays, threeCanvas);
      }
      if (isPlayMode && isIOS && compositionOutputSource === 'live') {
        const { width, height } = compositionSurfaceMetrics();
        presentWebGlBehindOverlay(
          imageOverlayCtx,
          threeCanvas,
          width,
          height,
        );
      }
      return { activeEffects, target, options };
    } finally {
      compositionOutputSource = previousOutputSource;
    }
  }

  function renderFrame(t) {
    return renderCompositionAt(t, null, { source: 'screenshot' });
  }

  function render(now) {
    if (!running) return;
    requestAnimationFrame(render);

    const delta = (now - lastTime) / 1000;
    lastTime = now;
    
    // Advance silent time when in silent playback mode
    if (silentPlaying) {
      silentTime += delta;
      
      // During export, don't loop - let the export controller handle stopping
      if (!exportInProgress) {
        // Handle loop mode
        if (loopEnabled && silentTime >= loopEnd) {
          silentTime = loopStart;
        }
        // Loop at end of timeline
        else if (silentTime >= durationGuess) {
          silentTime = loopEnabled ? loopStart : 0;
        }
      }
      // Update playhead position
      updatePlayhead();
    }

    audioClipPlayback?.sync(
      timelineTracks,
      silentTime,
      silentPlaying,
      { continuous: true },
    );

    // FPS counter
    frameCount++;
    if (now - fpsTime > 1000) {
      fpsPill.textContent = `FPS: ${frameCount}`;
      frameCount = 0;
      fpsTime = now;
      if (urlParams.has('debugResources')) {
        threeCanvas.dataset.resourceStats = JSON.stringify(getEditorResourceStats());
      }
    }

    const t = getCurrentTime();
    
    // Update all animation mixers (for GLTF/GLB/FBX model animations)
    animationMixers.updateAll(delta);
    
    const { activeEffects } = renderCompositionAt(t, null, { source: 'live' });

    // Update playhead
    updatePlayhead();

    // HUD
    const audio = measureAudio();
    window.currentAudioData = {
      bass: audio.bass,
      mid: audio.mid,
      treble: audio.treble,
      volume: (audio.bass + audio.mid + audio.treble) / 3
    };
    const fxNames = activeEffects.filter(e => e.type === 'postfx').map(f => f.seg.name).join(', ') || 'None';
    const activeImages = findActiveImages(t);
    const imgNames = activeImages.map(i => i.seg.name).join(', ') || 'None';
    const shaderNames = findActiveShaders(t).map(s => s.seg.name).join(', ') || 'None';
    const sceneLayers = getActiveLayers(t).filter(l => l.type === 'scene');
    const sceneInfo = sceneLayers.map(l => l.data?.sceneIndex).join(',') || '-1';
    hud.textContent = `Time: ${t.toFixed(2)}s\nScenes: ${sceneInfo}\nShaders: ${shaderNames}\nMedia: ${imgNames}\nPostFX: ${fxNames}\nBass: ${audio.bass.toFixed(2)}`;
  }

  // Create a Three.js material from a compiled shader
  function createPostFxMaterial(shader, seg) {
    // Check if we already have a material cached
    if (shader.threeMaterial) {
      // Material already exists, but still load textures for this specific segment
      if (seg && seg.textures && Object.keys(seg.textures).length > 0) {
        setTimeout(() => loadPostFxSegmentTextures(seg), 0);
      }
      return shader.threeMaterial;
    }
    
    // We need to create a Three.js material from the raw GLSL
    // The shader was compiled for raw WebGL, so let's create a new Three.js version
    let shaderSource = shader.source || '';
    
    // Strip any uniform declarations that we inject ourselves to prevent redefinition errors
    // This handles AI-generated shaders that incorrectly include these declarations
    const standardUniformPatterns = [
      /uniform\s+sampler2D\s+uTexture\s*;/g,
      /uniform\s+vec2\s+uResolution\s*;/g,
      /uniform\s+float\s+uTime\s*;/g,
      /uniform\s+float\s+uOpacity\s*;/g,
      /uniform\s+float\s+uIntensity\s*;/g,
      /uniform\s+float\s+uBloomStrength\s*;/g,
      /uniform\s+float\s+uBloomThreshold\s*;/g,
      /uniform\s+float\s+uVignetteStrength\s*;/g,
      /uniform\s+float\s+uChromaticAberration\s*;/g,
      /uniform\s+float\s+uSaturation\s*;/g,
      /uniform\s+float\s+uContrast\s*;/g,
      /uniform\s+float\s+uBrightness\s*;/g,
      /uniform\s+vec3\s+uColorTint\s*;/g,
    ];
    for (const pattern of standardUniformPatterns) {
      shaderSource = shaderSource.replace(pattern, '// (uniform stripped - injected by system)');
    }
    const shaderParams = Array.isArray(shader.params) ? shader.params : [];
    const textureParams = Array.isArray(shader.textureParams) ? shader.textureParams : [];
    
    // Build uniforms object with defaults
    const uniforms = {
      uTexture: { value: null },
      uResolution: { value: new THREE.Vector2(1, 1) },
      uTime: { value: 0 },
      uOpacity: { value: 1.0 },
      uIntensity: { value: 1.0 },
      // Standard tweakable uniforms (used as fallback)
      uBloomStrength: { value: 0.6 },
      uBloomThreshold: { value: 0.3 },
      uVignetteStrength: { value: 0.5 },
      uChromaticAberration: { value: 0.003 },
      uSaturation: { value: 1.0 },
      uContrast: { value: 1.0 },
      uBrightness: { value: 1.0 },
      uColorTint: { value: new THREE.Vector3(1.0, 1.0, 1.0) },
    };
    
    // Add custom uniforms from shader params
    for (const param of shaderParams) {
      if (!uniforms[param.uniform]) {
        uniforms[param.uniform] = { value: param.default };
      }
    }
    
    // Add texture uniforms with default white texture (so sampling never fails)
    if (!window._defaultWhiteTexture) {
      const canvas = document.createElement('canvas');
      canvas.width = 1;
      canvas.height = 1;
      const ctx = canvas.getContext('2d');
      ctx.fillStyle = 'white';
      ctx.fillRect(0, 0, 1, 1);
      window._defaultWhiteTexture = new THREE.CanvasTexture(canvas);
    }
    for (const texParam of textureParams) {
      if (!uniforms[texParam.uniform]) {
        uniforms[texParam.uniform] = { value: window._defaultWhiteTexture };
      }
    }
    
    // Build uniform declarations for fragment shader
    let customUniformDeclarations = '';
    for (const param of shaderParams) {
      // Skip standard uniforms that are already declared
      const standardUniforms = ['uTexture', 'uResolution', 'uTime', 'uIntensity', 
        'uBloomStrength', 'uBloomThreshold', 'uVignetteStrength', 'uChromaticAberration',
        'uSaturation', 'uContrast', 'uBrightness', 'uColorTint'];
      if (!standardUniforms.includes(param.uniform)) {
        customUniformDeclarations += `uniform float ${param.uniform};\n`;
      }
    }
    
    // Add texture uniform declarations (skip standard uniforms to prevent redefinition)
    const standardTextureUniforms = ['uTexture'];
    for (const texParam of textureParams) {
      if (!standardTextureUniforms.includes(texParam.uniform)) {
        customUniformDeclarations += `uniform sampler2D ${texParam.uniform};\n`;
      }
    }
    
    try {
      const material = new THREE.ShaderMaterial({
        uniforms: uniforms,
        vertexShader: `
          varying vec2 vUv;
          void main() {
            vUv = uv;
            gl_Position = vec4(position, 1.0);
          }
        `,
        fragmentShader: `
          precision highp float;
          uniform sampler2D uTexture;
          uniform vec2 uResolution;
          uniform float uTime;
          uniform float uOpacity;
          uniform float uIntensity;
          uniform float uBloomStrength;
          uniform float uBloomThreshold;
          uniform float uVignetteStrength;
          uniform float uChromaticAberration;
          uniform float uSaturation;
          uniform float uContrast;
          uniform float uBrightness;
          uniform vec3 uColorTint;
          ${customUniformDeclarations}
          varying vec2 vUv;
          
          ${shaderSource}
          
          // Post-process color adjustments
          vec3 applyColorAdjustments(vec3 col) {
            // Brightness
            col *= uBrightness;
            
            // Contrast
            col = (col - 0.5) * uContrast + 0.5;
            
            // Saturation
            float gray = dot(col, vec3(0.299, 0.587, 0.114));
            col = mix(vec3(gray), col, uSaturation);
            
            // Color tint
            col *= uColorTint;
            
            return clamp(col, 0.0, 1.0);
          }
          
          void main() {
            // Get original color from input texture
            vec4 original = texture(uTexture, vUv);
            
            // Apply the effect
            vec4 col = vec4(0.0);
            mainImage(col, gl_FragCoord.xy);
            col.rgb = applyColorAdjustments(col.rgb);
            
            // Blend between original and effect based on opacity
            gl_FragColor = mix(original, col, uOpacity);
          }
        `,
      });
      
      // Check for shader compilation errors
      material.onBeforeCompile = function(shaderObj) {
        // Store for error logging
        material.userData.compiledShader = shaderObj;
      };
      
      shader.threeMaterial = material;
      
      // Load any saved textures for this segment (async, non-blocking)
      if (seg && seg.textures && Object.keys(seg.textures).length > 0) {
        // Use setTimeout to avoid blocking material return
        setTimeout(() => loadPostFxSegmentTextures(seg), 0);
      }
      
      return material;
    } catch (err) {
      console.error('Failed to create post-fx material:', err);
      return null;
    }
  }
  
  // Store video elements for texture uniforms (for cleanup and playback control)
  const shaderTextureVideos = new Map(); // key: `${shaderFile}_${uniformName}`, value: video element
  
  // Apply a texture (image or video) to a post-fx shader uniform
  async function applyPostFxTexture(seg, uniformName, texturePath) {
    const shader = seg.shaderFile ? loadedPostFxShaders[seg.shaderFile] : null;
    if (!shader?.threeMaterial) return;
    
    const material = shader.threeMaterial;
    if (!material.uniforms[uniformName]) {
      material.uniforms[uniformName] = { value: null };
    }
    
    // Clean up old texture/video
    const videoKey = `postfx_${seg.shaderFile}_${uniformName}`;
    if (shaderTextureVideos.has(videoKey)) {
      const oldVideo = shaderTextureVideos.get(videoKey);
      oldVideo.pause();
      oldVideo.src = '';
      shaderTextureVideos.delete(videoKey);
    }
    if (material.uniforms[uniformName].value) {
      material.uniforms[uniformName].value.dispose();
    }
    
    try {
      const fullPath = '/' + texturePath;
      
      if (isVideoFile(texturePath)) {
        // Load video texture
        const video = document.createElement('video');
        video.src = fullPath;
        video.crossOrigin = 'anonymous';
        video.loop = true;
        video.muted = true;
        video.playsInline = true;
        video.preload = 'auto';
        
        await new Promise((resolve, reject) => {
          video.onloadeddata = resolve;
          video.onerror = reject;
        });
        
        const texture = new THREE.VideoTexture(video);
        texture.wrapS = THREE.RepeatWrapping;
        texture.wrapT = THREE.RepeatWrapping;
        texture.minFilter = THREE.LinearFilter;
        texture.magFilter = THREE.LinearFilter;
        
        material.uniforms[uniformName].value = texture;
        shaderTextureVideos.set(videoKey, video);
        video.play().catch(err => console.warn('[PostFX] Video autoplay blocked:', err));
        
        console.log(`[PostFX] Applied video texture to ${uniformName}:`, texturePath);
      } else {
        // Load image texture
        const textureLoader = new THREE.TextureLoader();
        const texture = await new Promise((resolve, reject) => {
          textureLoader.load(
            fullPath,
            (tex) => {
              tex.wrapS = THREE.RepeatWrapping;
              tex.wrapT = THREE.RepeatWrapping;
              tex.minFilter = THREE.LinearFilter;
              tex.magFilter = THREE.LinearFilter;
              resolve(tex);
            },
            undefined,
            reject
          );
        });
        
        material.uniforms[uniformName].value = texture;
        console.log(`[PostFX] Applied image texture to ${uniformName}:`, texturePath);
      }
    } catch (err) {
      console.error(`[PostFX] Failed to load texture for ${uniformName}:`, err);
    }
  }
  
  // Clear a texture from a post-fx shader uniform
  function clearPostFxTexture(seg, uniformName) {
    const shader = seg.shaderFile ? loadedPostFxShaders[seg.shaderFile] : null;
    if (!shader?.threeMaterial) return;
    
    const material = shader.threeMaterial;
    
    // Clean up video if any
    const videoKey = `postfx_${seg.shaderFile}_${uniformName}`;
    if (shaderTextureVideos.has(videoKey)) {
      const video = shaderTextureVideos.get(videoKey);
      video.pause();
      video.src = '';
      shaderTextureVideos.delete(videoKey);
    }
    
    if (material.uniforms[uniformName]?.value) {
      material.uniforms[uniformName].value.dispose();
      material.uniforms[uniformName].value = null;
      console.log(`[PostFX] Cleared texture from ${uniformName}`);
    }
  }
  
  // Load textures for a post-fx segment (called when loading project)
  // Standard uniforms that shouldn't be loaded as user textures
  const POSTFX_STANDARD_TEXTURE_UNIFORMS = ['uTexture'];
  
  async function loadPostFxSegmentTextures(seg) {
    if (!seg.textures) return;
    
    // Prevent repeated loading - mark segment as having textures loaded
    if (seg._texturesLoaded) return;
    seg._texturesLoaded = true;
    
    const shader = seg.shaderFile ? loadedPostFxShaders[seg.shaderFile] : null;
    if (!shader) {
      seg._texturesLoaded = false; // Reset if shader not ready yet
      return;
    }
    
    for (const [uniformName, texturePath] of Object.entries(seg.textures)) {
      // Skip standard uniforms that are built-in (not user-selectable)
      if (POSTFX_STANDARD_TEXTURE_UNIFORMS.includes(uniformName)) {
        console.log(`[PostFX] Skipping standard uniform ${uniformName} - removing from seg.textures`);
        delete seg.textures[uniformName];
        continue;
      }
      if (texturePath) {
        await applyPostFxTexture(seg, uniformName, texturePath);
      }
    }
  }
  
  // Apply a texture (image or video) to a regular shader uniform
  async function applyShaderTexture(seg, uniformName, texturePath) {
    const shader = seg.shaderFile ? loadedShaders[seg.shaderFile] : null;
    if (!shader?.material) return;
    
    const material = shader.material;
    if (!material.uniforms[uniformName]) {
      material.uniforms[uniformName] = { value: null };
    }
    
    // Clean up old texture/video
    const videoKey = `shader_${seg.shaderFile}_${uniformName}`;
    if (shaderTextureVideos.has(videoKey)) {
      const oldVideo = shaderTextureVideos.get(videoKey);
      oldVideo.pause();
      oldVideo.src = '';
      shaderTextureVideos.delete(videoKey);
    }
    if (material.uniforms[uniformName].value) {
      material.uniforms[uniformName].value.dispose();
    }
    
    try {
      const fullPath = '/' + texturePath;
      
      if (isVideoFile(texturePath)) {
        // Load video texture
        const video = document.createElement('video');
        video.src = fullPath;
        video.crossOrigin = 'anonymous';
        video.loop = true;
        video.muted = true;
        video.playsInline = true;
        video.preload = 'auto';
        
        await new Promise((resolve, reject) => {
          video.onloadeddata = resolve;
          video.onerror = reject;
        });
        
        const texture = new THREE.VideoTexture(video);
        texture.wrapS = THREE.RepeatWrapping;
        texture.wrapT = THREE.RepeatWrapping;
        texture.minFilter = THREE.LinearFilter;
        texture.magFilter = THREE.LinearFilter;
        
        material.uniforms[uniformName].value = texture;
        shaderTextureVideos.set(videoKey, video);
        video.play().catch(err => console.warn('[Shader] Video autoplay blocked:', err));
        
        console.log(`[Shader] Applied video texture to ${uniformName}:`, texturePath);
        return;
      }
      
      // Load image texture
      const textureLoader = new THREE.TextureLoader();
      const texture = await new Promise((resolve, reject) => {
        textureLoader.load(
          fullPath,
          (tex) => {
            tex.wrapS = THREE.RepeatWrapping;
            tex.wrapT = THREE.RepeatWrapping;
            tex.minFilter = THREE.LinearFilter;
            tex.magFilter = THREE.LinearFilter;
            resolve(tex);
          },
          undefined,
          reject
        );
      });
      
      material.uniforms[uniformName].value = texture;
      console.log(`[Shader] Applied image texture to ${uniformName}:`, texturePath);
    } catch (err) {
      console.error(`[Shader] Failed to load texture for ${uniformName}:`, err);
    }
  }
  
  // Clear a texture from a regular shader uniform
  function clearShaderTexture(seg, uniformName) {
    const shader = seg.shaderFile ? loadedShaders[seg.shaderFile] : null;
    if (!shader?.material) return;
    
    const material = shader.material;
    
    // Clean up video if any
    const videoKey = `shader_${seg.shaderFile}_${uniformName}`;
    if (shaderTextureVideos.has(videoKey)) {
      const video = shaderTextureVideos.get(videoKey);
      video.pause();
      video.src = '';
      shaderTextureVideos.delete(videoKey);
    }
    
    if (material.uniforms[uniformName]?.value) {
      material.uniforms[uniformName].value.dispose();
      material.uniforms[uniformName].value = null;
      console.log(`[Shader] Cleared texture from ${uniformName}`);
    }
  }
  
  // Load textures for a shader segment (called when loading project)
  // Standard uniforms that shouldn't be loaded as user textures
  const SHADER_STANDARD_TEXTURE_UNIFORMS = ['iChannel0', 'uTexture'];
  
  async function loadShaderSegmentTextures(seg) {
    if (!seg.textures) return;
    
    // Prevent repeated loading - mark segment as having textures loaded
    if (seg._texturesLoaded) return;
    seg._texturesLoaded = true;
    
    const shader = seg.shaderFile ? loadedShaders[seg.shaderFile] : null;
    if (!shader) {
      seg._texturesLoaded = false; // Reset if shader not ready yet
      return;
    }
    
    for (const [uniformName, texturePath] of Object.entries(seg.textures)) {
      // Skip standard uniforms that are built-in (not user-selectable)
      if (SHADER_STANDARD_TEXTURE_UNIFORMS.includes(uniformName)) {
        console.log(`[Shader] Skipping standard uniform ${uniformName} - removing from seg.textures`);
        delete seg.textures[uniformName];
        continue;
      }
      if (texturePath) {
        await applyShaderTexture(seg, uniformName, texturePath);
      }
    }
  }

  function animateScene(scene, data, sceneTime, globalTime) {
    scene.traverse((obj) => {
      if (obj.isMesh && obj.userData.animation) {
        const anim = obj.userData.animation;
        const pos = obj.userData.position;
        
        if (anim.rotate) {
          obj.rotation.x += (anim.rotate.x || 0) * 0.016;
          obj.rotation.y += (anim.rotate.y || 0) * 0.016;
          obj.rotation.z += (anim.rotate.z || 0) * 0.016;
        }
        
        if (anim.float) {
          // Handle both array and object position formats
          const baseY = Array.isArray(pos) ? (pos[1] || 0) : (pos?.y || 0);
          obj.position.y = baseY + Math.sin(globalTime * (anim.float.speed || 1)) * (anim.float.amplitude || 0.5);
        }

        if (anim.pulse) {
          const baseScale = typeof obj.userData.scale === 'number' ? obj.userData.scale : 1;
          const scale = 1 + Math.sin(globalTime * (anim.pulse.speed || 2)) * (anim.pulse.amount || 0.1);
          obj.scale.setScalar(scale * baseScale);
        }
      }
    });
  }

  function animateLights(animatedLights, sceneTime, globalTime) {
    if (!animatedLights || animatedLights.length === 0) return;
    
    for (const light of animatedLights) {
      const anim = light.userData?.animation;
      if (!anim) continue;
      
      if (anim.type === 'tunnel') {
        const startZ = anim.startZ ?? 50;
        const endZ = anim.endZ ?? -100;
        const speed = anim.speed || 0.1;
        const radius = anim.radius || 2;
        
        // Calculate Z position (ping-pong or looping)
        const tunnelLength = Math.abs(endZ - startZ);
        const direction = endZ > startZ ? 1 : -1;
        
        // Use sine wave for smooth back-and-forth motion
        const t = globalTime * speed;
        const pingPong = (Math.sin(t) + 1) * 0.5; // 0 to 1
        const z = startZ + (endZ - startZ) * pingPong;
        
        // Optional wobble in X and Y
        let x = 0, y = 0;
        if (anim.wobble) {
          const wobbleSpeed = anim.wobbleSpeed || 0.5;
          x = Math.sin(globalTime * wobbleSpeed * 2.3) * radius;
          y = Math.cos(globalTime * wobbleSpeed * 1.7) * radius * 0.7;
        }
        
        light.position.set(x, y, z);
      }
      
      if (anim.type === 'orbit') {
        const radius = anim.radius || 5;
        const height = anim.height || 0;
        const speed = anim.speed || 1;
        const centerX = anim.center?.x || 0;
        const centerY = anim.center?.y || 0;
        const centerZ = anim.center?.z || 0;
        
        const angle = globalTime * speed;
        light.position.x = centerX + Math.cos(angle) * radius;
        light.position.y = centerY + height + Math.sin(globalTime * 0.5) * 0.5;
        light.position.z = centerZ + Math.sin(angle) * radius;
      }
      
      // Intensity pulsing
      if (anim.pulse) {
        const baseIntensity = anim.baseIntensity || light.userData.originalIntensity || 1;
        const pulseAmount = anim.pulse.amount || 0.3;
        const pulseSpeed = anim.pulse.speed || 2;
        light.intensity = baseIntensity * (1 + Math.sin(globalTime * pulseSpeed) * pulseAmount);
      }
    }
  }

  function animateCamera(camera, camData, sceneTime, globalTime) {
    const anim = camData.animation;
    if (!anim) return;

    if (anim.orbit) {
      const angle = globalTime * (anim.orbit.speed ?? 0.5);
      const radius = anim.orbit.radius ?? 10;
      let height = anim.orbit.height ?? 5;
      
      // Add vertical oscillation if defined
      if (anim.orbit.verticalOscillation) {
        const vosc = anim.orbit.verticalOscillation;
        const voscAmp = vosc.amplitude ?? 0;
        const voscSpd = vosc.speed ?? 0.3;
        height += Math.sin(globalTime * voscSpd) * voscAmp;
      }
      
      const target = anim.orbit.target || {};
      camera.position.x = (target.x ?? 0) + Math.sin(angle) * radius;
      camera.position.z = (target.z ?? 0) + Math.cos(angle) * radius;
      camera.position.y = height;
      camera.lookAt(target.x ?? 0, target.y ?? 0, target.z ?? 0);
    }

    if (anim.flyover) {
      const fo = anim.flyover;
      const speed = fo.speed ?? 5;
      const height = fo.height ?? 3;
      const heightWave = fo.heightWave ?? 0.5;
      const sway = fo.sway ?? 2;
      const lookAhead = fo.lookAhead ?? 20;
      const lookDown = fo.lookDown ?? 1;
      
      // Move forward along Z axis (or custom direction)
      const dirX = fo.directionX ?? 0;
      const dirZ = fo.directionZ ?? -1;
      
      const startX = fo.startX ?? 0;
      const startZ = fo.startZ ?? 30;
      let pathX = startX + globalTime * speed * dirX;
      let pathZ = startZ + globalTime * speed * dirZ;
      let lookDirX = dirX;
      let lookDirZ = dirZ;

      // Long flyover presets can define an endpoint so travel stays within
      // the generated terrain instead of continuing past its geometry.
      if (Number.isFinite(fo.endX) || Number.isFinite(fo.endZ)) {
        const endX = Number.isFinite(fo.endX) ? fo.endX : startX;
        const endZ = Number.isFinite(fo.endZ) ? fo.endZ : startZ;
        const deltaX = endX - startX;
        const deltaZ = endZ - startZ;
        const pathLength = Math.max(0.001, Math.hypot(deltaX, deltaZ));
        const progress = (globalTime * speed) % pathLength;
        lookDirX = deltaX / pathLength;
        lookDirZ = deltaZ / pathLength;
        pathX = startX + lookDirX * progress;
        pathZ = startZ + lookDirZ * progress;
      }

      // Position: move forward with gentle height bobbing and side sway
      camera.position.x = pathX + Math.sin(globalTime * 0.3) * sway;
      camera.position.z = pathZ;
      camera.position.y = height + Math.sin(globalTime * 0.5) * heightWave;
      
      // Look ahead in direction of travel with slight downward angle
      camera.lookAt(
        camera.position.x + lookAhead * lookDirX + Math.sin(globalTime * 0.2) * sway * 0.5,
        camera.position.y - lookDown,
        camera.position.z + lookAhead * lookDirZ
      );
    }

    if (anim.tunnel) {
      const tn = anim.tunnel;
      const speed = tn.speed ?? 10;
      const wobble = tn.wobble ?? 0.5;
      const wobbleSpeed = tn.wobbleSpeed ?? 0.5;
      const startZ = tn.startZ ?? 50;
      const endZ = tn.endZ ?? -150;
      
      // Calculate Z position moving through tunnel (looping)
      // Direction: positive speed moves from startZ toward endZ
      const tunnelLength = Math.abs(endZ - startZ);
      const direction = endZ > startZ ? 1 : -1;
      const progress = (globalTime * speed) % tunnelLength;
      const loopedZ = startZ + direction * progress;
      
      // Gentle wobble in X and Y (staying centered in tunnel)
      const wobbleX = Math.sin(globalTime * wobbleSpeed) * wobble;
      const wobbleY = Math.cos(globalTime * wobbleSpeed * 0.7) * wobble * 0.5;
      
      camera.position.x = wobbleX;
      camera.position.y = wobbleY;
      camera.position.z = loopedZ;
      
      // Look ahead into the tunnel (in direction of travel)
      camera.lookAt(
        wobbleX * 0.3,
        wobbleY * 0.3,
        loopedZ + direction * 30
      );
    }
  }

  // =========================
  // CONTROLS
  // =========================
  function togglePlayPause() {
    // Resume audio context if suspended (browser autoplay policy)
    if (audioCtx.state === 'suspended') {
      audioCtx.resume();
    }
    
    if (!silentPlaying) {
      // If any clip is selected, jump to its start before playing
      if (selectedSegment) {
        const seg = getCurrentSegment();
        if (seg && typeof seg.start === 'number') {
          silentTime = seg.start;
        }
      }
      silentPlaying = true;
      audioClipPlayback?.sync(timelineTracks, silentTime, true);
      resumeAllAnimations();
      if (!editorWakeLock) requestEditorWakeLock();
    } else {
      silentPlaying = false;
      audioClipPlayback?.pauseAll();
      pauseAllAnimations();
      releaseEditorWakeLock();
    }
    updatePlayPauseButton();
  }

  function updatePlayPauseButton() {
    const iconPlay = document.getElementById('iconPlay');
    const iconPause = document.getElementById('iconPause');
    // Check both audio state and silent mode
    const isPlaying = silentPlaying;
    if (!isPlaying) {
      iconPlay.style.display = '';
      iconPause.style.display = 'none';
      btnPlayPause.classList.add("primary");
    } else {
      iconPlay.style.display = 'none';
      iconPause.style.display = '';
      btnPlayPause.classList.remove("primary");
    }
  }

  // Loop button handler
  const btnLoop = document.getElementById('btnLoop');
  
  function toggleLoop() {
    loopEnabled = !loopEnabled;
    
    if (loopEnabled) {
      // Set loop region based on selected segment, or default to current position + 10s
      const seg = getCurrentSegment();
      if (seg && typeof seg.start === 'number' && typeof seg.end === 'number') {
        loopStart = seg.start;
        loopEnd = seg.end;
      } else {
        const t = getCurrentTime();
        loopStart = t;
        loopEnd = Math.min(t + 10, durationGuess);
      }
    }
    
    updateLoopButton();
    drawWaveAndRuler(); // Redraw to show/hide loop region
  }
  
  function updateLoopButton() {
    if (loopEnabled) {
      btnLoop.classList.add('loop-active');
      btnLoop.title = 'Loop ON (L) - Click to disable';
    } else {
      btnLoop.classList.remove('loop-active');
      btnLoop.title = 'Loop Selection (L)';
    }
  }
  
  audioPlaybackController = createAudioPlaybackController({
    // Timeline clips own audible transport. The legacy element is retained only
    // for waveform metadata and must never become a second playback source.
    audio: null,
    playButton: btnPlayPause,
    skipButton: btnSkipToStart,
    loopButton: btnLoop,
    documentTarget: document,
    isSilentPlaying: () => silentPlaying,
    setSilentTime: value => { silentTime = value; },
    getLoop: () => ({ enabled: loopEnabled, start: loopStart, end: loopEnd }),
    togglePlayback: togglePlayPause,
    toggleLoop,
    updateButton: updatePlayPauseButton,
    updatePlayhead,
    resumeAnimations: resumeAllAnimations,
    pauseAnimations: pauseAllAnimations,
    acquireWakeLock: async () => {
      if (!editorWakeLock) await requestEditorWakeLock();
    },
    releaseWakeLock: releaseEditorWakeLock,
    toggleFullscreen: () => fullscreenController.toggle(),
    isFullscreen: () => fullscreenController.isFullscreen(),
    exitFullscreen: () => fullscreenController.exit(),
    logger: console,
  });

  // Fullscreen button

  // Viewport text picking
  canvasContainer.addEventListener('pointerdown', onViewportPointerDown);
  canvasContainer.addEventListener('pointermove', onViewportPointerMoveHover);

  // Handle text edit focus when clicking outside the canvas container
  document.addEventListener('pointerdown', (e) => {
    if (!textEditMode) return;

    // Check if click is inside the canvas container
    if (canvasContainer.contains(e.target)) return;

    // Check if click is inside the params panel - just unfocus (keep selection)
    if (paramPanel.contains(e.target)) {
      unfocusTextEdit();
      return;
    }

    // Click is elsewhere (timeline, menus, etc) - exit text edit mode completely
    exitTextEditMode();
  }, true); // Use capture phase to run before other handlers

  // =========================
  // VIEWPORT TEXT EDITING KEYBOARD HANDLER
  // =========================
  // Use capture phase to handle text editing before other shortcuts
  document.addEventListener('keydown', handleTextEditKeydown, true);

  function handleTextEditKeydown(e) {
    // Handle selected text (not in edit mode) - arrow keys to move
    if (!textEditMode && viewportSelectedText && viewportSelectedText.seg) {
      const seg = viewportSelectedText.seg;

      // Vertical arrows can still move the text box. Horizontal arrows are
      // reserved for playhead navigation throughout the editor.
      if (
        ['ArrowUp', 'ArrowDown'].includes(e.key)
        && !e.altKey
        && !e.metaKey
        && !e.ctrlKey
      ) {
        // Don't capture if an input element is focused
        const activeEl = document.activeElement;
        if (activeEl && (activeEl.tagName === 'INPUT' || activeEl.tagName === 'TEXTAREA' || activeEl.tagName === 'SELECT')) {
          return;
        }

        e.preventDefault();
        e.stopPropagation();

        // Calculate movement amount
        const dpr = Math.min(2, window.devicePixelRatio);
        const canvasH = imageOverlayCanvas.height / dpr;

        // 1 pixel for normal, 10 pixels for shift
        const pixelAmount = e.shiftKey ? 10 : 1;
        const pctY = (pixelAmount / canvasH) * 100;

        // Save undo state on first key press
        if (!viewportTextDrag) {
          saveUndoState();
        }

        switch (e.key) {
          case 'ArrowUp':
            seg.areaY = Math.max(0, seg.areaY - pctY);
            break;
          case 'ArrowDown':
            seg.areaY = Math.min(100, seg.areaY + pctY);
            break;
        }

        // Update UI sliders
        const ySlider = document.getElementById('text_area_y');
        if (ySlider) {
          ySlider.value = seg.areaY;
          updateSliderThumb(ySlider);
          const yNum = document.getElementById('text_area_y_num');
          if (yNum) yNum.value = Math.round(seg.areaY);
        }

        scheduleTimelineAutoSave();
        return;
      }

      // Enter to start editing
      if (e.key === 'Enter') {
        e.preventDefault();
        e.stopPropagation();
        enterTextEditMode(seg);
        return;
      }

      // Delete/Backspace to delete selected text segment
      if (e.key === 'Delete' || e.key === 'Backspace') {
        // Don't capture if an input element is focused
        const activeEl = document.activeElement;
        if (activeEl && (activeEl.tagName === 'INPUT' || activeEl.tagName === 'TEXTAREA' || activeEl.tagName === 'SELECT')) {
          return;
        }
        // We could add segment deletion here in the future
        return;
      }

      return;
    }

    // Only handle when in text edit mode
    if (!textEditMode || !viewportSelectedText || !viewportSelectedText.seg) return;

    const seg = viewportSelectedText.seg;
    const text = seg.text || '';
    const hasSelection = textEditSelection && textEditSelection.start !== textEditSelection.end;

    // Handle Escape - exit edit mode (works even when unfocused)
    if (e.key === 'Escape') {
      e.preventDefault();
      e.stopPropagation();
      exitTextEditMode();
      return;
    }

    // If not focused (e.g., typing in params panel), don't capture other keys
    if (!textEditFocused) return;

    // Handle Enter - insert newline
    if (e.key === 'Enter' && !e.metaKey && !e.ctrlKey) {
      e.preventDefault();
      e.stopPropagation();
      insertTextAtCursor('\n');
      return;
    }

    // Handle Backspace
    if (e.key === 'Backspace') {
      e.preventDefault();
      e.stopPropagation();
      if (hasSelection) {
        deleteSelection();
      } else if (textEditCursor > 0) {
        const deleteIdx = textEditCursor - 1;
        seg.text = text.slice(0, deleteIdx) + text.slice(textEditCursor);
        textEditCursor--;
        // Update kerning array if exists
        if (seg.kerning && seg.kerning.length > deleteIdx) {
          seg.kerning.splice(deleteIdx, 1);
        }
        // Adjust format ranges for deletion
        adjustRangesForEdit(seg, deleteIdx, 0, 1);
        onTextChanged();
      }
      return;
    }

    // Handle Delete
    if (e.key === 'Delete') {
      e.preventDefault();
      e.stopPropagation();
      if (hasSelection) {
        deleteSelection();
      } else if (textEditCursor < text.length) {
        seg.text = text.slice(0, textEditCursor) + text.slice(textEditCursor + 1);
        // Update kerning array if exists
        if (seg.kerning && seg.kerning.length > textEditCursor) {
          seg.kerning.splice(textEditCursor, 1);
        }
        // Adjust format ranges for deletion
        adjustRangesForEdit(seg, textEditCursor, 0, 1);
        onTextChanged();
      }
      return;
    }

    // Arrow key navigation
    if (e.key === 'ArrowLeft') {
      e.preventDefault();
      e.stopPropagation();

      // Alt+Arrow with selection: Adjust tracking (letter spacing)
      if (e.altKey && hasSelection) {
        const delta = (e.metaKey || e.ctrlKey) ? -2 : -0.5;
        seg.letterSpacing = Math.max(-10, (seg.letterSpacing || 0) + delta);
        onTextChanged();
        updateTextParamSlider('text_letter_spacing', seg.letterSpacing);
        // Keep cursor at end of selection for consistent feel
        textEditCursor = Math.max(textEditSelection.start, textEditSelection.end);
        resetCursorBlink();
        return;
      }

      // Alt+Arrow without selection: Kerning adjustment
      if (e.altKey && !hasSelection && textEditCursor > 0) {
        adjustKerning(textEditCursor - 1, e.metaKey || e.ctrlKey ? -100 : -20);
        return;
      }

      // Cmd/Ctrl+Left: Jump to word boundary
      if (e.metaKey || e.ctrlKey) {
        const newPos = findWordBoundary(text, textEditCursor, -1);
        if (e.shiftKey) {
          extendSelection(newPos);
        } else {
          textEditCursor = newPos;
          textEditSelection = null;
        }
      } else if (e.shiftKey) {
        // Shift+Left: Extend selection
        extendSelection(Math.max(0, textEditCursor - 1));
      } else {
        // Plain Left: Move cursor
        if (hasSelection) {
          textEditCursor = Math.min(textEditSelection.start, textEditSelection.end);
          textEditSelection = null;
        } else {
          textEditCursor = Math.max(0, textEditCursor - 1);
        }
      }
      resetCursorBlink();
      return;
    }

    if (e.key === 'ArrowRight') {
      e.preventDefault();
      e.stopPropagation();

      // Alt+Arrow with selection: Adjust tracking (letter spacing)
      if (e.altKey && hasSelection) {
        const delta = (e.metaKey || e.ctrlKey) ? 2 : 0.5;
        seg.letterSpacing = Math.min(50, (seg.letterSpacing || 0) + delta);
        onTextChanged();
        updateTextParamSlider('text_letter_spacing', seg.letterSpacing);
        // Keep cursor at end of selection for consistent feel
        textEditCursor = Math.max(textEditSelection.start, textEditSelection.end);
        resetCursorBlink();
        return;
      }

      // Alt+Arrow without selection: Kerning adjustment
      if (e.altKey && !hasSelection && textEditCursor < text.length) {
        adjustKerning(textEditCursor, e.metaKey || e.ctrlKey ? 100 : 20);
        return;
      }

      // Cmd/Ctrl+Right: Jump to word boundary
      if (e.metaKey || e.ctrlKey) {
        const newPos = findWordBoundary(text, textEditCursor, 1);
        if (e.shiftKey) {
          extendSelection(newPos);
        } else {
          textEditCursor = newPos;
          textEditSelection = null;
        }
      } else if (e.shiftKey) {
        // Shift+Right: Extend selection
        extendSelection(Math.min(text.length, textEditCursor + 1));
      } else {
        // Plain Right: Move cursor
        if (hasSelection) {
          textEditCursor = Math.max(textEditSelection.start, textEditSelection.end);
          textEditSelection = null;
        } else {
          textEditCursor = Math.min(text.length, textEditCursor + 1);
        }
      }
      resetCursorBlink();
      return;
    }

    if (e.key === 'ArrowUp') {
      e.preventDefault();
      e.stopPropagation();

      // Alt+Up: Decrease line height (leading)
      if (e.altKey) {
        const delta = (e.metaKey || e.ctrlKey) ? -0.2 : -0.05;
        seg.lineHeight = Math.max(0.5, (seg.lineHeight || 1.3) + delta);
        onTextChanged();
        updateTextParamSlider('text_line_height', seg.lineHeight);
        return;
      }

      // Move cursor up one line
      moveCursorVertically(-1, e.shiftKey);
      return;
    }

    if (e.key === 'ArrowDown') {
      e.preventDefault();
      e.stopPropagation();

      // Alt+Down: Increase line height (leading)
      if (e.altKey) {
        const delta = (e.metaKey || e.ctrlKey) ? 0.2 : 0.05;
        seg.lineHeight = Math.min(5, (seg.lineHeight || 1.3) + delta);
        onTextChanged();
        updateTextParamSlider('text_line_height', seg.lineHeight);
        return;
      }

      // Move cursor down one line
      moveCursorVertically(1, e.shiftKey);
      return;
    }

    // Cmd/Ctrl+A: Select all
    if ((e.metaKey || e.ctrlKey) && e.key === 'a') {
      e.preventDefault();
      e.stopPropagation();
      textEditSelection = { start: 0, end: text.length };
      textEditCursor = text.length;
      resetCursorBlink();
      return;
    }

    // Cmd/Ctrl+B: Toggle bold
    if ((e.metaKey || e.ctrlKey) && e.key === 'b') {
      e.preventDefault();
      e.stopPropagation();
      toggleBold();
      return;
    }

    // Cmd/Ctrl+I: Toggle italic
    if ((e.metaKey || e.ctrlKey) && e.key === 'i') {
      e.preventDefault();
      e.stopPropagation();
      toggleItalic();
      return;
    }

    // Cmd/Ctrl+L: Align left
    if ((e.metaKey || e.ctrlKey) && e.key === 'l') {
      e.preventDefault();
      e.stopPropagation();
      setTextAlignment('left');
      return;
    }

    // Cmd/Ctrl+E: Align center
    if ((e.metaKey || e.ctrlKey) && e.key === 'e') {
      e.preventDefault();
      e.stopPropagation();
      setTextAlignment('center');
      return;
    }

    // Cmd/Ctrl+R: Align right
    if ((e.metaKey || e.ctrlKey) && e.key === 'r') {
      e.preventDefault();
      e.stopPropagation();
      setTextAlignment('right');
      return;
    }

    // Cmd/Ctrl+Shift+< or >: Font size adjustment
    if ((e.metaKey || e.ctrlKey) && e.shiftKey && (e.key === '<' || e.key === ',' || e.key === '>' || e.key === '.')) {
      e.preventDefault();
      e.stopPropagation();

      if (hasSelection) {
        // With selection: adjust fontSizeScale on selected text
        const start = Math.min(textEditSelection.start, textEditSelection.end);
        const end = Math.max(textEditSelection.start, textEditSelection.end);
        const scaleDelta = (e.key === '>' || e.key === '.') ? 0.1 : -0.1;
        adjustFontSizeScaleOnSelection(seg, start, end, scaleDelta);
      } else {
        // Without selection: adjust base font size
        const delta = (e.key === '>' || e.key === '.') ? 2 : -2;
        seg.fontSize = Math.max(8, Math.min(500, (seg.fontSize || 48) + delta));
        // Update font size input field
        const fontSizeInput = document.getElementById('text_font_size');
        if (fontSizeInput) fontSizeInput.value = seg.fontSize;
      }
      onTextChanged();
      return;
    }

    // Printable characters - insert text
    if (e.key.length === 1 && !e.metaKey && !e.ctrlKey) {
      e.preventDefault();
      e.stopPropagation();
      insertTextAtCursor(e.key);
      return;
    }
  }

  // Helper: Insert text at cursor position
  function insertTextAtCursor(char) {
    if (!viewportSelectedText || !viewportSelectedText.seg) return;
    const seg = viewportSelectedText.seg;
    const result = insertText(seg, textEditCursor, textEditSelection, char);
    textEditCursor = result.cursor;
    textEditSelection = result.selection;
    onTextChanged();
  }

  // Helper: Delete the current selection
  function deleteSelection() {
    if (!viewportSelectedText || !viewportSelectedText.seg || !textEditSelection) return;
    const seg = viewportSelectedText.seg;
    const result = deleteTextSelection(seg, textEditCursor, textEditSelection);
    textEditCursor = result.cursor;
    textEditSelection = result.selection;
    onTextChanged();
  }

  // Helper: Extend selection to new position
  function extendSelection(newPos) {
    const result = extendTextSelection(textEditCursor, textEditSelection, newPos);
    textEditCursor = result.cursor;
    textEditSelection = result.selection;
    resetCursorBlink();
  }

  // Helper: Move cursor vertically (between lines)
  function moveCursorVertically(direction, extendSel) {
    if (!textEditCharPositions.length) return;
    const newPos = verticalCursorIndex(textEditCharPositions, textEditCursor, direction);
    if (newPos === textEditCursor) return;
    if (extendSel) {
      extendSelection(newPos);
    } else {
      textEditCursor = newPos;
      textEditSelection = null;
    }
    resetCursorBlink();
  }

  // Helper: Adjust kerning at specific position
  function adjustKerning(charIndex, delta) {
    if (!viewportSelectedText || !viewportSelectedText.seg) return;
    const seg = viewportSelectedText.seg;
    const text = seg.text || '';

    // Initialize kerning array if needed
    if (!seg.kerning) {
      seg.kerning = new Array(text.length).fill(0);
    }

    // Ensure kerning array is long enough
    while (seg.kerning.length < text.length) {
      seg.kerning.push(0);
    }

    // Adjust kerning value (in 1/1000 em units)
    seg.kerning[charIndex] = (seg.kerning[charIndex] || 0) + delta;

    onTextChanged();
    console.log('[TextEdit] Kerning at', charIndex, ':', seg.kerning[charIndex]);
  }

  // Helper: Toggle bold (cycle through available weights)
  function toggleBold() {
    if (!viewportSelectedText || !viewportSelectedText.seg) return;
    const seg = viewportSelectedText.seg;
    const hasSelection = textEditSelection && textEditSelection.start !== textEditSelection.end;

    // If in edit mode with a selection, apply rich text formatting to selection only
    if (textEditMode && hasSelection) {
      const start = Math.min(textEditSelection.start, textEditSelection.end);
      const end = Math.max(textEditSelection.start, textEditSelection.end);
      toggleBoldOnSelection(seg, start, end);
      onTextChanged();
      return;
    }

    // Otherwise toggle the entire segment's font weight
    const fontInfo = AVAILABLE_FONTS.find(f => f.name === seg.fontFamily);
    const weights = fontInfo ? fontInfo.weights : [400, 700];

    const currentWeight = seg.fontWeight || 400;

    // Toggle between 400 and 700 (or available weights)
    if (currentWeight < 600) {
      // Find a bold weight (600+)
      const boldWeight = weights.find(w => w >= 600);
      seg.fontWeight = boldWeight || weights[weights.length - 1];
    } else {
      // Go back to regular weight
      seg.fontWeight = weights[0];
    }

    onTextChanged();
    updateTextParamSelect('text_weight', seg.fontWeight);
  }

  // Helper: Toggle italic
  function toggleItalic() {
    if (!viewportSelectedText || !viewportSelectedText.seg) return;
    const seg = viewportSelectedText.seg;
    const hasSelection = textEditSelection && textEditSelection.start !== textEditSelection.end;

    // If in edit mode with a selection, apply rich text formatting to selection only
    if (textEditMode && hasSelection) {
      const start = Math.min(textEditSelection.start, textEditSelection.end);
      const end = Math.max(textEditSelection.start, textEditSelection.end);
      toggleItalicOnSelection(seg, start, end);
      onTextChanged();
      return;
    }

    // Otherwise toggle the entire segment's font style
    seg.fontStyle = seg.fontStyle === 'italic' ? 'normal' : 'italic';

    onTextChanged();
    // Update style buttons in params panel
    const normalBtn = document.getElementById('text_style_normal');
    const italicBtn = document.getElementById('text_style_italic');
    if (normalBtn && italicBtn) {
      normalBtn.classList.toggle('active', seg.fontStyle === 'normal');
      italicBtn.classList.toggle('active', seg.fontStyle === 'italic');
    }
  }

  // Helper: Set text alignment
  function setTextAlignment(align) {
    if (!viewportSelectedText || !viewportSelectedText.seg) return;
    const seg = viewportSelectedText.seg;

    seg.textAlign = align;

    onTextChanged();
    // Update alignment buttons in params panel
    const leftBtn = document.getElementById('text_align_left');
    const centerBtn = document.getElementById('text_align_center');
    const rightBtn = document.getElementById('text_align_right');
    if (leftBtn) leftBtn.classList.toggle('active', align === 'left');
    if (centerBtn) centerBtn.classList.toggle('active', align === 'center');
    if (rightBtn) rightBtn.classList.toggle('active', align === 'right');
  }

  // Helper: Update text param slider in the panel
  function updateTextParamSlider(sliderId, value) {
    const slider = document.getElementById(sliderId);
    if (slider) {
      slider.value = value;
      const numInput = document.getElementById(sliderId + '_num');
      if (numInput) numInput.value = typeof value === 'number' ? value.toFixed(2) : value;
    }
  }

  // Helper: Update text param select in the panel
  function updateTextParamSelect(selectId, value) {
    const select = document.getElementById(selectId);
    if (select) select.value = value;
  }

  // Called when text content or properties change
  function onTextChanged() {
    if (!viewportSelectedText || !viewportSelectedText.seg) return;

    // Recalculate character positions with updated spacing/kerning
    calculateCharPositions(viewportSelectedText.seg);

    // Clamp cursor to valid range
    const maxPos = (viewportSelectedText.seg.text || '').length;
    textEditCursor = Math.min(textEditCursor, maxPos);

    // Clamp selection to valid range if exists
    if (textEditSelection) {
      textEditSelection.start = Math.min(textEditSelection.start, maxPos);
      textEditSelection.end = Math.min(textEditSelection.end, maxPos);
      // Clear selection if it became empty
      if (textEditSelection.start === textEditSelection.end) {
        textEditSelection = null;
      }
    }

    // Update the text content textarea in params panel
    const textarea = document.getElementById('text_content');
    if (textarea && textarea.value !== viewportSelectedText.seg.text) {
      textarea.value = viewportSelectedText.seg.text || '';
    }

    // Update font inputs to reflect cursor position
    updateFontSizeInputFromCursor();
    updateFontDropdownFromCursor();
    updateLetterSpacingFromCursor();

    // Schedule autosave
    scheduleTimelineAutoSave();
  }

  // Update font size input to show the effective font size at cursor position
  function updateFontSizeInputFromCursor() {
    if (!viewportSelectedText || !viewportSelectedText.seg) return;
    if (!textEditMode) return;

    const seg = viewportSelectedText.seg;
    const fontSizeInput = document.getElementById('text_font_size');
    if (!fontSizeInput) return;

    // Get the character index to check (cursor position, or start of selection)
    let charIndex = textEditCursor;
    if (textEditSelection && textEditSelection.start !== textEditSelection.end) {
      // If there's a selection, show the font size of the first selected character
      charIndex = Math.min(textEditSelection.start, textEditSelection.end);
    }

    // Clamp to valid range (cursor can be at text.length, but we need a valid char index)
    const text = seg.text || '';
    if (text.length === 0) {
      // Empty text - just show base font size
      fontSizeInput.value = seg.fontSize || 48;
      return;
    }
    if (charIndex >= text.length) {
      charIndex = text.length - 1;
    }

    // Get the format at this character position
    const format = getCharFormat(seg, charIndex);
    const baseFontSize = seg.fontSize || 48;
    const fontSizeScale = format.fontSizeScale ?? 1.0;
    const effectiveFontSize = Math.round(baseFontSize * fontSizeScale);

    // Update the input value
    fontSizeInput.value = effectiveFontSize;
  }

  function updateFontDropdownFromCursor() {
    if (!viewportSelectedText || !viewportSelectedText.seg) return;
    if (!textEditMode) return;

    const seg = viewportSelectedText.seg;
    const fontInput = document.getElementById('text_font');
    const fontDropdown = document.getElementById('text_font_dropdown');
    const weightSelect = document.getElementById('text_weight');
    if (!fontInput) return;

    // Get the character index to check (cursor position, or start of selection)
    let charIndex = textEditCursor;
    if (textEditSelection && textEditSelection.start !== textEditSelection.end) {
      // If there's a selection, show the font of the first selected character
      charIndex = Math.min(textEditSelection.start, textEditSelection.end);
    }

    // Clamp to valid range
    const text = seg.text || '';
    if (text.length === 0) {
      // Empty text - just show base font
      const defaultFont = seg.fontFamily || 'Inter';
      const defaultFontObj = AVAILABLE_FONTS.find(f => f.name === defaultFont) || AVAILABLE_FONTS[0];
      fontInput.value = defaultFont;
      if (fontDropdown) {
        const selected = fontDropdown.querySelector('.font-dropdown-selected');
        if (selected) {
          selected.textContent = defaultFont;
          selected.style.fontFamily = defaultFontObj.family;
        }
      }
      if (weightSelect) weightSelect.value = seg.fontWeight || '400';
      return;
    }
    if (charIndex >= text.length) {
      charIndex = text.length - 1;
    }

    // Get the format at this character position
    const format = getCharFormat(seg, charIndex);
    const effectiveFont = format.fontFamily || seg.fontFamily || 'Inter';
    const effectiveFontObj = AVAILABLE_FONTS.find(f => f.name === effectiveFont) || AVAILABLE_FONTS[0];
    const effectiveWeight = format.fontWeight || seg.fontWeight || '400';

    // Update the custom font dropdown
    fontInput.value = effectiveFont;
    if (fontDropdown) {
      const selected = fontDropdown.querySelector('.font-dropdown-selected');
      if (selected) {
        selected.textContent = effectiveFont;
        selected.style.fontFamily = effectiveFontObj.family;
      }
      // Update selected item in list
      fontDropdown.querySelectorAll('.font-dropdown-item').forEach(item => {
        item.classList.toggle('selected', item.dataset.font === effectiveFont);
      });
    }

    // Update the weight dropdown options for this font and set value
    if (weightSelect) {
      const font = AVAILABLE_FONTS.find(f => f.name === effectiveFont);
      if (font) {
        const weightLabels = { 400: 'Regular', 500: 'Medium', 600: 'Semibold', 700: 'Bold' };
        const currentWeightStr = String(effectiveWeight);
        weightSelect.innerHTML = font.weights.map(w =>
          `<option value="${w}" ${String(w) === currentWeightStr ? 'selected' : ''}>${weightLabels[w] || w}</option>`
        ).join('');
      }
      weightSelect.value = effectiveWeight;
    }
  }

  function updateLetterSpacingFromCursor() {
    if (!viewportSelectedText || !viewportSelectedText.seg) return;
    if (!textEditMode) return;

    const seg = viewportSelectedText.seg;
    const letterSpacingInput = document.getElementById('text_letter_spacing');
    if (!letterSpacingInput) return;

    // Get the character index to check (cursor position, or start of selection)
    let charIndex = textEditCursor;
    if (textEditSelection && textEditSelection.start !== textEditSelection.end) {
      charIndex = Math.min(textEditSelection.start, textEditSelection.end);
    }

    const text = seg.text || '';
    if (text.length === 0) {
      letterSpacingInput.value = seg.letterSpacing || 0;
      updateSliderThumb(letterSpacingInput);
      return;
    }
    if (charIndex >= text.length) {
      charIndex = text.length - 1;
    }

    // Calculate effective letter spacing at this position
    const baseLetterSpacing = seg.letterSpacing || 0;
    const kerning = (seg.kerning && seg.kerning[charIndex]) || 0;
    // Convert kerning back to letter spacing units
    const effectiveLetterSpacing = baseLetterSpacing + (kerning / 50);

    letterSpacingInput.value = effectiveLetterSpacing.toFixed(1);
    updateSliderThumb(letterSpacingInput);
  }

  // Settings modal
  const settingsModal = document.getElementById("settingsModal");
  const btnSettings = document.getElementById("btnSettings");
  const settingsOk = document.getElementById("settingsOk");
  const settingsCancel = document.getElementById("settingsCancel");
  const settingsCloseBtn = document.getElementById("settingsCloseBtn");
  const projectDurationInput = document.getElementById("projectDuration");
  const projectFrameRateSelect = document.getElementById("projectFrameRate");
  const viewportOrientationSelect = document.getElementById("viewportOrientation");
  
  // Update camera aspect ratios for all loaded scene instances
  function updateAllSceneCameraAspects() {
    const aspect = getTargetAspectRatio();
    
    // Update main loaded scenes
    for (const scene of loadedScenes) {
      if (scene && scene.camera && scene.camera.isPerspectiveCamera) {
        scene.camera.aspect = aspect;
        scene.camera.updateProjectionMatrix();
      }
    }
    
    // Update segment scene instances
    for (const [key, sceneObj] of segmentSceneInstances) {
      if (sceneObj && sceneObj.camera && sceneObj.camera.isPerspectiveCamera) {
        sceneObj.camera.aspect = aspect;
        sceneObj.camera.updateProjectionMatrix();
      }
    }
  }
  
  // Update export resolution labels based on current viewport orientation
  function updateExportResolutionLabels() {
    const select = document.getElementById('exportHQResolution');
    if (!select) return;
    
    const labels = exportResolutionLabels(viewportOrientation);
    
    for (const option of select.options) {
      if (labels[option.value]) {
        option.textContent = labels[option.value];
      }
    }

    const estimate = document.getElementById('exportHQEstimate');
    const fpsSelect = document.getElementById('exportHQFps');
    const rangeSelect = document.getElementById('exportRange');
    if (estimate && fpsSelect && rangeSelect) {
      const { width, height } = exportResolution(select.value, viewportOrientation);
      const fps = Number(fpsSelect.value) || 30;
      const exportRange = exportTimeRange(
        rangeSelect.value,
        durationGuess,
        { enabled: loopEnabled, start: loopStart, end: loopEnd },
      );
      const frames = exportFrameCount(exportRange.duration, fps);
      const chunkFrames = exportChunkFrameCount(width, height);
      const chunks = Math.ceil(frames / chunkFrames);
      estimate.textContent =
        `${frames.toLocaleString()} frames • ${chunks.toLocaleString()} memory-safe chunks`
        + ` • ${audioClipRecords(timelineTracks, { includeHidden: false }).length} audio clips mixed`;
    }
  }
  
  createSettingsController({
    modal: settingsModal,
    openButton: btnSettings,
    okButton: settingsOk,
    cancelButton: settingsCancel,
    closeButton: settingsCloseBtn,
    capture: () => ({
      follow: followChk.checked,
      snap: snapChk.checked,
      bpm: bpmInput.value,
      grid: gridSel.value,
      projectDuration: durationGuess,
      frameRate: projectFrameRate,
      viewportOrientation,
    }),
    restore: snapshot => {
      followChk.checked = snapshot.follow;
      snapChk.checked = snapshot.snap;
      bpmInput.value = snapshot.bpm;
      gridSel.value = snapshot.grid;
      projectDurationInput.value = snapshot.projectDuration;
      projectFrameRateSelect.value = String(snapshot.frameRate);
    },
    onOpen: snapshot => {
      projectDurationInput.value = Number(snapshot.projectDuration.toFixed(3));
      projectFrameRateSelect.value = String(snapshot.frameRate);
    },
    getOrientation: () => viewportOrientation,
    setOrientationInput: value => { viewportOrientationSelect.value = value; },
    readOrientationInput: () => viewportOrientationSelect.value,
    onSave: newOrientation => {
      const nextDuration = normalizeProjectDuration(
        projectDurationInput.value,
        timelineTracks,
        durationGuess,
      );
      const durationChanged = nextDuration !== durationGuess;
      const nextFrameRate = normalizeTimelineFrameRate(
        projectFrameRateSelect.value,
        projectFrameRate,
      );
      const frameRateChanged = nextFrameRate !== projectFrameRate;
      const orientationChanged = newOrientation !== viewportOrientation;

      if (durationChanged) {
        durationGuess = nextDuration;
        silentTime = Math.min(silentTime, durationGuess);
        loopStart = Math.min(loopStart, Math.max(0, durationGuess - 0.25));
        loopEnd = Math.min(durationGuess, Math.max(loopStart + 0.25, loopEnd));
        projectDurationInput.value = Number(durationGuess.toFixed(3));
        layoutTimeline();
        drawWaveAndRuler();
        renderTracks();
        updatePlayhead(true);
        console.log('[Settings] Project length changed to:', durationGuess);
      }

      if (frameRateChanged) {
        projectFrameRate = nextFrameRate;
        for (const selectId of ['exportQuickFps', 'exportHQFps']) {
          const select = document.getElementById(selectId);
          if (select && [...select.options].some(option => Number(option.value) === projectFrameRate)) {
            select.value = String(projectFrameRate);
          }
        }
        console.log('[Settings] Project frame rate changed to:', projectFrameRate);
      }

      if (orientationChanged) {
        viewportOrientation = newOrientation;
        console.log('[Settings] Viewport orientation changed to:', viewportOrientation);
        resizeRenderer();
        updateAllSceneCameraAspects();
        updateExportResolutionLabels();
      }

      if (durationChanged || frameRateChanged || orientationChanged) scheduleDemoSave();
    },
  });
  
  // =========================
  // ADD ASSETS DROPDOWN (Editor Bar)
  // =========================
  const addAssetsMenu = document.getElementById('addAssetsMenu');
  const btnAddAssets = document.getElementById('btnAddAssets');
  
  btnAddAssets.addEventListener('click', (e) => {
    e.stopPropagation();
    addAssetsMenu.classList.toggle('open');
  });
  
  document.addEventListener('click', (e) => {
    if (!addAssetsMenu.contains(e.target)) {
      addAssetsMenu.classList.remove('open');
    }
  });
  
  // Handle add assets item clicks
  document.querySelectorAll('.add-assets-item').forEach(item => {
    item.addEventListener('click', () => {
      const assetType = item.dataset.assetType;
      addAssetsMenu.classList.remove('open');
      
      if (assetType === 'text') {
        // Text doesn't have an asset browser - directly add a text clip
        addTextClipQuickAdd();
      } else {
        openAssetBrowserForQuickAdd(assetType);
      }
    });
  });
  
  // Quick-add a text clip (no asset browser needed)
  function addTextClipQuickAdd() {
    // Find or create a text track
    const textTracks = getTracksByType('text');
    let track;
    
    if (textTracks.length > 0) {
      track = textTracks[0];
    } else {
      // Create a new text track
      track = addTrack('text');
    }
    
    if (!track) {
      console.error('[QuickAdd] Failed to find or create text track');
      return;
    }
    
    // Add a text segment to the track at current playhead
    const currentTime = getCurrentTime();
    addSegmentToTrack(track.id, currentTime);
    
    console.log('[QuickAdd] Added text clip to track:', track.id);
  }

  // =========================
  // NEW TRACK MODAL
  // =========================
  const btnAddTrack = document.getElementById('btnAddTrack');
  const newTrackModal = document.getElementById('newTrackModal');
  const newTrackCloseBtn = document.getElementById('newTrackCloseBtn');
  
  function openNewTrackModal() {
    newTrackModal.classList.add('show');
  }
  
  function closeNewTrackModal() {
    newTrackModal.classList.remove('show');
  }
  
  btnAddTrack.addEventListener('click', openNewTrackModal);
  newTrackCloseBtn.addEventListener('click', closeNewTrackModal);
  
  // Handle track type selection
  document.querySelectorAll('.track-type-btn').forEach(btn => {
    btn.addEventListener('click', () => {
      const trackType = btn.dataset.type;
      if (trackType) {
        addTrack(trackType);
        closeNewTrackModal();
      }
    });
  });
  
  // Close modal when clicking overlay
  newTrackModal.addEventListener('click', (e) => {
    if (e.target === newTrackModal) closeNewTrackModal();
  });
  
  // + key to open new track modal
  document.addEventListener('keydown', (e) => {
    // Skip if user is typing in an input field
    if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.isContentEditable) return;
    // Skip if any modal is open
    if (document.querySelector('.modal.show')) return;
    
    if (e.key === '+' || (e.key === '=' && !e.ctrlKey && !e.metaKey)) {
      e.preventDefault();
      openNewTrackModal();
    }
  });

  // =========================
  // MODE TOGGLE (Timeline/Keyframe)
  // =========================
  const btnModeTimeline = document.getElementById('btnModeTimeline');
  const btnModeKeyframe = document.getElementById('btnModeKeyframe');
  
  function setEditorMode(mode) {
    editorMode = mode;
    
    btnModeTimeline.classList.toggle('active', mode === 'timeline');
    btnModeKeyframe.classList.toggle('active', mode === 'keyframe');
    
    timelineViewport.style.display = mode === 'timeline' ? 'block' : 'none';
    keyframeViewport.style.display = mode === 'keyframe' ? 'block' : 'none';
    
    if (mode === 'keyframe') {
      renderKeyframeEditor();
    } else {
      // Refresh timeline view
      layoutTimeline();
      renderSegments();
      drawWaveAndRuler();
    }
  }
  
  btnModeTimeline.addEventListener('click', () => setEditorMode('timeline'));
  btnModeKeyframe.addEventListener('click', () => setEditorMode('keyframe'));
  
  // Tab key to toggle between Timeline and Keyframe modes
  document.addEventListener('keydown', (e) => {
    if (e.code === 'Tab') {
      // Don't intercept Tab when in text inputs (allow normal tab navigation)
      const tag = e.target.tagName;
      const isTextInput = tag === "TEXTAREA" || tag === "SELECT" || 
        (tag === "INPUT" && e.target.type !== "range" && e.target.type !== "checkbox");
      if (isTextInput) return;
      
      e.preventDefault();
      setEditorMode(editorMode === 'timeline' ? 'keyframe' : 'timeline');
    }
  });
  
  // Arrow keys to scrub timeline (works in both modes)
  document.addEventListener('keydown', (e) => {
    const delta = timelinePlayheadDelta(e, {
      bpm: bpmInput.value,
      frameRate: 60,
    });
    if (delta === null) return;

    e.preventDefault();

    const currentTime = getCurrentTime();
    const newTime = clamp(currentTime + delta, 0, durationGuess);

    // Make sure audio context is resumed
    if (audioCtx.state === 'suspended') {
      audioCtx.resume();
    }

    // Update both silent time and audio time
    silentTime = newTime;
    audioClipPlayback?.sync(timelineTracks, newTime, silentPlaying);
    updatePlayhead(true);
  });

  // =========================
  // KEYFRAME INTERACTION
  // =========================
  let draggedKeyframe = null;
  let selectedKeyframe = null;
  let selectedKeyframes = []; // Array of { paramId, index } for multi-selection
  let marqueeSelection = null; // { trackArea, startX, startY, element }
  let isDraggingMultiple = false;
  let justFinishedMarquee = false; // Flag to prevent click from deselecting after marquee
  let selectedTrack = null; // Currently selected track paramId
  let draggedAutomationLane = null;
  let laneResizeUndoActive = false;
  let laneResizeUndoTimer = null;
  let laneResizeDrawFrame = null;
  
  function selectTrack(paramId) {
    selectedTrack = paramId;
    document.querySelectorAll('.keyframe-track').forEach(t => {
      const selected = t.dataset.param === paramId;
      t.classList.toggle('selected', selected);
      t.setAttribute('aria-selected', String(selected));
    });
  }
  
  function deselectTrack() {
    selectedTrack = null;
    document.querySelectorAll('.keyframe-track').forEach(t => {
      t.classList.remove('selected');
      t.setAttribute('aria-selected', 'false');
    });
  }

  function clearAutomationLaneDropIndicators() {
    keyframeTracks?.querySelectorAll('.drop-before, .drop-after').forEach(track => {
      track.classList.remove('drop-before', 'drop-after');
    });
  }
  
  // Double-click to add keyframe
  if (keyframeTracks) {
    keyframeTracks.addEventListener('dragstart', (e) => {
      const handle = e.target.closest('.keyframe-track-drag-handle');
      const track = handle?.closest('.keyframe-track');
      if (!track) {
        e.preventDefault();
        return;
      }
      draggedAutomationLane = track.dataset.param;
      selectTrack(draggedAutomationLane);
      track.classList.add('dragging');
      e.dataTransfer.effectAllowed = 'move';
      e.dataTransfer.setData('application/x-lsd-keyframe-lane', draggedAutomationLane);
      e.dataTransfer.setData('text/plain', draggedAutomationLane);
    });

    keyframeTracks.addEventListener('dragover', (e) => {
      const targetTrack = e.target.closest('.keyframe-track');
      if (!targetTrack || !draggedAutomationLane || targetTrack.dataset.param === draggedAutomationLane) {
        clearAutomationLaneDropIndicators();
        return;
      }
      e.preventDefault();
      e.dataTransfer.dropEffect = 'move';
      clearAutomationLaneDropIndicators();
      const rect = targetTrack.getBoundingClientRect();
      const placement = e.clientY < rect.top + rect.height / 2 ? 'before' : 'after';
      targetTrack.classList.add(placement === 'before' ? 'drop-before' : 'drop-after');
    });

    keyframeTracks.addEventListener('drop', (e) => {
      const targetTrack = e.target.closest('.keyframe-track');
      const draggedParam = draggedAutomationLane
        || e.dataTransfer.getData('application/x-lsd-keyframe-lane')
        || e.dataTransfer.getData('text/plain');
      clearAutomationLaneDropIndicators();
      if (!targetTrack || !selectedSegment || !draggedParam) return;
      const targetParam = targetTrack.dataset.param;
      if (!targetParam || targetParam === draggedParam) return;
      e.preventDefault();

      const rect = targetTrack.getBoundingClientRect();
      const placement = e.clientY < rect.top + rect.height / 2 ? 'before' : 'after';
      const segId = getSegmentId(selectedSegment);
      const current = exposedParams[segId];
      const reordered = reorderKeyframeParams(current, draggedParam, targetParam, placement);
      if ([...reordered].join('\u0000') === [...(current || [])].join('\u0000')) return;

      saveUndoState();
      exposedParams[segId] = reordered;
      selectedTrack = draggedParam;
      draggedAutomationLane = null;
      renderKeyframeEditor();
      scheduleTimelineAutoSave();
    });

    keyframeTracks.addEventListener('dragend', () => {
      draggedAutomationLane = null;
      keyframeTracks.querySelectorAll('.keyframe-track.dragging').forEach(track => {
        track.classList.remove('dragging');
      });
      clearAutomationLaneDropIndicators();
    });

    keyframeTracks.addEventListener('wheel', (e) => {
      if (!e.shiftKey || !selectedSegment) return;
      const track = e.target.closest('.keyframe-track');
      if (!track || track.dataset.param !== selectedTrack) return;

      const segId = getSegmentId(selectedSegment);
      const paramId = track.dataset.param;
      const currentHeight = normalizeKeyframeLaneHeight(
        exposedParamsMeta[segId]?.[paramId]?.laneHeight ?? track.getBoundingClientRect().height,
      );
      const wheelDelta = Math.abs(e.deltaY) >= Math.abs(e.deltaX) ? e.deltaY : e.deltaX;
      const nextHeight = keyframeLaneHeightFromWheel(currentHeight, wheelDelta);
      if (nextHeight === currentHeight) return;

      e.preventDefault();
      e.stopPropagation();
      if (!laneResizeUndoActive) {
        saveUndoState();
        laneResizeUndoActive = true;
      }
      clearTimeout(laneResizeUndoTimer);
      laneResizeUndoTimer = setTimeout(() => {
        laneResizeUndoActive = false;
      }, 350);

      if (!exposedParamsMeta[segId]) exposedParamsMeta[segId] = {};
      if (!exposedParamsMeta[segId][paramId]) {
        const range = getParamRange(paramId, segId);
        exposedParamsMeta[segId][paramId] = {
          min: range.min,
          max: range.max,
          step: range.step,
        };
      }
      exposedParamsMeta[segId][paramId].laneHeight = nextHeight;
      track.style.height = `${nextHeight}px`;
      track.title = `${keyframeParamDisplayName(paramId)} automation lane, ${nextHeight}px high`;

      if (!laneResizeDrawFrame) {
        laneResizeDrawFrame = requestAnimationFrame(() => {
          laneResizeDrawFrame = null;
          drawAllKeyframeTracks(segId);
          updateKeyframePlayhead();
        });
      }
      scheduleTimelineAutoSave();
    }, { passive: false });

    keyframeTracks.addEventListener('keydown', (e) => {
      // Space belongs exclusively to the global play/pause transport. A lane
      // can retain focus after Shift+wheel resizing, so never consume it here.
      if (!isKeyframeLaneSelectionKey(e.key)) return;
      const track = e.target.closest('.keyframe-track');
      if (!track) return;
      selectTrack(track.dataset.param);
      e.preventDefault();
      e.stopPropagation();
    });

    keyframeTracks.addEventListener('mouseover', (e) => {
      const diamond = e.target.closest('.keyframe-diamond');
      if (!diamond || !keyframeTracks.contains(diamond)) return;
      keyframeValueTooltip.textContent = `Value: ${diamond.dataset.valueLabel}`;
      positionKeyframeValueTooltip(diamond);
      keyframeValueTooltip.classList.add('visible');
      keyframeValueTooltip.setAttribute('aria-hidden', 'false');
    });

    keyframeTracks.addEventListener('mousemove', (e) => {
      const diamond = e.target.closest('.keyframe-diamond');
      if (diamond) positionKeyframeValueTooltip(diamond);
    });

    keyframeTracks.addEventListener('mouseout', (e) => {
      if (!e.target.closest('.keyframe-diamond')) return;
      hideKeyframeValueTooltip();
    });

    keyframeTracks.addEventListener('dblclick', (e) => {
      // Find the track (works whether clicking on track, track-area, or canvas)
      const track = e.target.closest('.keyframe-track');
      if (!track) return;
      
      if (e.target.classList.contains('keyframe-diamond')) {
        e.preventDefault();
        e.stopPropagation();
        hideKeyframeValueTooltip();

        if (!selectedSegment) return;
        const segId = getSegmentId(selectedSegment);
        const paramId = e.target.dataset.param;
        const index = parseInt(e.target.dataset.index);
        const keyframe = keyframeData[segId]?.[paramId]?.[index];
        if (!keyframe) return;

        const valueSpec = getParamRange(paramId, segId);
        const formattedValue = formatKeyframeValue(keyframe.value, valueSpec);
        const displayName = keyframeParamDisplayName(paramId);
        keyframeValueDialogController.show({
          id: 'keyframeValueDialog',
          title: 'Set keyframe value',
          label: `${displayName} (${formatKeyframeValue(valueSpec.min, valueSpec)}–${formatKeyframeValue(valueSpec.max, valueSpec)})`,
          value: formattedValue,
          placeholder: formattedValue,
          confirmLabel: 'Set value',
          emptyMessage: 'Enter a keyframe value.',
          inputMode: 'decimal',
          min: valueSpec.min,
          max: valueSpec.max,
          step: valueSpec.step,
          validate: nextValue => {
            const parsed = parseKeyframeValue(nextValue, valueSpec);
            return parsed.valid ? null : parsed.error;
          },
          onSubmit: nextValue => {
            const parsed = parseKeyframeValue(nextValue, valueSpec);
            if (!parsed.valid) return;
            const nextNumericValue = clamp(parsed.value, valueSpec.min, valueSpec.max);
            if (Math.abs(nextNumericValue - keyframe.value) < 0.0000001) return;
            saveUndoState();
            keyframe.value = nextNumericValue;
            selectedKeyframe = { paramId, index };
            selectedKeyframes = [{ paramId, index }];
            renderKeyframeEditor();
            const updatedDiamond = keyframeTracks.querySelector(
              `.keyframe-diamond[data-param="${paramId}"][data-index="${index}"]`,
            );
            updatedDiamond?.classList.add('selected');
            scheduleTimelineAutoSave();
            showToast(`${displayName}: ${formatKeyframeValue(nextNumericValue, valueSpec)}`);
          },
        });
        return;
      }
      
      if (!selectedSegment) return;
      const segId = getSegmentId(selectedSegment);
      
      const paramId = track.dataset.param;
      if (!paramId) return;
      
      // Get the track area for position calculation
      const trackArea = track.querySelector('.keyframe-track-area');
      if (!trackArea) return;
      
      const rect = trackArea.getBoundingClientRect();
      const x = e.clientX - rect.left;
      const y = e.clientY - rect.top;
      
      // Clamp x to valid range
      const clampedX = Math.max(0, Math.min(x, rect.width));
      const rawTime = (clampedX / rect.width) * keyframeClipDuration;
      const time = snapKeyframeTime(rawTime);
      
      // Calculate value from Y position (reverse the drawing formula)
      // Drawing formula: y = h - padding - ((value - minV) / range) * (h - padding * 2)
      // Reverse: value = minV + range * (h - padding - y) / (h - padding * 2)
      const h = rect.height;
      const padding = 8;
      const paramRange = getParamRange(paramId);
      const minV = paramRange.min;
      const maxV = paramRange.max;
      const range = maxV - minV || 1;
      
      // Clamp y to valid range (within padding)
      const clampedY = Math.max(padding, Math.min(y, h - padding));
      const value = minV + range * (h - padding - clampedY) / (h - padding * 2);
      
      // Save undo state before adding keyframe
      saveUndoState();
      
      addKeyframe(segId, paramId, time, value, 'linear');
      selectTrack(paramId);
      renderKeyframeEditor();
      scheduleTimelineAutoSave();
    });
    
    // Click on track label to select track
    keyframeTracks.addEventListener('click', (e) => {
      const label = e.target.closest('.keyframe-track-label');
      if (label) {
        const track = label.closest('.keyframe-track');
        if (track) {
          selectTrack(track.dataset.param);
        }
        return;
      }
    });
    
    // Click on diamond to select, or curve type button, or delete button
    keyframeTracks.addEventListener('click', (e) => {
      // Handle curve type button clicks
      if (e.target.classList.contains('curve-type-btn')) {
        const paramId = e.target.dataset.param;
        const curveType = e.target.dataset.curve;
        const segId = getSegmentId(selectedSegment);
        
        // Save undo state before changing
        saveUndoState();
        
        // Update curve type
        setCurveType(segId, paramId, curveType);
        
        // Update button states
        const buttons = e.target.parentElement.querySelectorAll('.curve-type-btn');
        buttons.forEach(btn => btn.classList.remove('active'));
        e.target.classList.add('active');
        
        // Redraw track
        drawAllKeyframeTracks(segId);
        
        // Save to storage
        scheduleTimelineAutoSave();
        return;
      }
      
      // Handle delete track button clicks
      if (e.target.classList.contains('track-delete-btn')) {
        const paramId = e.target.dataset.param;
        const segId = getSegmentId(selectedSegment);
        
        // Show confirmation dialog
        if (!confirm('Are you sure you want to remove all keyframing for this parameter?\n\nThis will delete all keyframes and remove this track.')) {
          return;
        }
        
        // Save undo state before deleting
        saveUndoState();
        
        // Remove all keyframes for this parameter
        if (keyframeData[segId]?.[paramId]) {
          delete keyframeData[segId][paramId];
        }
        
        // Remove curve type
        if (curveTypes[segId]?.[paramId]) {
          delete curveTypes[segId][paramId];
        }
        
        // Remove LFO data
        if (lfoData[segId]?.[paramId]) {
          delete lfoData[segId][paramId];
        }

        if (exposedParamsMeta[segId]?.[paramId]) {
          delete exposedParamsMeta[segId][paramId];
        }
        
        // Remove from exposed params (untoggle K button)
        if (exposedParams[segId]) {
          exposedParams[segId].delete(paramId);
        }
        
        // Update the K button in the parameters panel if it exists
        const kBtn = document.querySelector(`.keyframe-toggle[data-param="${paramId}"]`);
        if (kBtn) {
          kBtn.classList.remove('active');
        }
        
        // Re-enable the slider since keyframes are removed
        updateSliderKeyframeState(paramId, segId);
        
        renderKeyframeEditor();
        scheduleTimelineAutoSave();
        return;
      }
      
      // Handle LFO button clicks
      if (e.target.classList.contains('lfo-btn')) {
        const paramId = e.target.dataset.param;
        const segId = getSegmentId(selectedSegment);
        
        // Save undo state before changing
        saveUndoState();
        
        // Toggle LFO enabled state
        if (!lfoData[segId]) lfoData[segId] = {};
        if (!lfoData[segId][paramId]) {
          lfoData[segId][paramId] = {
            enabled: true,
            shape: 'sine',
            frequency: 1,
            amplitude: 0.5,
            phase: 0
          };
        } else {
          lfoData[segId][paramId].enabled = !lfoData[segId][paramId].enabled;
        }
        
        // Toggle button state
        e.target.classList.toggle('active', lfoData[segId][paramId].enabled);
        
        // Show/hide LFO controls
        const track = e.target.closest('.keyframe-track');
        const lfoControls = track?.querySelector('.lfo-controls');
        if (lfoControls) {
          lfoControls.classList.toggle('visible', lfoData[segId][paramId].enabled);
        }
        
        // Redraw track
        drawAllKeyframeTracks(segId);
        
        // Save to storage
        scheduleTimelineAutoSave();
        return;
      }
      
      if (!e.target.classList.contains('keyframe-diamond')) {
        // Deselect all (unless we just finished marquee selection)
        if (!marqueeSelection && !justFinishedMarquee) {
          document.querySelectorAll('.keyframe-diamond.selected').forEach(d => d.classList.remove('selected'));
          selectedKeyframe = null;
          selectedKeyframes = [];
        }
        justFinishedMarquee = false;
        return;
      }
      
      // Handle selection
      const paramId = e.target.dataset.param;
      const index = parseInt(e.target.dataset.index);
      
      if (e.shiftKey || e.ctrlKey || e.metaKey) {
        // Multi-select: toggle this diamond
        if (e.target.classList.contains('selected')) {
          e.target.classList.remove('selected');
          selectedKeyframes = selectedKeyframes.filter(k => !(k.paramId === paramId && k.index === index));
        } else {
          e.target.classList.add('selected');
          selectedKeyframes.push({ paramId, index });
        }
        selectedKeyframe = selectedKeyframes.length > 0 ? selectedKeyframes[selectedKeyframes.length - 1] : null;
      } else {
        // Single select: deselect others and select this one
        document.querySelectorAll('.keyframe-diamond.selected').forEach(d => d.classList.remove('selected'));
        e.target.classList.add('selected');
        selectedKeyframe = { paramId, index };
        selectedKeyframes = [{ paramId, index }];
      }
    });
    
    // Handle LFO control changes
    keyframeTracks.addEventListener('change', (e) => {
      const segId = getSegmentId(selectedSegment);
      if (!segId) return;
      
      // LFO shape dropdown
      if (e.target.classList.contains('lfo-shape')) {
        const paramId = e.target.dataset.param;
        if (lfoData[segId]?.[paramId]) {
          lfoData[segId][paramId].shape = e.target.value;
          drawAllKeyframeTracks(segId);
          scheduleTimelineAutoSave();
        }
        return;
      }
      
      // LFO frequency dropdown
      if (e.target.classList.contains('lfo-freq')) {
        const paramId = e.target.dataset.param;
        if (lfoData[segId]?.[paramId]) {
          lfoData[segId][paramId].frequency = parseFloat(e.target.value);
          drawAllKeyframeTracks(segId);
          scheduleTimelineAutoSave();
        }
        return;
      }
    });
    
    // Handle LFO slider inputs (realtime)
    keyframeTracks.addEventListener('input', (e) => {
      const segId = getSegmentId(selectedSegment);
      if (!segId) return;
      
      // LFO amplitude slider
      if (e.target.classList.contains('lfo-amp')) {
        const paramId = e.target.dataset.param;
        if (lfoData[segId]?.[paramId]) {
          lfoData[segId][paramId].amplitude = parseFloat(e.target.value);
          drawAllKeyframeTracks(segId);
        }
        return;
      }
      
      // LFO phase slider
      if (e.target.classList.contains('lfo-phase')) {
        const paramId = e.target.dataset.param;
        if (lfoData[segId]?.[paramId]) {
          lfoData[segId][paramId].phase = parseFloat(e.target.value);
          drawAllKeyframeTracks(segId);
        }
        return;
      }
    });
    
    // Save LFO slider changes on mouseup
    keyframeTracks.addEventListener('mouseup', (e) => {
      if (e.target.classList.contains('lfo-amp') || e.target.classList.contains('lfo-phase')) {
        scheduleTimelineAutoSave();
      }
    });
    
    // Right-click for easing menu
    keyframeTracks.addEventListener('contextmenu', (e) => {
      if (!e.target.classList.contains('keyframe-diamond')) return;
      e.preventDefault();
      
      const paramId = e.target.dataset.param;
      const kfIndex = parseInt(e.target.dataset.index);
      const segId = getSegmentId(selectedSegment);
      
      showEasingMenu(segId, paramId, kfIndex, e.clientX, e.clientY);
    });
    
    // Drag keyframes (mousedown also selects the diamond)
    keyframeTracks.addEventListener('mousedown', (e) => {
      const track = e.target.closest('.keyframe-track');
      if (!track) return;
      
      const trackArea = track.querySelector('.keyframe-track-area');
      if (!trackArea) return;
      
      if (e.target.classList.contains('keyframe-diamond')) {
        // Clicking on a diamond - start dragging
        const paramId = e.target.dataset.param;
        const kfIndex = parseInt(e.target.dataset.index);
        
        // Select this track
        selectTrack(paramId);
        
        // Check if this diamond is already selected (for multi-drag)
        const isAlreadySelected = e.target.classList.contains('selected');
        
        if (!isAlreadySelected && !e.shiftKey && !e.ctrlKey && !e.metaKey) {
          // Deselect any previously selected diamonds if not using modifier keys
          document.querySelectorAll('.keyframe-diamond.selected').forEach(d => d.classList.remove('selected'));
          selectedKeyframes = [];
        }
        
        // Select this diamond
        e.target.classList.add('selected');
        if (!selectedKeyframes.some(k => k.paramId === paramId && k.index === kfIndex)) {
          selectedKeyframes.push({ paramId, index: kfIndex });
        }
        selectedKeyframe = { paramId, index: kfIndex };
        
        // Check if we're dragging multiple selected keyframes
        if (selectedKeyframes.length > 1) {
          isDraggingMultiple = true;
          draggedKeyframe = {
            element: e.target,
            paramId,
            index: kfIndex,
            trackArea,
            startX: e.clientX,
            startY: e.clientY,
            moved: false,
            startValues: {} // Store initial values for all selected keyframes
          };
          // Store initial Y positions for all selected
          const segId = getSegmentId(selectedSegment);
          for (const sel of selectedKeyframes) {
            const kfs = keyframeData[segId]?.[sel.paramId];
            if (kfs && kfs[sel.index]) {
              draggedKeyframe.startValues[`${sel.paramId}_${sel.index}`] = kfs[sel.index].value;
            }
          }
        } else {
          isDraggingMultiple = false;
          draggedKeyframe = {
            element: e.target,
            paramId,
            index: kfIndex,
            trackArea,
            startX: e.clientX,
            startY: e.clientY,
            moved: false
          };
        }
        
        e.preventDefault();
      } else if (e.target.closest('.keyframe-track-area') || e.target.classList.contains('keyframe-track-canvas')) {
        // Clicking on track area - start marquee selection
        const rect = trackArea.getBoundingClientRect();
        const startX = e.clientX - rect.left;
        const startY = e.clientY - rect.top;
        
        // Select this track
        selectTrack(track.dataset.param);
        
        // Deselect all if not using modifier keys
        if (!e.shiftKey && !e.ctrlKey && !e.metaKey) {
          document.querySelectorAll('.keyframe-diamond.selected').forEach(d => d.classList.remove('selected'));
          selectedKeyframe = null;
          selectedKeyframes = [];
        }
        
        // Create marquee element
        const marquee = document.createElement('div');
        marquee.className = 'keyframe-marquee';
        marquee.style.left = `${startX}px`;
        marquee.style.top = `${startY}px`;
        marquee.style.width = '0px';
        marquee.style.height = '0px';
        trackArea.appendChild(marquee);
        
        marqueeSelection = {
          trackArea,
          paramId: track.dataset.param,
          startX,
          startY,
          element: marquee,
          rect
        };
        
        e.preventDefault();
      }
    });
  }
  
  window.addEventListener('mousemove', (e) => {
    // Handle marquee selection
    if (marqueeSelection) {
      const currentX = e.clientX - marqueeSelection.rect.left;
      const currentY = e.clientY - marqueeSelection.rect.top;
      
      // Calculate marquee bounds
      const left = Math.min(marqueeSelection.startX, currentX);
      const top = Math.min(marqueeSelection.startY, currentY);
      const width = Math.abs(currentX - marqueeSelection.startX);
      const height = Math.abs(currentY - marqueeSelection.startY);
      
      // Update marquee element
      marqueeSelection.element.style.left = `${left}px`;
      marqueeSelection.element.style.top = `${top}px`;
      marqueeSelection.element.style.width = `${width}px`;
      marqueeSelection.element.style.height = `${height}px`;
      
      // Highlight diamonds within marquee
      const diamonds = marqueeSelection.trackArea.querySelectorAll('.keyframe-diamond');
      diamonds.forEach(d => {
        const dx = parseFloat(d.style.left) || 0;
        const dy = parseFloat(d.style.top) || 0;
        const isInside = dx >= left && dx <= left + width && dy >= top && dy <= top + height;
        if (isInside) {
          d.classList.add('selected');
        } else if (!e.shiftKey && !e.ctrlKey && !e.metaKey) {
          d.classList.remove('selected');
        }
      });
      return;
    }
    
    if (!draggedKeyframe) return;

    if (!draggedKeyframe.moved) {
      if (!keyframeDragActivated({
        startX: draggedKeyframe.startX,
        startY: draggedKeyframe.startY,
        clientX: e.clientX,
        clientY: e.clientY,
      })) return;
      draggedKeyframe.moved = true;
      saveUndoState();
    }
    
    const rect = draggedKeyframe.trackArea.getBoundingClientRect();
    const padding = 8;
    const x = clamp(e.clientX - rect.left, 0, rect.width);
    const y = clamp(e.clientY - rect.top, padding, rect.height - padding);
    
    const segId = getSegmentId(selectedSegment);
    
    if (isDraggingMultiple && selectedKeyframes.length > 1) {
      // Multi-drag: only move vertically (change values)
      const deltaY = e.clientY - draggedKeyframe.startY;
      
      for (const sel of selectedKeyframes) {
        const kfs = keyframeData[segId]?.[sel.paramId];
        if (!kfs || !kfs[sel.index]) continue;
        
        const paramRange = getParamRange(sel.paramId);
        const minV = paramRange.min;
        const maxV = paramRange.max;
        const range = maxV - minV || 1;
        
        // Calculate value delta based on mouse movement
        const valueDelta = -deltaY * range / (rect.height - padding * 2);
        const startValue = draggedKeyframe.startValues[`${sel.paramId}_${sel.index}`];
        if (startValue !== undefined) {
          kfs[sel.index].value = clamp(startValue + valueDelta, minV, maxV);
        }
        
        // Update diamond position
        const diamond = marqueeSelection?.trackArea?.querySelector(`.keyframe-diamond[data-param="${sel.paramId}"][data-index="${sel.index}"]`) ||
                        document.querySelector(`.keyframe-diamond[data-param="${sel.paramId}"][data-index="${sel.index}"]`);
        if (diamond) {
          const normalizedV = (kfs[sel.index].value - minV) / range;
          const newY = padding + (1 - normalizedV) * (rect.height - padding * 2);
          diamond.style.top = `${newY}px`;
        }
      }
      
      // Redraw tracks
      drawAllKeyframeTracks(segId);
    } else {
      // Single keyframe drag
      // Calculate new time and value
      const rawTime = (x / rect.width) * keyframeClipDuration;
      const newTime = snapKeyframeTime(rawTime);
      const paramRange = getParamRange(draggedKeyframe.paramId);
      const minV = paramRange.min;
      const maxV = paramRange.max;
      const range = maxV - minV || 1;
      const normalizedY = (y - padding) / (rect.height - padding * 2);
      const newValue = maxV - normalizedY * range;
      
      // Calculate snapped X position for visual feedback
      const snappedX = (newTime / keyframeClipDuration) * rect.width;
      
      // Update diamond position visually (use snapped X)
      draggedKeyframe.element.style.left = `${snappedX}px`;
      draggedKeyframe.element.style.top = `${y}px`;
      
      // Update keyframe data temporarily for live preview
      const kfs = keyframeData[segId]?.[draggedKeyframe.paramId] || [];
      const kf = kfs[draggedKeyframe.index];
      if (kf) {
        kf.time = newTime;
        kf.value = newValue;
        // Redraw curve (but not diamonds to avoid flicker)
        const canvas = draggedKeyframe.trackArea.querySelector('.keyframe-track-canvas');
        if (canvas) {
          drawSingleKeyframeTrack(canvas, segId, draggedKeyframe.paramId);
        }
      }
    }
  });
  
  window.addEventListener('mouseup', (e) => {
    // Handle marquee selection end
    if (marqueeSelection) {
      // Collect selected keyframes from the marquee
      const diamonds = marqueeSelection.trackArea.querySelectorAll('.keyframe-diamond.selected');
      selectedKeyframes = [];
      diamonds.forEach(d => {
        selectedKeyframes.push({
          paramId: d.dataset.param,
          index: parseInt(d.dataset.index)
        });
      });
      selectedKeyframe = selectedKeyframes.length > 0 ? selectedKeyframes[0] : null;
      
      // Remove marquee element
      marqueeSelection.element.remove();
      marqueeSelection = null;
      
      // Set flag to prevent the subsequent click event from deselecting
      justFinishedMarquee = true;
      return;
    }
    
    if (!draggedKeyframe) return;

    if (!draggedKeyframe.moved) {
      draggedKeyframe = null;
      isDraggingMultiple = false;
      return;
    }
    
    const rect = draggedKeyframe.trackArea.getBoundingClientRect();
    const x = clamp(e.clientX - rect.left, 0, rect.width);
    const padding = 8;
    const y = clamp(e.clientY - rect.top, padding, rect.height - padding);
    
    const segId = getSegmentId(selectedSegment);
    
    if (isDraggingMultiple && selectedKeyframes.length > 1) {
      // Multi-drag finished - values already updated in mousemove
      // Sort keyframes by time
      for (const sel of selectedKeyframes) {
        const kfs = keyframeData[segId]?.[sel.paramId];
        if (kfs) kfs.sort((a, b) => a.time - b.time);
      }
    } else {
      // Single keyframe drag
      const rawTime = (x / rect.width) * keyframeClipDuration;
      const newTime = snapKeyframeTime(rawTime);
      
      // Calculate new value based on Y position using slider range
      const paramRange = getParamRange(draggedKeyframe.paramId);
      const minV = paramRange.min;
      const maxV = paramRange.max;
      const range = maxV - minV || 1;
      
      // Y position: top = max, bottom = min
      const normalizedY = (y - padding) / (rect.height - padding * 2);
      const newValue = maxV - normalizedY * range; // Invert because Y grows downward
      
      // Update keyframe
      const kfs = keyframeData[segId]?.[draggedKeyframe.paramId] || [];
      const kf = kfs[draggedKeyframe.index];
      if (kf) {
        kf.time = newTime;
        kf.value = newValue;
        kfs.sort((a, b) => a.time - b.time);
      }
    }
    
    draggedKeyframe = null;
    isDraggingMultiple = false;
    renderKeyframeEditor();
    scheduleTimelineAutoSave();
  });
  
  // Delete keyframe(s) with Delete/Backspace
  document.addEventListener('keydown', (e) => {
    if (editorMode !== 'keyframe') return;
    // Skip if typing in text fields
    const tag = e.target.tagName;
    if (tag === 'INPUT' || tag === 'TEXTAREA') return;
    // Don't handle if dropdown/select has focus (user might be using keyboard navigation)
    if (tag === 'SELECT') return;
    
    if ((e.code === 'Delete' || e.code === 'Backspace') && (selectedKeyframe || selectedKeyframes.length > 0)) {
      const segId = getSegmentId(selectedSegment);
      
      // Save undo state before removing keyframe(s)
      saveUndoState();
      
      if (selectedKeyframes.length > 1) {
        // Delete multiple keyframes - sort by index descending to avoid index shifting issues
        const toDelete = [...selectedKeyframes].sort((a, b) => b.index - a.index);
        for (const kf of toDelete) {
          removeKeyframe(segId, kf.paramId, kf.index);
        }
        selectedKeyframes = [];
      } else if (selectedKeyframe) {
        removeKeyframe(segId, selectedKeyframe.paramId, selectedKeyframe.index);
      }
      
      selectedKeyframe = null;
      renderKeyframeEditor();
      scheduleTimelineAutoSave();
      e.preventDefault();
    }
  });
  
  // Enter key to add keyframe at playhead position on selected track
  document.addEventListener('keydown', (e) => {
    if (editorMode !== 'keyframe') return;
    if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
    if (e.code !== 'Enter') return;
    
    // Must have a selected track
    if (!selectedTrack) return;
    
    // Must have a selected segment
    if (!selectedSegment) return;
    
    const seg = getCurrentSegment();
    if (!seg) return;
    
    const segId = getSegmentId(selectedSegment);
    const paramId = selectedTrack;
    
    // Calculate local time (relative to clip start)
    const currentTime = getCurrentTime();
    const rawLocalTime = currentTime - seg.start;
    const localTime = snapKeyframeTime(rawLocalTime);
    
    // Check if playhead is within clip bounds
    if (localTime < 0 || localTime > (seg.end - seg.start)) return;
    
    // Check if a keyframe already exists at this time (within small tolerance)
    const kfs = keyframeData[segId]?.[paramId] || [];
    const existingIdx = kfs.findIndex(k => Math.abs(k.time - localTime) < 0.001);
    if (existingIdx >= 0) {
      // Keyframe already exists, do nothing
      return;
    }
    
    // Get current parameter value from slider
    const slider = document.getElementById(paramId);
    const value = slider ? parseFloat(slider.value) : 0;
    
    // Save undo state before adding keyframe
    saveUndoState();
    
    addKeyframe(segId, paramId, localTime, value, 'linear');
    renderKeyframeEditor();
    scheduleTimelineAutoSave();
    e.preventDefault();
  });
  
  // Easing context menu
  function showEasingMenu(segId, paramId, keyframeIndex, x, y) {
    // Remove any existing menu
    document.querySelectorAll('.easing-menu').forEach(m => m.remove());
    
    const kf = keyframeData[segId]?.[paramId]?.[keyframeIndex];
    if (!kf) return;
    
    const menu = document.createElement('div');
    menu.className = 'easing-menu';
    menu.innerHTML = `
      <div class="easing-option${kf.easing === 'linear' ? ' active' : ''}" data-easing="linear">Linear</div>
      <div class="easing-option${kf.easing === 'easeIn' ? ' active' : ''}" data-easing="easeIn">Ease In</div>
      <div class="easing-option${kf.easing === 'easeOut' ? ' active' : ''}" data-easing="easeOut">Ease Out</div>
      <div class="easing-option${kf.easing === 'easeInOut' ? ' active' : ''}" data-easing="easeInOut">Ease In-Out</div>
    `;
    menu.style.left = `${x}px`;
    menu.style.top = `${y}px`;
    document.body.appendChild(menu);
    
    menu.addEventListener('click', (e) => {
      const easing = e.target.dataset.easing;
      if (easing) {
        // Save undo state before changing easing
        saveUndoState();
        kf.easing = easing;
        renderKeyframeEditor();
        scheduleTimelineAutoSave();
      }
      menu.remove();
    });
    
    // Close menu on click elsewhere
    const closeMenu = (e) => {
      if (!menu.contains(e.target)) {
        menu.remove();
        document.removeEventListener('click', closeMenu);
      }
    };
    setTimeout(() => document.addEventListener('click', closeMenu), 0);
  }
  
  keyframeEditorController = createKeyframeEditorController({
    waveCanvas: keyframeWaveCanvas,
    rulerCanvas: keyframeRulerCanvas,
    viewport: keyframeViewport,
    snapButton: kfSnapBtn,
    gridSelect: kfGridSelect,
    windowTarget: window,
    documentTarget: document,
    getMetrics: () => ({
      start: keyframeClipStart,
      duration: keyframeClipDuration,
      pixelsPerSecond: keyframePxPerSec,
    }),
    isKeyframeMode: () => editorMode === 'keyframe',
    hasSelection: () => !!selectedSegment,
    getSnapEnabled: () => kfSnapEnabled,
    setSnapEnabled: enabled => { kfSnapEnabled = enabled; },
    onSeek: time => {
      if (audioCtx.state === 'suspended') audioCtx.resume();
      silentTime = time;
      audioClipPlayback?.sync(timelineTracks, time, silentPlaying);
      updatePlayhead(true);
    },
    onRedraw: () => drawAllKeyframeTracks(getSegmentId(selectedSegment)),
  });

  // =========================
  // KEYFRAME K-BUTTON HANDLER
  // =========================
  document.addEventListener('click', (e) => {
    if (!e.target.classList.contains('keyframe-toggle')) return;
    
    const paramId = e.target.dataset.param;
    const segId = selectedSegment ? getSegmentId(selectedSegment) : null;
    if (!segId) {
      alert('Please select a clip first');
      return;
    }
    
    if (!exposedParams[segId]) exposedParams[segId] = new Set();
    
    if (exposedParams[segId].has(paramId)) {
      // Removing - confirm if keyframes exist
      if (keyframeData[segId]?.[paramId]?.length > 0) {
        if (!confirm('Remove keyframes for this parameter?')) return;
      }
      // Save undo state before removing
      saveUndoState();
      if (keyframeData[segId]?.[paramId]) {
        delete keyframeData[segId][paramId];
      }
      exposedParams[segId].delete(paramId);
      // Also remove metadata
      if (exposedParamsMeta[segId]?.[paramId]) {
        delete exposedParamsMeta[segId][paramId];
      }
      e.target.classList.remove('active');
    } else {
      // Save undo state before adding
      saveUndoState();
      exposedParams[segId].add(paramId);
      
      // Store metadata about which object this param belongs to AND the slider range
      if (!exposedParamsMeta[segId]) exposedParamsMeta[segId] = {};
      
      // Get and store the current slider range
      const slider = document.getElementById(paramId);
      const sliderMin = slider ? parseFloat(slider.min) : 0;
      const sliderMax = slider ? parseFloat(slider.max) : 1;
      const sliderStep = slider ? parseFloat(slider.step) : NaN;
      const sliderValue = slider ? parseFloat(slider.value) : NaN;
      
      exposedParamsMeta[segId][paramId] = {
        min: isNaN(sliderMin) ? 0 : sliderMin,
        max: isNaN(sliderMax) ? 1 : sliderMax,
        step: Number.isFinite(sliderStep) ? sliderStep : undefined
      };
      
      // Add object-specific metadata
      if (selectedSceneObject) {
        if (selectedSceneObject.type === 'light' && paramId.startsWith('light_')) {
          exposedParamsMeta[segId][paramId].lightIndex = parseInt(selectedSceneObject.id) || 0;
        } else if (selectedSceneObject.type === 'object' && paramId.startsWith('obj_')) {
          exposedParamsMeta[segId][paramId].objectId = selectedSceneObject.id;
        }
      }

      // First exposure captures the current parameter value at the playhead.
      const segment = getCurrentSegment();
      const initialKeyframe = initialKeyframeAtPlayhead({
        playheadTime: getCurrentTime(),
        segmentStart: segment?.start,
        segmentEnd: segment?.end,
        value: sliderValue,
        min: exposedParamsMeta[segId][paramId].min,
        max: exposedParamsMeta[segId][paramId].max,
      });
      if (initialKeyframe) {
        addKeyframe(
          segId,
          paramId,
          initialKeyframe.time,
          initialKeyframe.value,
          initialKeyframe.easing,
        );
        selectedTrack = paramId;
      }
      
      e.target.classList.add('active');
      
      // Auto-switch to keyframe mode when adding a keyframe track
      if (editorMode !== 'keyframe') {
        setEditorMode('keyframe');
      }
    }
    
    // Gray out the slider if keyframes exist, re-enable if removed
    updateSliderKeyframeState(paramId, segId);
    
    if (editorMode === 'keyframe') {
      renderKeyframeEditor();
    }
    
    // Save state
    scheduleTimelineAutoSave();
  });
  
  // Update slider disabled state based on whether keyframes exist
  function updateSliderKeyframeState(paramId, segId) {
    const slider = document.getElementById(paramId);
    if (!slider) return;
    
    // Use the isParamExposed function which already checks object/light ownership
    const hasKeyframes = isParamExposed(segId, paramId);
    
    const sliderWrapper = slider.closest('.slider-wrapper') || slider.parentElement;
    const kBtn = document.querySelector(`.keyframe-toggle[data-param="${paramId}"]`);
    
    if (hasKeyframes) {
      slider.disabled = true;
      slider.classList.add('has-keyframes');
      if (sliderWrapper) sliderWrapper.classList.add('has-keyframes');
      if (kBtn) kBtn.classList.add('active');
    } else {
      slider.disabled = false;
      slider.classList.remove('has-keyframes');
      if (sliderWrapper) sliderWrapper.classList.remove('has-keyframes');
      if (kBtn) kBtn.classList.remove('active');
    }
  }
  
  // Update all sliders' keyframe state for current segment
  function updateAllSliderKeyframeStates() {
    if (!selectedSegment) return;
    const segId = getSegmentId(selectedSegment);
    
    // Find all sliders with K buttons
    document.querySelectorAll('.keyframe-toggle').forEach(kBtn => {
      const paramId = kBtn.dataset.param;
      if (paramId) {
        updateSliderKeyframeState(paramId, segId);
      }
    });
  }

  btnReloadScenes.addEventListener("click", async () => {
    btnReloadScenes.disabled = true;
    btnReloadScenes.style.opacity = "0.5";
    await loadAllScenes();
    resizeRenderer();
    btnReloadScenes.style.opacity = "";
    btnReloadScenes.disabled = false;
  });

  btnExport.addEventListener("click", () => {
    // Convert exposedParams Sets to arrays for JSON serialization
    const exposedParamsArray = {};
    for (const [segId, paramSet] of Object.entries(exposedParams)) {
      exposedParamsArray[segId] = [...paramSet];
    }
    
    const data = {
      version: 7,
      audio: AUDIO_URL,
      duration: durationGuess,
      sceneFiles: SCENE_FILES,
      tracks: timelineTracks,
      trackIdCounter: trackIdCounter,
      sceneSegments: getSceneSegments(),
      imageSegments: getImageSegments(),
      postfxSegments: getPostfxSegments(),
      shaderSegments: getShaderSegments(),
      keyframeData,
      exposedParams: exposedParamsArray,
      exposedParamsMeta,
      curveTypes,
    };
    const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
    const a = document.createElement("a");
    a.href = URL.createObjectURL(blob);
    a.download = "demotool_timeline.json";
    a.click();
  });

  btnImport.addEventListener("click", () => {
    const inp = document.createElement("input");
    inp.type = "file";
    inp.accept = "application/json";
    inp.onchange = async () => {
      const file = inp.files?.[0];
      if (!file) return;
      saveUndoState();
      const txt = await file.text();
      const data = JSON.parse(txt);
      projectLoadedFromServer = true; // Prevent auto-extension when loading from file
      
      // Load tracks if available (new format)
      if (data.tracks) {
        timelineTracks = data.tracks;
        if (data.trackIdCounter !== undefined) trackIdCounter = data.trackIdCounter;
      } else {
        // Migrate from old format
        timelineTracks = [];
        trackIdCounter = 0;
        timelineTracks.push({ id: generateTrackId(), type: 'scene', name: 'Scene 1', order: 0, segments: data.sceneSegments || [] });
        timelineTracks.push({ id: generateTrackId(), type: 'shader', name: 'Shader 1', order: 1, segments: data.shaderSegments || [] });
        timelineTracks.push({ id: generateTrackId(), type: 'media', name: 'Media 1', order: 2, segments: data.imageSegments || [] });
        timelineTracks.push({ id: generateTrackId(), type: 'postfx', name: 'Post FX 1', order: 3, segments: data.postfxSegments || [] });
      }
      migrateSceneClipOverrideOwnership();
      
      // Load keyframe data
      if (data.keyframeData) keyframeData = data.keyframeData;
      
      // Convert exposedParams arrays back to Sets
      if (data.exposedParams) {
        exposedParams = {};
        for (const [segId, paramArray] of Object.entries(data.exposedParams)) {
          exposedParams[segId] = new Set(paramArray);
        }
      }
      
      // Load exposed params metadata
      if (data.exposedParamsMeta) {
        exposedParamsMeta = data.exposedParamsMeta;
      }
      
      // Load curve types
      if (data.curveTypes) {
        curveTypes = data.curveTypes;
      }
      
      // Preload any media (images/videos) referenced
      for (const seg of getImageSegments()) {
        if (seg.imageFile) {
          seg.isVideo = isVideoFile(seg.imageFile);
          loadMedia(seg.imageFile).catch(err => console.warn('Failed to load media:', seg.imageFile));
        }
      }
      renderTracks();
      
      // If in keyframe mode, refresh the editor
      if (editorMode === 'keyframe') {
        renderKeyframeEditor();
      }
    };
    inp.click();
  });

  // Undo/Redo buttons
  btnUndo.addEventListener("click", performUndo);
  btnRedo.addEventListener("click", performRedo);

  function resolveSelectedTimelineClip() {
    if (!selectedSegment) return null;
    if (selectedSegment.trackId) {
      const track = getTrackById(selectedSegment.trackId);
      const segment = track?.segments?.[selectedSegment.index];
      return track && segment
        ? { track, segment, segments: track.segments, index: selectedSegment.index }
        : null;
    }

    const typeByLegacyName = {
      audios: 'audio',
      scenes: 'scene',
      shaders: 'shader',
      images: 'media',
      postfx: 'postfx',
      texts: 'text',
      luts: 'lut',
    };
    const type = typeByLegacyName[selectedSegment.track];
    if (!type) return null;
    let remainingIndex = selectedSegment.index;
    for (const track of getTracksByType(type)) {
      if (remainingIndex < track.segments.length) {
        return {
          track,
          segment: track.segments[remainingIndex],
          segments: track.segments,
          index: remainingIndex,
        };
      }
      remainingIndex -= track.segments.length;
    }
    return null;
  }

  function handleTimelineClipEditingShortcut(event) {
    const command = timelineClipShortcut(event);
    if (
      !command
      || editorMode !== 'timeline'
      || document.querySelector('.demo-modal-overlay.show')
    ) return false;
    const record = resolveSelectedTimelineClip();
    if (command.type === 'rename') {
      const selectedTrack = selectedTimelineTrack
        ? getTrackById(selectedTimelineTrack)
        : null;
      if (!selectedTrack && !record) return false;
      event.preventDefault();
      if (event.repeat) return true;

      if (selectedTrack) {
        const currentName = String(selectedTrack.name || '').trim();
        clipRenameDialogController.show({
          id: 'trackRenameDialog',
          title: 'Rename track',
          label: 'Track name',
          value: currentName,
          placeholder: 'Track name',
          confirmLabel: 'Rename',
          emptyMessage: 'Enter a track name.',
          onSubmit: nextName => {
            const result = renameTimelineTrack(
              timelineTracks,
              selectedTrack.id,
              nextName,
            );
            if (!result.changed) return;
            saveUndoState();
            timelineTracks = result.tracks;
            renderTracks();
            scheduleTimelineAutoSave();
            showToast(`Renamed track to “${result.name}”`);
          },
        });
        return true;
      }

      const currentName = String(
        segmentDisplayName(record.segment, record.track.type, record.index),
      ).trim();
      clipRenameDialogController.show({
        id: 'clipRenameDialog',
        title: 'Rename clip',
        label: 'Clip name',
        value: currentName,
        placeholder: 'Clip name',
        confirmLabel: 'Rename',
        emptyMessage: 'Enter a clip name.',
        onSubmit: nextName => {
          if (nextName === currentName) return;
          saveUndoState();
          record.segment.name = nextName;
          const trackLabel = selectedSegment?.track || record.track.type;
          selName.textContent = `${nextName} (${trackLabel})`;
          renderSegments();
          populateObjectList(trackLabel, record.segment);
          scheduleTimelineAutoSave();
          showToast(`Renamed clip to “${nextName}”`);
        },
      });
      return true;
    }

    event.preventDefault();
    if (!record) {
      if (!event.repeat) showToast('Select a timeline clip first');
      return true;
    }

    const preventOverlap = ['audio', 'scene', 'shader'].includes(record.track.type);
    const result = applyTimelineClipShortcut({
      command,
      segment: record.segment,
      segments: record.segments,
      index: record.index,
      playhead: getCurrentTime(),
      duration: durationGuess,
      frameRate: projectFrameRate,
      preventOverlap,
      trackType: record.track.type,
    });
    if (!result.changed) {
      if (!event.repeat) {
        const reasonMessages = {
          'already-at-playhead': 'Clip edge is already at the playhead',
          'playhead-outside': 'Playhead would cross the opposite clip edge',
          'source-bound': 'No more source media is available in that direction',
        };
        showToast(reasonMessages[result.reason] || 'Clip edge cannot reach the playhead');
      }
      return true;
    }

    const previousStart = record.segment.start;
    const previousEnd = record.segment.end;
    if (!event.repeat) saveUndoState();
    Object.assign(record.segment, result.patch);
    selStart.value = record.segment.start.toFixed(2);
    selEnd.value = record.segment.end.toFixed(2);
    if (record.track.type === 'audio') {
      refreshPrimaryAudioState();
      audioClipPlayback?.sync(timelineTracks, getCurrentTime(), silentPlaying);
    }
    renderSegments();
    drawWaveAndRuler();
    scheduleTimelineAutoSave();

    if (!event.repeat) {
      const message = command.type === 'trim-start'
        ? `${record.segment.start < previousStart ? 'Extended' : 'Trimmed'} clip start to playhead`
        : command.type === 'trim-end'
          ? `${record.segment.end > previousEnd ? 'Extended' : 'Trimmed'} clip end to playhead`
          : `Nudged ${Math.abs(command.frames)} frame${Math.abs(command.frames) === 1 ? '' : 's'} ${command.direction < 0 ? 'left' : 'right'}`;
      showToast(message);
    }
    return true;
  }

  // Keyboard shortcuts for undo/redo, debug toggle, and delete
  document.addEventListener("keydown", (e) => {
    // Check if user is typing in a text input (not sliders or checkboxes)
    const isTextInput = e.target.tagName === "TEXTAREA" || 
                        e.target.tagName === "SELECT" ||
                        (e.target.tagName === "INPUT" && 
                         e.target.type !== "range" && 
                         e.target.type !== "checkbox" &&
                         e.target.type !== "color");
    
    // Undo/Redo should work universally except when typing in text inputs
    if ((e.ctrlKey || e.metaKey) && e.code === "KeyZ") {
      if (!isTextInput) {
        e.preventDefault();
        if (e.shiftKey) {
          performRedo();
        } else {
          performUndo();
        }
      }
      return;
    }
    
    if ((e.ctrlKey || e.metaKey) && e.code === "KeyY") {
      if (!isTextInput) {
        e.preventDefault();
        performRedo();
      }
      return;
    }
    
    // Group/Ungroup (Cmd/Ctrl + G)
    if ((e.ctrlKey || e.metaKey) && e.code === "KeyG") {
      if (!isTextInput && currentEditingSceneIdx >= 0) {
        e.preventDefault();
        handleGroupShortcut(currentEditingSceneIdx);
      }
      return;
    }
    
    // Other shortcuts only work when not in any input
    if (isTextInput) {
      return;
    }

    if (handleTimelineClipEditingShortcut(e)) return;
    
    // Debug mode toggle (comma key)
    if (e.code === "Comma") {
      e.preventDefault();
      debugMode = !debugMode;
      inspector.classList.toggle("debug-visible", debugMode);
    }
    
    // Delete selected post-fx, shader, image, text, or LUT segment (Backspace or Delete)
    // Only delete segments in timeline mode - in keyframe mode, Delete removes keyframes instead
    if (e.code === "Backspace" || e.code === "Delete") {
      if (editorMode === 'timeline' && selectedSegment && (selectedSegment.track === 'postfx' || selectedSegment.track === 'images' || selectedSegment.track === 'shaders' || selectedSegment.track === 'scenes' || selectedSegment.track === 'texts' || selectedSegment.track === 'luts' || selectedSegment.track === 'audios')) {
        e.preventDefault();
        deleteSelectedSegment();
      }
    }
    
    handleSegmentClipboardShortcut(e, {
      editorMode,
      selectedSegment,
      clipboardSegment,
      copy: copySelectedSegment,
      cut: cutSelectedSegment,
      paste: pasteSegment,
      notify: showToast,
    });
  });
  
  // Delete the currently selected segment (scenes, postfx, shader, image, text, or LUT)
  function deleteSelectedSegment() {
    if (!selectedSegment) return;
    if (selectedSegment.track !== 'postfx' && selectedSegment.track !== 'images' && selectedSegment.track !== 'shaders' && selectedSegment.track !== 'scenes' && selectedSegment.track !== 'texts' && selectedSegment.track !== 'luts' && selectedSegment.track !== 'audios') return;
    
    const trackName = selectedSegment.track;
    const index = selectedSegment.index;
    
    // Get segments from specific track if available
    let segs;
    if (selectedSegment.trackId) {
      const track = getTrackById(selectedSegment.trackId);
      segs = track ? track.segments : [];
    } else {
      segs = getSegmentsForTrack(trackName);
    }
    
    if (index < 0 || index >= segs.length) return;
    
    // Save state for undo
    saveUndoState();

    const trackId = selectedSegment.trackId;
    if (trackName === 'scenes' && trackId) {
      releaseSegmentSceneInstance(trackId, index);
    }
    
    // Clean up keyframe data for this segment and shift remaining segments
    // Use trackId-based segment ID if available
    cleanupKeyframeDataForDeletedSegment(trackName, index, segs.length, trackId);
    
    // Remove the segment
    segs.splice(index, 1);
    refreshPrimaryAudioState();
    audioClipPlayback?.sync(timelineTracks, getCurrentTime(), silentPlaying);
    
    // Clear selection
    selectedSegment = null;
    selectedSceneObject = null;
    
    // Clear panels
    objectListContent.innerHTML = '<div class="panel-empty">Select a segment from the timeline</div>';
    paramContent.innerHTML = '<div class="panel-empty">Select an object to edit parameters</div>';
    
    // Re-render timeline
    renderSegments();
    scheduleTimelineAutoSave();
  }
  
  // Clean up keyframe data when a segment is deleted
  function cleanupKeyframeDataForDeletedSegment(track, deletedIndex, totalSegments, trackId = null) {
    if (trackId) {
      const timelineTrack = getTrackById(trackId);
      const segment = timelineTrack?.segments?.[deletedIndex];
      if (segment) {
        clearSegmentState(
          [keyframeData, exposedParams, exposedParamsMeta, curveTypes, lfoData],
          track,
          trackId,
          segment,
          deletedIndex,
        );
        return;
      }
    }
    removeIndexedSegmentState(
      [keyframeData, exposedParams, exposedParamsMeta, curveTypes, lfoData],
      track,
      trackId,
      deletedIndex,
      totalSegments,
    );
  }

  // =========================
  // COPY/CUT/PASTE SEGMENTS
  // =========================
  
  // Copy the currently selected segment to clipboard
  function copySelectedSegment() {
    if (!selectedSegment) return false;
    
    const track = selectedSegment.track;
    const index = selectedSegment.index;
    
    // Get the actual segment from the specific track, not the flattened array
    let seg = null;
    if (selectedSegment.trackId) {
      const actualTrack = getTrackById(selectedSegment.trackId);
      if (actualTrack && actualTrack.segments[index]) {
        seg = actualTrack.segments[index];
      }
    }
    
    // Fallback to legacy method (single track per type)
    if (!seg) {
    const segs = getSegmentsForTrack(track);
      if (index >= 0 && index < segs.length) {
        seg = segs[index];
      }
    }
    
    if (!seg) return false;
    
    // Deep clone the segment
    const segmentCopy = JSON.parse(JSON.stringify(seg));
    
    // For scene segments, capture the current scene state at copy time
    // This ensures the copied segment has all current settings (cloner params, etc.)
    if (track === 'scenes' && seg.sceneFile) {
      const sceneKey = selectedSegment.trackId
        ? getSegmentKey(selectedSegment.trackId, selectedSegment.index)
        : null;
      const sceneObj = sceneKey ? segmentSceneInstances.get(sceneKey) : null;
      const currentState = sceneObj
        ? serializeRuntimeSceneState(THREE, sceneObj)
        : seg.sceneOverrides;
      segmentCopy.sceneOverrides = cloneSerializable(currentState || {});
      console.log(`[Copy] Captured clip-owned scene state for ${segmentCopy.id || 'segment'}`);
    }
    
    clipboardSegment = createSegmentClipboardPayload({
      track,
      trackId: selectedSegment.trackId,
      index,
      segment: segmentCopy,
      keyframeData,
      curveTypes,
      lfoData,
      exposedParams,
      exposedParamsMeta,
    });
    
    console.log(`Copied ${track} segment "${seg.name || seg.id}" to clipboard`);
    return true;
  }
  
  // Cut the currently selected segment (copy + delete)
  function cutSelectedSegment() {
    if (!selectedSegment) return false;
    
    if (copySelectedSegment()) {
      deleteSelectedSegment();
      console.log('Cut segment to clipboard');
      return true;
    }
    return false;
  }
  
  // Paste segment from clipboard at playhead position
  function pasteSegment() {
    if (!clipboardSegment) {
      console.log('Nothing in clipboard to paste');
      return false;
    }
    
    const { track, trackId: sourceTrackId, segment } = clipboardSegment;
    
    // Calculate new segment position at playhead
    const playheadTime = getCurrentTime();
    const duration = segment.end - segment.start;
    
    // Create new segment with unique ID
    const newSeg = JSON.parse(JSON.stringify(segment));
    newSeg.start = playheadTime;
    newSeg.end = playheadTime + duration;
    
    // Generate new unique ID based on track type
    if (track === 'scenes') {
      sceneIdCounter++;
      newSeg.id = `scene${sceneIdCounter}`;
      newSeg.color = `hsl(${210 + sceneIdCounter * 18}, 70%, 60%)`;
    } else if (track === 'shaders') {
      shaderIdCounter++;
      newSeg.id = `shader${shaderIdCounter}`;
      newSeg.color = `hsl(${270 + shaderIdCounter * 25}, 60%, 55%)`;
    } else if (track === 'images') {
      imageIdCounter++;
      newSeg.id = `image${imageIdCounter}`;
      newSeg.color = `hsl(${120 + imageIdCounter * 30}, 60%, 50%)`;
    } else if (track === 'postfx') {
      postfxIdCounter++;
      newSeg.id = `postfx${postfxIdCounter}`;
      newSeg.color = `hsl(${30 + postfxIdCounter * 15}, 70%, 55%)`;
    } else if (track === 'texts') {
      textIdCounter++;
      newSeg.id = `text${textIdCounter}`;
      newSeg.color = `hsl(${180 + textIdCounter * 20}, 65%, 50%)`;
    }
    
    // Save state for undo
    saveUndoState();
    
    // Find the target track - prefer the same track as the source, otherwise use first track of that type
    const trackType = track === 'scenes' ? 'scene' : track === 'shaders' ? 'shader' : track === 'images' ? 'media' : track === 'texts' ? 'text' : 'postfx';
    let targetTrack = null;
    
    // Try to use the same track as the source
    if (sourceTrackId) {
      targetTrack = getTrackById(sourceTrackId);
    }
    
    // Fallback to first track of this type
    if (!targetTrack) {
      const targetTracks = getTracksByType(trackType);
      if (targetTracks.length === 0) return false;
      targetTrack = targetTracks[0];
    }

    newSeg.start = findAvailableSegmentStart(targetTrack.segments, playheadTime, duration);
    newSeg.end = newSeg.start + duration;
    
    const trackSegs = targetTrack.segments;

    // Add the new segment to the target track
    targetTrack.segments.push(newSeg);
    
    // Sort segments by start time
    targetTrack.segments.sort((a, b) => a.start - b.start);
    
    // Find the new index after sorting
    const newIndex = targetTrack.segments.findIndex(s => s.id === newSeg.id);
    const newSegId = stableSegmentStateKey(
      track,
      targetTrack.id,
      newSeg,
      newIndex,
    );
    
    // Keyframe times are local to the clip, so the copied animation state can
    // be applied directly at its new stable clip key.
    applySegmentClipboardState(clipboardSegment, newSegId, {
      keyframeData,
      curveTypes,
      lfoData,
      exposedParams,
      exposedParamsMeta,
    });
    
    // The clipboard payload already contains the source clip's exact runtime
    // scene state. Clone it again so the pasted clip never shares object identity.
    if (track === 'scenes' && segment.sceneFile) {
      newSeg.sceneOverrides = cloneSerializable(segment.sceneOverrides || {});
      console.log(`[Paste] Copied independent scene overrides to ${newSeg.id}`);
    }
    
    // Re-render and select the new segment
    extendSilentTimelineTo(newSeg.end);
    renderTracks();
    selectSegmentInTrack(targetTrack.id, newIndex);
    scheduleTimelineAutoSave();
    scheduleDemoSave();
    
    console.log(`Pasted ${track} segment at ${newSeg.start.toFixed(2)}s`);
    return true;
  }

  // =========================
  // STARTUP
  // =========================
  async function loadWaveformInBackground() {
    // Skip waveform loading if no audio file is set
    if (!AUDIO_URL && !currentAudioFile) {
      console.log("No audio file - skipping waveform loading");
      waveStatus.textContent = "Waveform: none";
      drawWave(); // Draw the "No audio selected" message
      return;
    }
    
    // If we already have waveform data from project loading, skip
    if (wavePeaks && wavePeaks.length > 0) {
      console.log("Waveform already loaded from project");
      return;
    }
    
    // Try to load pre-generated waveform for the current audio file (fast path)
    // Only use this for the default audio file path
    if (AUDIO_URL === "assets/audio/chase29_p.mp3") {
    try {
      const waveRes = await fetch("assets/audio/waveform.json");
      if (waveRes.ok) {
        const waveData = await waveRes.json();
        wavePeaks = new Float32Array(waveData.peaks);
        if (waveData.duration) {
          durationGuess = Math.max(durationGuess, waveData.duration);
          updateDurationSegments();
        }
        waveStatus.textContent = "Waveform: ready";
        drawWaveAndRuler();
        console.log("Loaded pre-generated waveform");
        return;
      }
    } catch (e) {
      // No pre-generated waveform
      }
    }

    // Fall back to audio analysis (slower)
    waveStatus.textContent = "Waveform: analyzing...";
    try {
      const res = await fetch(AUDIO_URL); // Allow browser caching
      const buf = await res.arrayBuffer();
      const { peaks, duration } = await buildWaveformPeaks(buf);
      wavePeaks = peaks;
      durationGuess = Math.max(durationGuess, duration);
      updateDurationSegments();
      waveStatus.textContent = "Waveform: ready";
      drawWaveAndRuler();
      console.log("Waveform analyzed. Run exportWaveform() in console to save for faster loading.");
    } catch (e) {
      console.error("Failed to analyze waveform:", e);
      waveStatus.textContent = "Waveform: failed";
    }
  }

  function updateDurationSegments() {
    // Only extend scene segments to fill the timeline if we haven't loaded a saved project
    // When loading from server, scene lengths should be preserved as saved
    const allScenes = getSceneSegments();
    if (allScenes.length > 0 && !projectLoadedFromServer) {
      allScenes[allScenes.length - 1].end = durationGuess;
    }
    layoutTimeline();
    renderTracks();
  }

  async function start() {
    const isIOSDevice = /iPad|iPhone|iPod/.test(navigator.userAgent) || 
                        (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
    const debugPrefix = isIOSDevice ? '[iOS] ' : '';
    
    try {
      setProgress(0.05, debugPrefix + "Loading demo...");
      await initializeDemo();
      console.log('[Start] Demo initialized');
    } catch (err) {
      console.error('[Start] initializeDemo failed:', err);
      setProgress(0.05, debugPrefix + "Error: " + err.message);
      throw err;
    }
    
    // Apply any pending project data from server
    if (pendingProjectData) {
      try {
        // Scene overrides need to be in localStorage before loadAllScenes
        if (pendingSceneOverrides) {
          for (const [sceneName, data] of Object.entries(pendingSceneOverrides)) {
            localStorage.setItem(`demotool_scene_${sceneName}`, JSON.stringify(data));
          }
        }
        // Load the timeline/project data (this sets AUDIO_URL if project has audio)
        loadProjectFromServer(pendingProjectData);
        pendingProjectData = null;
        pendingSceneOverrides = null;
        console.log('[Start] Project data loaded');
      } catch (err) {
        console.error('[Start] loadProjectFromServer failed:', err);
      }
    }

    // Standalone/published playback must not advertise readiness until every
    // timeline image and video's first renderable frame is available. The
    // editor still preloads in the background so opening large projects stays
    // responsive while editing.
    if (isPlayMode) {
      setProgress(0.06, debugPrefix + "Preloading timeline media...");
      const mediaSummary = await preloadTimelineMedia(
        timelineTracks,
        loadMedia,
        {
          timeoutMs: 60000,
          onProgress: ({ loaded, total }) => {
            if (!total) return;
            setProgress(
              0.06 + (loaded / total) * 0.02,
              debugPrefix + `Preloading timeline media ${loaded}/${total}...`,
            );
          },
        },
      );
      if (mediaSummary.failed.length) {
        const failedFiles = mediaSummary.failed
          .slice(0, 3)
          .map(({ filename }) => filename)
          .join(', ');
        throw new Error(
          `Could not preload ${mediaSummary.failed.length} timeline media file(s): ${failedFiles}`,
        );
      }
      console.log(`[Start] Timeline media ready: ${mediaSummary.loaded}/${mediaSummary.total}`);
    }
    
    // Now load the audio (after project data is applied, so AUDIO_URL is correct)
    try {
      setProgress(0.08, debugPrefix + "Loading audio...");
      
      if (isPlayMode) {
        // PLAY MODE: Fast path - just set the audio source directly
        // No blob conversion needed since we don't need seeking
        console.log('[Start] Play mode - using streaming audio');
        if (AUDIO_URL || currentAudioFile) {
          const audioPath = currentAudioFile || AUDIO_URL;
          const audioUrl = audioPath.startsWith('/') ? audioPath : '/' + audioPath;
          audioEl.src = audioUrl;
          audioEl.preload = 'auto'; // Let browser decide buffering
          audioFullyLoaded = true; // Mark as ready to proceed
          console.log('[Start] Audio source set:', audioUrl);
        } else {
          console.log('[Start] No audio file in project');
          audioFullyLoaded = true;
        }
      } else {
        // EDITOR MODE: Load as blob for seeking support
        await loadAudioAsBlob();
      }
      console.log('[Start] Audio loaded');
    } catch (err) {
      console.error('[Start] loadAudioAsBlob failed:', err);
      // Continue without audio on iOS
      if (isIOSDevice) {
        console.log('[Start] iOS - continuing without audio');
      }
    }
    
    try {
      setProgress(0.1, debugPrefix + "Loading scenes...");
      await loadAllScenes();
      console.log('[Start] Scenes loaded');
    } catch (err) {
      console.error('[Start] loadAllScenes failed:', err);
      setProgress(0.1, debugPrefix + "Error loading scenes: " + err.message);
      throw err;
    }

    setProgress(0.7, debugPrefix + "Initializing renderer...");
    
    try {
      // Initialize post-fx pipeline
      initPostFxPipeline();
      console.log('[Start] Post-FX pipeline initialized');
      
      // Initialize scene compositing (for multi-scene layering)
      initSceneCompositing();
      console.log('[Start] Scene compositing initialized');
    } catch (err) {
      console.error('[Start] Renderer init failed:', err);
      setProgress(0.7, debugPrefix + "Renderer error: " + err.message);
      // Continue anyway - might still work
    }

    // Wait for audio to be loaded
    // Play mode: much shorter timeout since we just need to start
    // Editor mode: longer timeout for seeking support
    const audioTimeout = isPlayMode ? 500 : (isIOSDevice ? 2000 : 5000);
    setProgress(0.8, debugPrefix + "Waiting for audio...");
    
    await new Promise((resolve) => {
      let resolved = false;
      
      const checkAudio = () => {
        if (resolved) return;
        
        // In play mode, we can proceed as soon as we have any readyState
        // The browser will buffer as we play
        if (isPlayMode) {
          if (audioEl.readyState >= 1 || audioFullyLoaded) {
            // readyState 1 = HAVE_METADATA
            if (Number.isFinite(audioEl.duration)) {
              durationGuess = Math.max(durationGuess, timelineContentEnd(timelineTracks, 60));
            }
            console.log("[Start] Play mode - audio ready, duration:", durationGuess);
            connectAudioNodes();
            resolved = true;
            resolve();
            return;
          }
        }
        
        if (audioFullyLoaded && audioEl.duration && !isNaN(audioEl.duration) && Number.isFinite(audioEl.duration)) {
          durationGuess = Math.max(durationGuess, timelineContentEnd(timelineTracks, 60));
          console.log("[Start] Audio fully loaded, duration:", durationGuess);
          connectAudioNodes();
          resolved = true;
          resolve();
        } else if (audioEl.readyState >= 2) {
          // At least metadata loaded
          if (Number.isFinite(audioEl.duration)) {
            durationGuess = Math.max(durationGuess, timelineContentEnd(timelineTracks, 60));
          }
          console.log("[Start] Audio metadata ready, duration:", durationGuess, "fullyLoaded:", audioFullyLoaded);
          connectAudioNodes();
          resolved = true;
          resolve();
        }
      };
      
      // Check immediately
      checkAudio();
      
      // Also listen for events
      audioEl.addEventListener("loadedmetadata", checkAudio, { once: true });
      audioEl.addEventListener("canplaythrough", checkAudio, { once: true });
      
      // Timeout fallback
      setTimeout(() => {
        if (resolved) return;
        console.log("[Start] Audio load timeout after " + audioTimeout + "ms - proceeding anyway");
        connectAudioNodes();
        resolved = true;
        resolve();
      }, audioTimeout);
    });
    
    // Ensure durationGuess is valid
    if (!Number.isFinite(durationGuess) || durationGuess <= 0) {
      console.warn("[Start] Invalid duration, using fallback");
      durationGuess = 60; // fallback for empty projects
    }

    // Update segments with duration
    updateDurationSegments();
    console.log('[Start] Duration set to', durationGuess);

    // Skip timeline UI operations in play mode
    if (!isPlayMode) {
      waveStatus.textContent = "Waveform: loading...";
    }

    resizeRenderer();
    
    // Skip timeline layout in play mode - it's hidden anyway
    if (!isPlayMode) {
      layoutTimeline();
      
      // Initialize default tracks if none exist
      if (timelineTracks.length === 0) {
        createDefaultTracks();
      }
    }
    
    renderTracks();
    
    // Skip waveform-related operations in play mode - not needed for playback
    if (!isPlayMode) {
      drawWaveAndRuler();
    }
    
    // Pre-load scene instances for all scene segments
    // This ensures each segment has its own unique scene instance
    await preloadAllSegmentSceneInstances();

    // Load waveform in background (non-blocking) - skip in play mode
    if (!isPlayMode) {
      loadWaveformInBackground();
    }

    setProgress(1.0, debugPrefix + "Ready!");
  }

  btnStart.addEventListener("click", async () => {
    // Save original button state and show loading
    const originalText = btnStart.textContent;
    const originalDisabled = btnStart.disabled;
    btnStart.textContent = 'Loading...';
    btnStart.disabled = true;
    btnStart.style.opacity = '0.6';
    btnStart.style.cursor = 'wait';
    
    try {
      // Check if we need to redirect to continue an existing demo
      const urlParams = new URLSearchParams(window.location.search);
      const currentDemoIdFromUrl = urlParams.get('project');
      
      // If returning user with a last project, and we're not already on a project page, redirect
      if (lastDemoId && !currentDemoIdFromUrl) {
        window.location.href = `/editor?project=${lastDemoId}`;
        return; // Keep loading state during redirect
      }
      
      // Resume audio context with timeout - don't block if it fails
      // (Chrome requires user gesture, which we have now)
      try {
        const resumePromise = audioCtx.resume ? audioCtx.resume() : Promise.resolve();
        await Promise.race([
          resumePromise,
          new Promise((_, reject) => setTimeout(() => reject(new Error('AudioContext timeout')), 2000))
        ]);
        console.log('[Start] AudioContext resumed, state:', audioCtx.state);
      } catch (audioErr) {
        console.warn('[Start] AudioContext resume failed/timeout:', audioErr.message, '- continuing anyway');
        // Continue without audio - silent playback will work
      }
      
      await start();

      running = true;
      currentSceneIndex = 0;
      lastTime = performance.now();
      fpsTime = lastTime;
      requestAnimationFrame(render);

      startOverlay.style.display = "none";
      
      // Cleanup background shader
      if (window.startBgCleanup) window.startBgCleanup();
      
      // Initialize the object list panel with the first scene
      const sceneSegs = getSceneSegments();
      if (sceneSegs.length > 0 && !isPlayMode) {
        // Find the track containing the first scene
        const sceneTracks = getTracksByType('scene');
        if (sceneTracks.length > 0 && sceneTracks[0].segments.length > 0) {
          selectSegmentInTrack(sceneTracks[0].id, 0);
        }
      }
    } catch (err) {
      console.error(err);
      progressText.textContent = "Failed - check console";
      alert(String(err));
      
      // Restore button state on error
      btnStart.textContent = originalText;
      btnStart.disabled = originalDisabled;
      btnStart.style.opacity = '1';
      btnStart.style.cursor = 'pointer';
    }
  });
  
  // =========================
  // PLAY MODE AUTO-START
  // =========================
  console.log('[Editor] Reached play mode section, isPlayMode:', isPlayMode);
  
  if (isPlayMode) {
    // Only show play mode controls if NOT embedded in an iframe (player page has its own controls)
    const isEmbedded = window.self !== window.top;
    
    if (!isEmbedded) {
      // Add play mode controls only for standalone play mode
    const playModeControls = document.createElement('div');
    playModeControls.className = 'play-mode-controls';
    playModeControls.innerHTML = `
        <button class="play-mode-btn" id="playModePauseBtn">Pause</button>
        <button class="play-mode-btn" id="playModeRestartBtn">Restart</button>
    `;
    document.body.appendChild(playModeControls);
    }
    
    // Flag to track if playback has started (for mobile waiting for user tap)
    let playbackStarted = false;

    // The standalone shell calls this same-origin hook directly from its Start
    // tap. A postMessage handler is asynchronous and therefore too late for
    // Safari's media/WebAudio user-activation requirement.
    window.unlockStandaloneAudio = () => {
      try {
        audioCtx.resume?.().catch?.(() => {});
      } catch (error) {
        console.warn('[PlayMode] AudioContext unlock failed:', error);
      }
      return audioClipPlayback?.unlock(timelineTracks, silentTime || 0) || 0;
    };
    
    // Function to actually start playback (visuals + audio)
    function startPlayback() {
      if (playbackStarted) return;
      playbackStarted = true;
      
      console.log('[PlayMode] Starting playback (visuals + audio)');
      
      running = true;
      currentSceneIndex = 0;
      silentTime = 0; // Reset to start
      lastTime = performance.now();
      fpsTime = lastTime;
      requestAnimationFrame(render);
      console.log('[PlayMode] Render loop started');
      
      silentPlaying = true;
      audioClipPlayback?.sync(timelineTracks, 0, true);
      try {
        window.parent.postMessage({ type: EDITOR_EVENTS.AUDIO_PLAYING }, playerMessageOrigin);
      } catch (e) {}
      if (window.self !== window.top) {
        try {
          window.parent.postMessage({
            type: EDITOR_EVENTS.PLAY_STATE_CHANGED,
            isPlaying: true,
          }, playerMessageOrigin);
        } catch (e) {}
      }
    }
    
    // Load everything but wait for user interaction on mobile
    console.log('[PlayMode] Auto-start timeout triggered');
    setTimeout(async () => {
      try {
        console.log('[PlayMode] Resuming audio context...');
        // Add timeout for audio context resume - iOS may hang here
        const resumePromise = audioCtx.resume ? audioCtx.resume() : Promise.resolve();
        const timeoutPromise = new Promise((_, reject) => 
          setTimeout(() => reject(new Error('AudioContext resume timeout')), 2000)
        );
        
        try {
          await Promise.race([resumePromise, timeoutPromise]);
          console.log('[PlayMode] Audio context resumed');
        } catch (audioErr) {
          console.warn('[PlayMode] Audio context resume failed/timeout:', audioErr.message);
          // Continue anyway - we'll use silent playback
        }
        
        console.log('[PlayMode] Calling start()...');
        
        await start();
        console.log('[PlayMode] start() completed successfully');
        
        // Resize for fullscreen
        resizeRenderer();
        
        // Check if we're on mobile in iframe - if so, wait for user tap
        const isMobileInIframe = requiresMobileAudioGesture({
          isIOS,
          isMobile,
          isEmbedded: isEmbeddedInIframe,
          hasAudio: audioClipRecords(
            timelineTracks,
            { includeHidden: false },
          ).length > 0,
        });
        
        if (isMobileInIframe) {
          // Mobile in iframe: DON'T auto-start playback
          // Wait for forcePlay message from player after user taps
          console.log('[PlayMode] Mobile in iframe - waiting for user tap to start');
          await audioClipPlayback?.prepare(timelineTracks, 0, { timeoutMs: 4000 });
          
          // Notify parent that loading is complete (so it can show the play button)
          try {
            window.parent.postMessage({
              type: EDITOR_EVENTS.LOADING_COMPLETE
            }, playerMessageOrigin);
          } catch (e) {}
        } else {
          // Desktop or standalone: auto-start immediately
          startPlayback();
          
          // Notify parent that loading is complete
          if (window.self !== window.top) {
            try {
              window.parent.postMessage({
                type: EDITOR_EVENTS.LOADING_COMPLETE
              }, playerMessageOrigin);
            } catch (e) {}
          }
        }
      } catch (err) {
        console.error('Play mode auto-start failed:', err);
        // Notify parent of error
        if (window.self !== window.top) {
          try {
            window.parent.postMessage({
              type: EDITOR_EVENTS.LOADING_ERROR,
              message: err.message || 'Failed to load demo'
            }, playerMessageOrigin);
          } catch (e) {}
        }
      }
    }, 100);
    
    // Play mode controls
    document.getElementById('playModePauseBtn')?.addEventListener('click', () => {
      togglePlayPause();
      const btn = document.getElementById('playModePauseBtn');
      const isPlaying = silentPlaying;
      if (isPlaying) {
        btn.textContent = 'Pause';
        document.body.classList.remove('paused');
      } else {
        btn.textContent = 'Play';
        document.body.classList.add('paused');
      }
    });
    
    document.getElementById('playModeRestartBtn')?.addEventListener('click', () => {
      silentTime = 0;
      audioClipPlayback?.sync(timelineTracks, 0, silentPlaying);
      const isPlaying = silentPlaying;
      if (!isPlaying) {
        togglePlayPause();
        document.getElementById('playModePauseBtn').textContent = 'Pause';
        document.body.classList.remove('paused');
      }
    });
    
    // Space to pause in play mode
    document.addEventListener('keydown', (e) => {
      if (e.code === 'Space') {
        e.preventDefault();
        document.getElementById('playModePauseBtn')?.click();
      }
    });
    
    // Listen for postMessage from parent window (player page controls)
    if (window.self !== window.top) {
      window.addEventListener('message', (event) => {
        if (!event.data || typeof event.data !== 'object') return;
        
        if (!isPlayerControlMessage(event, window.parent, playerMessageOrigin)) return;
        const { action } = event.data;
        
        if (action === PLAYER_ACTIONS.TOGGLE_PAUSE) {
          togglePlayPause();
          // Notify parent of new state
          const isPlaying = silentPlaying;
          try {
            window.parent.postMessage({
              type: EDITOR_EVENTS.PLAY_STATE_CHANGED,
              isPlaying: isPlaying
            }, playerMessageOrigin);
          } catch (e) {}
        } else if (action === PLAYER_ACTIONS.RESTART) {
          silentTime = 0;
          audioClipPlayback?.sync(timelineTracks, 0, silentPlaying);
          const isPlaying = silentPlaying;
          if (!isPlaying) {
            togglePlayPause();
          }
          // Notify parent
          try {
            window.parent.postMessage({
              type: EDITOR_EVENTS.PLAY_STATE_CHANGED,
              isPlaying: true
            }, playerMessageOrigin);
          } catch (e) {}
        } else if (action === PLAYER_ACTIONS.UNLOCK_AUDIO) {
          window.unlockStandaloneAudio?.();
          try {
            window.parent.postMessage({ type: EDITOR_EVENTS.AUDIO_PLAYING }, playerMessageOrigin);
          } catch (e) {}
        } else if (action === PLAYER_ACTIONS.FORCE_PLAY) {
          // Force start playback with audio AND visuals
          console.log('[PlayMode] Force play requested');
          
          // Resume audio context first (user gesture)
          if (audioCtx.state === 'suspended') {
            audioCtx.resume().catch(() => {});
          }
          
          // Start playback if not already started
          if (typeof startPlayback === 'function') {
            startPlayback();
          } else {
            silentPlaying = true;
            audioClipPlayback?.sync(timelineTracks, silentTime || 0, true);
            // Start render loop if not running
            if (!running) {
              running = true;
              requestAnimationFrame(render);
            }
          }
        }
      });
    }
  }

  // Load metadata when audio is ready
  audioEl.addEventListener("loadedmetadata", () => {
    if (Number.isFinite(audioEl.duration)) {
      durationGuess = Math.max(durationGuess, timelineContentEnd(timelineTracks, 60));
    }
  });

  // =========================
  // VIDEO EXPORT SYSTEM
  // =========================
  
  // Export state
  let exportCancelled = false;
  let exportInProgress = false;
  let hqExportActive = false;
  let activeFfmpegProgress = null;
  const recentFfmpegLogs = [];
  ffmpegAdapter = createFfmpegAdapter({
    onProgress: progress => activeFfmpegProgress?.(progress),
    onLog: data => {
      const message = data?.message || String(data || '');
      if (!message) return;
      recentFfmpegLogs.push(message);
      if (recentFfmpegLogs.length > 30) recentFfmpegLogs.shift();
      console.debug('[FFmpeg]', message);
    },
  });
  
  // DOM elements for export
  const exportProgressModal = document.getElementById('exportProgressModal');
  const exportCompleteModal = document.getElementById('exportCompleteModal');
  exportController = createExportController({
    document,
    getDuration: () => durationGuess,
    getLoop: () => ({ enabled: loopEnabled, start: loopStart, end: loopEnd }),
    getCanvasSize: () => ({ width: threeCanvas.width, height: threeCanvas.height }),
    hasAudio: () => audioClipRecords(
      timelineTracks,
      { includeHidden: false },
    ).some(({ segment }) => segment.audioFile && !segment.muted),
    updateResolutionLabels: updateExportResolutionLabels,
    onStart: mode => mode === 'quick' ? startQuickExport() : startHQExport(),
    onCancel: () => {
      exportCancelled = true;
      if (hqExportActive) ffmpegAdapter?.terminate();
    },
  });
  
  // =========================
  // QUICK EXPORT (MediaRecorder - WebM)
  // =========================
  
  async function startQuickExport() {
    exportCancelled = false;
    exportInProgress = true;
    
    const fps = parseInt(document.getElementById('exportQuickFps').value);
    const range = document.getElementById('exportRange').value;
    
    // Get current canvas dimensions (we capture at viewport resolution for quick export)
    const width = threeCanvas.width;
    const height = threeCanvas.height;
    
    // Calculate time range
    const { start: startTime, end: endTime, duration } = exportTimeRange(
      range,
      durationGuess,
      { enabled: loopEnabled, start: loopStart, end: loopEnd },
    );
    
    console.log(`[Export] Starting quick export: ${startTime}s to ${endTime}s (${duration}s)`);
    
    // Show progress modal
    document.getElementById('exportProgressTitle').textContent = 'Recording Video...';
    document.getElementById('exportProgressStatus').textContent = 'Playing timeline in real-time...';
    document.getElementById('exportProgressDetail').textContent = `WebM ${width}×${height} @ ${fps}fps (viewport resolution)`;
    document.getElementById('exportProgressBar').style.width = '0%';
    document.getElementById('exportProgressPercent').textContent = '0%';
    document.getElementById('exportProgressTime').textContent = formatTime(duration) + ' remaining';
    exportProgressModal.classList.add('show');
    
    // Setup preview canvas (maintains aspect ratio)
    const previewCanvas = document.getElementById('exportPreviewCanvas');
    if (viewportOrientation === 'vertical') {
      previewCanvas.width = 180;
      previewCanvas.height = 320;
    } else {
      previewCanvas.width = 320;
      previewCanvas.height = 180;
    }
    const previewCtx = previewCanvas.getContext('2d');
    
    // Create a composite canvas that combines WebGL and 2D overlay
    const compositeCanvas = document.createElement('canvas');
    compositeCanvas.width = width;
    compositeCanvas.height = height;
    const compositeCtx = compositeCanvas.getContext('2d');
    
    // Create MediaRecorder from the composite canvas
    const stream = compositeCanvas.captureStream(fps);
    
    // Check if audio should be included
    const includeAudio = document.getElementById('exportIncludeAudio')?.checked ?? true;
    
    // Add audio track if we have audio and it's enabled
    let audioContext = null;
    let audioDestination = null;
    let audioSource = null;
    
    if (
      includeAudio
      && audioClipRecords(timelineTracks, { includeHidden: false }).length > 0
    ) {
      try {
        // Reuse the existing audioCtx and srcNode since createMediaElementSource 
        // can only be called once per audio element
        if (gain && audioCtx) {
          audioDestination = audioCtx.createMediaStreamDestination();
          // Connect from the gain node (after analysis) to capture the audio
          gain.connect(audioDestination);
        
        stream.addTrack(audioDestination.stream.getAudioTracks()[0]);
          console.log('[Export] Audio track added to recording (using existing audio graph)');
        } else {
          console.warn('[Export] Audio mix bus is not initialized');
        }
      } catch (e) {
        console.warn('Could not capture audio:', e);
      }
    } else if (!includeAudio) {
      console.log('[Export] Audio disabled by user');
    }
    
    const chunks = [];
    const mediaRecorder = new MediaRecorder(stream, {
      mimeType: 'video/webm;codecs=vp9',
      videoBitsPerSecond: 8000000 // 8 Mbps
    });
    
    mediaRecorder.ondataavailable = (e) => {
      if (e.data.size > 0) {
        chunks.push(e.data);
        console.log(`[Export] Chunk received: ${e.data.size} bytes`);
      }
    };
    
    mediaRecorder.onstop = () => {
      console.log('[Export] MediaRecorder stopped, processing...');
      exportInProgress = false;
      
      // Disconnect the audio destination (but don't close the shared audioCtx)
      if (audioDestination && gain) {
        try {
          gain.disconnect(audioDestination);
        } catch (e) {
          // May already be disconnected
        }
      }
      
      if (exportCancelled) {
        exportProgressModal.classList.remove('show');
        return;
      }
      
      // Create and download the video
      console.log(`[Export] Creating blob from ${chunks.length} chunks`);
      const blob = new Blob(chunks, { type: 'video/webm' });
      console.log(`[Export] Blob size: ${blob.size} bytes`);
      
      const url = URL.createObjectURL(blob);
      const a = document.createElement('a');
      a.href = url;
      a.download = `${demoNameInput.value || 'export'}_${Date.now()}.webm`;
      document.body.appendChild(a);
      a.click();
      document.body.removeChild(a);
      URL.revokeObjectURL(url);
      
      console.log('[Export] Download triggered');
      
      // Show complete modal
      exportProgressModal.classList.remove('show');
      document.getElementById('exportCompleteInfo').textContent = 
        `${width}×${height} WebM • ${formatTime(duration)}`;
      exportCompleteModal.classList.add('show');
    };
    
    // Stop regular playback first
    const wasPlaying = silentPlaying;
    if (wasPlaying) {
      togglePlayPause();
    }
    
    // Disable looping for export
    const savedLoopEnabled = loopEnabled;
    loopEnabled = false;
    
    // Seek to start position
    silentTime = startTime;
    audioClipPlayback?.sync(timelineTracks, startTime, false);
    
    // Wait a moment for seek to complete
    await new Promise(r => setTimeout(r, 100));
    
    // Start recording
    mediaRecorder.start(100);
    console.log('[Export] MediaRecorder started');
    
    // Start playback (use silentPlaying for consistent timing)
    silentPlaying = true;
    audioClipPlayback?.sync(timelineTracks, startTime, includeAudio);
    
    // Track elapsed time ourselves for reliable end detection
    const exportStartTime = performance.now();
    let exportFinished = false;
    
    function captureFrame() {
      if (exportCancelled || exportFinished) {
        return;
      }

      drawCompositionFrame(compositeCtx, threeCanvas, imageOverlayCanvas, width, height);
    }
    
    const updateInterval = setInterval(() => {
      if (exportCancelled) {
        clearInterval(updateInterval);
        exportInProgress = false;
        loopEnabled = savedLoopEnabled;
        mediaRecorder.stop();
        silentPlaying = false;
        audioClipPlayback?.pauseAll();
        return;
      }
      
      if (exportFinished) {
        return;
      }
      
      // Capture current frame to composite canvas
      captureFrame();
      
      // Use wall-clock time for progress instead of timeline time
      // This is more reliable because timeline time might be affected by various factors
      const elapsedMs = performance.now() - exportStartTime;
      const elapsedSec = elapsedMs / 1000;
      const progress = Math.min(1, elapsedSec / duration);
      const remaining = Math.max(0, duration - elapsedSec);
      
      document.getElementById('exportProgressBar').style.width = `${progress * 100}%`;
      document.getElementById('exportProgressPercent').textContent = `${Math.round(progress * 100)}%`;
      document.getElementById('exportProgressTime').textContent = formatTime(remaining) + ' remaining';
      
      // Update preview
      previewCtx.drawImage(compositeCanvas, 0, 0, previewCanvas.width, previewCanvas.height);
      
      // Check if we've reached the end based on wall-clock time
      if (elapsedSec >= duration) {
        console.log(`[Export] End reached at ${elapsedSec}s`);
        exportFinished = true;
        clearInterval(updateInterval);
        
        // One final frame capture
        captureFrame();
        
        // Restore loop setting
        loopEnabled = savedLoopEnabled;
        
        // Small delay to ensure last frame is captured
        setTimeout(() => {
          console.log('[Export] Stopping MediaRecorder...');
          // Stop recording and playback
          silentPlaying = false;
          audioClipPlayback?.pauseAll();
          mediaRecorder.stop();
        }, 200);
      }
    }, 1000 / fps); // Capture at the target fps
  }
  
  // =========================
  // HIGH QUALITY EXPORT (FFmpeg.wasm - MP4)
  // =========================

  async function loadFFmpeg() {
    document.getElementById('exportProgressStatus').textContent = 'Loading FFmpeg (first time only)...';
    document.getElementById('exportProgressDetail').textContent = 'Downloading ~25MB...';

    try {
      return await ffmpegAdapter.load();
    } catch (e) {
      console.error('Failed to load FFmpeg:', e);
      throw new Error('Failed to load FFmpeg. Please try again.');
    }
  }
  
  async function startHQExport() {
    exportCancelled = false;
    exportInProgress = true;
    hqExportActive = true;
    recentFfmpegLogs.length = 0;
    const preExportAudioData = window.currentAudioData;
    
    const resolution = document.getElementById('exportHQResolution').value;
    const fps = parseInt(document.getElementById('exportHQFps').value);
    const quality = document.getElementById('exportHQQuality').value;
    const range = document.getElementById('exportRange').value;
    
    const { width, height } = exportResolution(resolution, viewportOrientation);
    
    const bitrate = exportBitrate(quality);
    const { start: startTime, end: endTime, duration } = exportTimeRange(
      range,
      durationGuess,
      { enabled: loopEnabled, start: loopStart, end: loopEnd },
    );
    const totalFrames = exportFrameCount(duration, fps);
    const framesPerChunk = exportChunkFrameCount(width, height);
    const totalChunks = Math.ceil(totalFrames / framesPerChunk);
    
    // Show progress modal
    document.getElementById('exportProgressTitle').textContent = 'Rendering Video...';
    document.getElementById('exportProgressStatus').textContent = 'Preparing renderer...';
    document.getElementById('exportProgressDetail').textContent =
      `MP4 ${width}×${height} @ ${fps}fps • ${totalFrames} frames • ${totalChunks} chunks`;
    document.getElementById('exportProgressBar').style.width = '0%';
    document.getElementById('exportProgressPercent').textContent = '0%';
    document.getElementById('exportProgressTime').textContent = 'Calculating...';
    exportProgressModal.classList.add('show');
    
    // Stop regular playback
    const wasPlaying = silentPlaying;
    if (wasPlaying) {
      togglePlayPause();
    }

    let restoreCompositionSurface = null;
    let failed = false;
    try {
      // Load FFmpeg
      const ffmpeg = await loadFFmpeg();
      
      if (exportCancelled) {
        exportProgressModal.classList.remove('show');
        return;
      }
      
      document.getElementById('exportProgressStatus').textContent = 'Setting up renderer...';

      const mediaSummary = await preloadTimelineMedia(
        timelineTracks,
        loadMedia,
        { timeoutMs: 60000 },
      );
      if (mediaSummary.failed.length) {
        const failedFiles = mediaSummary.failed
          .slice(0, 3)
          .map(({ filename }) => filename)
          .join(', ');
        throw new Error(
          `Could not preload ${mediaSummary.failed.length} timeline media file(s): ${failedFiles}`,
        );
      }

      // HQ frames use the exact compositor used by live playback and
      // screenshots. The shared surface is temporarily resized to the export
      // dimensions and restored as soon as frame rendering is complete.
      restoreCompositionSurface = await beginOfflineComposition(width, height);
      
      // Setup preview canvas (maintains aspect ratio)
      const previewCanvas = document.getElementById('exportPreviewCanvas');
      if (viewportOrientation === 'vertical') {
        previewCanvas.width = 180;
        previewCanvas.height = 320;
      } else {
        previewCanvas.width = 320;
        previewCanvas.height = 180;
      }
      const previewCtx = previewCanvas.getContext('2d');
      
      // Final composite canvas
      const compositeCanvas = document.createElement('canvas');
      compositeCanvas.width = width;
      compositeCanvas.height = height;
      const compositeCtx = compositeCanvas.getContext('2d');
      const includeAudio = document.getElementById('exportIncludeAudio')?.checked ?? true;
      const audioPlan = buildAudioMixPlan({
        tracks: timelineTracks,
        rangeStart: startTime,
        rangeEnd: endTime,
        keyframeData,
        curveTypes,
        lfoData,
        bpm: parseFloat(bpmInput.value) || 129.2,
      });
      const startedAt = performance.now();
      const setOverallProgress = (percent, status, detail) => {
        const normalized = clamp(percent, 0, 100);
        document.getElementById('exportProgressBar').style.width = `${normalized}%`;
        document.getElementById('exportProgressPercent').textContent = `${Math.round(normalized)}%`;
        if (status) document.getElementById('exportProgressStatus').textContent = status;
        if (detail) document.getElementById('exportProgressDetail').textContent = detail;
        const elapsed = (performance.now() - startedAt) / 1000;
        const fraction = normalized / 100;
        const remaining = fraction > 0.01
          ? Math.max(0, elapsed / fraction - elapsed)
          : NaN;
        document.getElementById('exportProgressTime').textContent = Number.isFinite(remaining)
          ? `${formatTime(remaining)} remaining`
          : 'Calculating...';
      };

      let exportAudioFrames = null;
      if (audioPlan.clips.length) {
        setOverallProgress(
          1,
          'Analyzing timeline audio...',
          `${audioPlan.clips.length} audio clip${audioPlan.clips.length === 1 ? '' : 's'}`,
        );
        exportAudioFrames = await analyzeAudioMixForExport({
          plan: audioPlan,
          fps,
          totalFrames,
          decodeAudioData: arrayBuffer => audioCtx.decodeAudioData(arrayBuffer.slice(0)),
          onProgress: event => {
            if (event.stage === 'decode') {
              setOverallProgress(
                1,
                'Analyzing timeline audio...',
                `Decoding source ${event.index + 1}/${event.total}`,
              );
            } else if (event.stage === 'analyze') {
              setOverallProgress(
                1 + (event.frame / Math.max(1, event.total)) * 2,
                'Analyzing timeline audio...',
                `Spectrum frame ${event.frame}/${event.total}`,
              );
            }
          },
        });
      }

      let activeChunkIndex = 0;
      activeFfmpegProgress = progress => {
        const chunkFraction = 0.62 + clamp(progress, 0, 1) * 0.38;
        const overall = (activeChunkIndex + chunkFraction) / Math.max(1, totalChunks);
        setOverallProgress(
          3 + overall * 90,
          `Encoding chunk ${activeChunkIndex + 1}/${totalChunks}...`,
        );
      };

      const outputData = await encodeVideoInChunks({
        ffmpeg,
        totalFrames,
        framesPerChunk,
        fps,
        bitrate,
        duration,
        isCancelled: () => exportCancelled,
        renderFrame: async (frameNum, chunk) => {
          const t = startTime + frameNum / fps;
          silentTime = t;
          window.currentAudioData = audioReactiveFrameAt(exportAudioFrames, frameNum);
          await prepareActiveVideoFramesForExport(t);
          renderCompositionAt(t, null, { source: 'export' });
          drawCompositionFrame(compositeCtx, threeCanvas, imageOverlayCanvas, width, height);
          if (frameNum % 10 === 0) {
            previewCtx.drawImage(compositeCanvas, 0, 0, previewCanvas.width, previewCanvas.height);
          }
          const frameBlob = await new Promise(resolve => {
            compositeCanvas.toBlob(resolve, 'image/jpeg', 0.95);
          });
          if (!frameBlob) throw new Error(`Browser could not capture frame ${frameNum + 1}`);
          if (frameNum % 5 === 0) await new Promise(resolve => setTimeout(resolve, 0));
          return new Uint8Array(await frameBlob.arrayBuffer());
        },
        createAudioData: includeAudio && audioPlan.clips.length
          ? async () => renderAudioMixToWav({
            plan: audioPlan,
            decodeAudioData: arrayBuffer => audioCtx.decodeAudioData(arrayBuffer.slice(0)),
            onProgress: event => {
              const detail = event.stage === 'decode'
                ? `Decoding audio source ${event.index + 1}/${event.total}`
                : `${audioPlan.clips.length} audio clip${audioPlan.clips.length === 1 ? '' : 's'}`;
              setOverallProgress(96, 'Mixing timeline audio...', detail);
            },
          })
          : null,
        onProgress: event => {
          if (event.stage === 'render') {
            activeChunkIndex = event.chunkIndex;
            const chunkFraction = (event.localIndex / event.localCount) * 0.62;
            const overall = (event.chunkIndex + chunkFraction) / event.chunkCount;
            setOverallProgress(
              3 + overall * 90,
              `Rendering chunk ${event.chunkIndex + 1}/${event.chunkCount}...`,
              `Frame ${event.frameIndex + 1}/${event.totalFrames}`,
            );
          } else if (event.stage === 'encode-chunk') {
            activeChunkIndex = event.chunkIndex;
            setOverallProgress(
              3 + ((event.chunkIndex + 0.62) / event.chunkCount) * 90,
              `Encoding chunk ${event.chunkIndex + 1}/${event.chunkCount}...`,
              `Keeping browser memory bounded to ${event.frameCount} frames`,
            );
          } else if (event.stage === 'concatenate') {
            activeFfmpegProgress = progress => setOverallProgress(
              94 + clamp(progress, 0, 1) * 1.5,
              'Joining video chunks...',
            );
            setOverallProgress(94, 'Joining video chunks...', `${event.chunkCount} chunks`);
          } else if (event.stage === 'mix-audio') {
            activeFfmpegProgress = null;
            setOverallProgress(96, 'Mixing timeline audio...', `${audioPlan?.clips.length || 0} clips`);
          } else if (event.stage === 'mux-audio') {
            activeFfmpegProgress = progress => setOverallProgress(
              97 + clamp(progress, 0, 1) * 2,
              'Adding mixed audio...',
            );
            setOverallProgress(97, 'Adding mixed audio...', 'AAC 192 kbps');
          } else if (event.stage === 'read-output') {
            activeFfmpegProgress = null;
            setOverallProgress(99, 'Finalizing download...', 'Reading MP4 output');
          }
        },
      });

      restoreCompositionSurface();
      restoreCompositionSurface = null;
      
      // Download the video
      const blob = new Blob([outputData], { type: 'video/mp4' });
      const url = URL.createObjectURL(blob);
      const a = document.createElement('a');
      a.href = url;
      a.download = `${demoNameInput.value || 'export'}_${Date.now()}.mp4`;
      document.body.appendChild(a);
      a.click();
      a.remove();
      setTimeout(() => URL.revokeObjectURL(url), 1000);
      
      // Show complete modal
      exportInProgress = false;
      exportProgressModal.classList.remove('show');
      const fileSizeMB = (outputData.length / (1024 * 1024)).toFixed(1);
      document.getElementById('exportCompleteInfo').textContent = 
        `${width}×${height} MP4 • ${formatTime(duration)} • ${fileSizeMB}MB`;
      exportCompleteModal.classList.add('show');
      
    } catch (error) {
      console.error('Export failed:', error);
      failed = true;
      exportInProgress = false;
      exportProgressModal.classList.remove('show');
      if (!(error instanceof ExportCancelledError) && !exportCancelled) {
        let message = exportErrorMessage(error);
        const finalLog = recentFfmpegLogs.at(-1);
        if (finalLog && !message.includes(finalLog)) {
          message += `\n\nFFmpeg: ${finalLog}`;
        }
        alert(`Export failed: ${message}`);
      }
    } finally {
      activeFfmpegProgress = null;
      window.currentAudioData = preExportAudioData;
      restoreCompositionSurface?.();
      hqExportActive = false;
      exportInProgress = false;
      if (failed || exportCancelled) ffmpegAdapter?.terminate();
    }
  }
  
