/* global React, ReactDOM */
const { useState, useEffect, useRef, useMemo } = React;

/* ---------- Icons ---------- */
const Icon = {
  back: () => <svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M10 12 L6 8 L10 4"/></svg>,
  copy: () => <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><rect x="5" y="5" width="8" height="8" rx="1.5"/><path d="M3 11V4.5A1.5 1.5 0 0 1 4.5 3H11"/></svg>,
  history: () => <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><circle cx="8" cy="8" r="5.5"/><path d="M8 5v3l2 1.5"/><path d="M2.5 4.5V2.5"/></svg>,
  share: () => <svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><circle cx="4" cy="8" r="1.6"/><circle cx="12" cy="3.5" r="1.6"/><circle cx="12" cy="12.5" r="1.6"/><path d="M5.4 7.2 10.6 4.3M5.4 8.8 10.6 11.7"/></svg>,
  search: () => <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><circle cx="7" cy="7" r="4.5"/><path d="m10.5 10.5 3 3" strokeLinecap="round"/></svg>,
  sparkle: () => <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round"><path d="M8 2.5 9 6l3.5 1L9 8l-1 3.5L7 8 3.5 7 7 6z"/><path d="m12 11.5.5 1.5.5-1.5 1.5-.5-1.5-.5L13 9l-.5 1.5L11 11z"/></svg>,
  filter: () => <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"><path d="M2.5 4h11M5 8h6M7 12h2"/></svg>,
  image: () => <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><rect x="2.5" y="3.5" width="11" height="9" rx="1.5"/><circle cx="6" cy="7" r="1"/><path d="m3 12 3.5-3 2 2 2.5-2.5 2 2"/></svg>,
  play: () => <svg width="10" height="10" viewBox="0 0 10 10" fill="currentColor"><path d="M3 1.5v7l5.5-3.5z"/></svg>,
  pause: () => <svg width="10" height="10" viewBox="0 0 10 10" fill="currentColor"><rect x="2.5" y="2" width="2" height="6"/><rect x="5.5" y="2" width="2" height="6"/></svg>,
  replay: () => <svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round"><path d="M9 5.5a3.5 3.5 0 1 1-1.03-2.47"/><path d="M9 1.5v2.5h-2.5"/></svg>,
  expandDown: () => <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M8 3v10"/><path d="m4.5 9.5 3.5 3.5 3.5-3.5"/></svg>,
  collapseUp: () => <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M8 13V3"/><path d="m4.5 6.5 3.5-3.5 3.5 3.5"/></svg>,
  mic: () => <svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="6" y="2" width="4" height="8" rx="2"/><path d="M3.5 8a4.5 4.5 0 0 0 9 0M8 12.5V14"/></svg>,
  stop: () => <svg width="11" height="11" viewBox="0 0 11 11" fill="currentColor"><rect x="2" y="2" width="7" height="7" rx="1"/></svg>,
  check: () => <svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="m3 6 2.5 2.5L9.5 4"/></svg>,
  chevron: () => <svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="m3 4.5 3 3 3-3"/></svg>,
  plus: () => <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"><path d="M8 3v10M3 8h10"/></svg>,
  wand: () => <svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="m4 12 7-7"/><path d="M10 4l1 1M3 11l1 1M13 2l.5 1.5L15 4l-1.5.5L13 6l-.5-1.5L11 4l1.5-.5z"/></svg>,
  upload: () => <svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M8 10V3m0 0L5.5 5.5M8 3l2.5 2.5"/><path d="M3 11v1.5A1.5 1.5 0 0 0 4.5 14h7a1.5 1.5 0 0 0 1.5-1.5V11"/></svg>,
  expand: () => <svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6V3h3M13 6V3h-3M3 10v3h3M13 10v3h-3"/></svg>,
  mic: () => <svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="6" y="2" width="4" height="8" rx="2"/><path d="M4 9a4 4 0 0 0 8 0M8 13v2"/></svg>,
  download: () => <svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M8 3v7m0 0 2.5-2.5M8 10 5.5 7.5"/><path d="M3 12v.5A1.5 1.5 0 0 0 4.5 14h7a1.5 1.5 0 0 0 1.5-1.5V12"/></svg>,
};

/* ---------- Data ---------- */
const AVATARS = [
  { id: 'a01', name: 'Mira',    role: 'F · 28 · EN-US',   tone: 'oklch(0.62 0.07 30)'  },
  { id: 'a02', name: 'Soraya',  role: 'F · 31 · EN-UK',   tone: 'oklch(0.4  0.06 280)' },
  { id: 'a03', name: 'Idris',   role: 'M · 47 · EN-IN',   tone: 'oklch(0.45 0.05 160)' },
  { id: 'a04', name: 'Naomi',   role: 'F · 39 · EN-US',   tone: 'oklch(0.35 0.04 30)'  },
  { id: 'a05', name: 'Yuki',    role: 'F · 26 · JA-JP',   tone: 'oklch(0.58 0.04 90)'  },
  { id: 'a06', name: 'Calder',  role: 'M · 34 · EN-AU',   tone: 'oklch(0.5  0.07 220)' },
  { id: 'a07', name: 'Elin',    role: 'F · 29 · SV-SE',   tone: 'oklch(0.65 0.08 60)'  },
  { id: 'a08', name: 'Mateo',   role: 'M · 33 · ES-MX',   tone: 'oklch(0.5  0.08 130)' },
  { id: 'a09', name: 'Ines',    role: 'F · 52 · PT-BR',   tone: 'oklch(0.45 0.06 350)' },
];

const VOICES = [
  { id: 'v01', name: 'Mira — Default',   tag: 'EN-US',  desc: 'Warm · Conversational', dur: '0:24', barCount: 32 },
  { id: 'v02', name: 'Studio Narrator',  tag: 'EN-US',  desc: 'Cinematic · Low',       dur: '0:31', barCount: 32 },
  { id: 'v03', name: 'Clear Brief',      tag: 'EN-UK',  desc: 'Crisp · Neutral',       dur: '0:18', barCount: 32 },
  { id: 'v04', name: 'Documentary',      tag: 'EN-IN',  desc: 'Calm · Measured',       dur: '0:27', barCount: 32 },
  { id: 'v05', name: 'Mira — Whispered', tag: 'EN-US',  desc: 'Soft · Intimate',       dur: '0:22', barCount: 32 },
  { id: 'v06', name: 'Live Anchor',      tag: 'EN-AU',  desc: 'Bright · Direct',       dur: '0:29', barCount: 32 },
];

const PRESETS = [
  { title: 'Product launch',   desc: 'Today we are introducing something we have been working on…' },
  { title: 'Welcome message',  desc: 'Hi — and thanks for stopping by. Here is what to expect…' },
  { title: 'Quarterly update', desc: 'A few quick numbers from the last 90 days, and what comes next.' },
  { title: 'Course intro',     desc: 'In this lesson we will cover three ideas. The first one is…' },
];

const SAMPLE_SCRIPT = `Hi, I'm Mira — your avatar. Type a script, pick a face, and I'll deliver it.`;

/* ---------- App ---------- */
function App() {
  const [tweaks, setTweak] = useTweaks(/*EDITMODE-BEGIN*/{
    "accent": "#c0664a",
    "model": "BosonAvatar-01",
    "showCaption": true
  }/*EDITMODE-END*/);

  // Upload (custom avatar photo + voice clip) is the "VIP" feature. The
  // standalone all-in-one UI (served from avatar_server at /) is unlocked;
  // the same bundle baked into the boson homepage (served at /workspace/*)
  // is locked. Single source file — runtime path detect, no per-copy diff.
  const UPLOAD_LOCKED = typeof window !== 'undefined'
    && window.location.pathname.startsWith('/workspace');

  const [tab, setTab]               = useState('avatar');
  const [avatarId, setAvatarId]     = useState('a01');
  const [voiceId, setVoiceId]       = useState('v01');
  // Stacks of uploaded files — each upload appends a new entry instead of
  // replacing the previous one. Selection is via avatarId/voiceId =
  // `upload:<id>`. New (the button) clears selection but keeps these lists.
  const [customAvatars, setCustomAvatars] = useState([]);  // [{id, file}]
  const [customVoices,  setCustomVoices]  = useState([]);  // [{id, file}]
  const addCustomAvatar = (file) => {
    const id = `u${Date.now().toString(36)}${Math.floor(Math.random()*1e4)}`;
    setCustomAvatars(prev => [...prev, { id, file }]);
    setAvatarId(`upload:${id}`);
  };
  const removeCustomAvatar = (id) => {
    setCustomAvatars(prev => prev.filter(u => u.id !== id));
    if (avatarId === `upload:${id}`) setAvatarId('a01');
  };
  const addCustomVoice = async (file) => {
    const id = `u${Date.now().toString(36)}${Math.floor(Math.random()*1e4)}`;
    // Insert with transcript pending; UI shows "transcribing…" until Gemini
    // returns the text. Higgs TTS clones the voice noticeably better with
    // the reference transcript than without it.
    setCustomVoices(prev => [...prev, { id, file, transcript: '', transcribing: true }]);
    setVoiceId(`upload:${id}`);
    try {
      const fd = new FormData();
      fd.append('audio', file, file.name);
      const r = await fetch('/api/transcribe', { method: 'POST', body: fd });
      if (!r.ok) throw new Error(`transcribe ${r.status}`);
      const { transcript } = await r.json();
      setCustomVoices(prev => prev.map(u =>
        u.id === id ? { ...u, transcript: transcript || '', transcribing: false } : u
      ));
    } catch (err) {
      console.warn('[transcribe failed]', err);
      setCustomVoices(prev => prev.map(u =>
        u.id === id ? { ...u, transcribing: false } : u
      ));
    }
  };
  const removeCustomVoice = (id) => {
    setCustomVoices(prev => prev.filter(u => u.id !== id));
    if (voiceId === `upload:${id}`) setVoiceId('v01');
  };
  const [playingVoice, setPlayingVoice] = useState(null);
  const [script, setScript]         = useState(SAMPLE_SCRIPT);
  const [language, setLanguage]     = useState('English (US)');

  const [isPlaying, setIsPlaying]   = useState(false);
  const [progress, setProgress]     = useState(0);      // 0..1
  const [generating, setGenerating] = useState(false);
  const [renderedTake, setRenderedTake] = useState(0);

  const [videoUrl, setVideoUrl]     = useState(null);  // final video (direct src)
  const [hlsUrl, setHlsUrl]         = useState(null);  // live m3u8 stream
  const [audioUrl, setAudioUrl]     = useState(null);
  const [audioSec, setAudioSec]     = useState(0);     // real TTS audio length once known
  const [audioPreviewReady, setAudioPreviewReady] = useState(false);  // streaming TTS chunk arrived
  const audioCtxRef = useRef(null);    // shared AudioContext (user-gesture initialised)
  const audioCursorRef = useRef(0);    // next playback start time in AudioContext clock
  const audioPcmQueueRef = useRef([]); // [{floats: Float32Array, sample_rate}] — buffered chunks not yet scheduled

  // Decode a base64-PCM-int16 chunk into a Float32Array in [-1, 1].
  // Pure function so it can run before AudioContext exists (we buffer the
  // decoded floats and schedule them once the user taps preview).
  const decodePcmChunk = (b64) => {
    const raw = atob(b64);
    const bytes = new Uint8Array(raw.length);
    for (let i = 0; i < raw.length; i++) bytes[i] = raw.charCodeAt(i);
    const i16 = new Int16Array(bytes.buffer, bytes.byteOffset, bytes.byteLength >> 1);
    const f32 = new Float32Array(i16.length);
    for (let i = 0; i < i16.length; i++) f32[i] = i16[i] / 32768;
    return f32;
  };

  // Schedule one Float32 chunk on the AudioContext, starting at the
  // current write cursor. Idempotent w.r.t. AudioContext creation.
  const scheduleAudioChunk = (floats, sampleRate) => {
    const ctx = audioCtxRef.current;
    if (!ctx) return;
    const buf = ctx.createBuffer(1, floats.length, sampleRate);
    buf.copyToChannel(floats, 0);
    const src = ctx.createBufferSource();
    src.buffer = buf;
    src.connect(ctx.destination);
    const startAt = Math.max(audioCursorRef.current, ctx.currentTime);
    src.start(startAt);
    audioCursorRef.current = startAt + buf.duration;
  };

  const startAudioPreview = () => {
    if (audioCtxRef.current) return;
    const Ctx = window.AudioContext || window.webkitAudioContext;
    if (!Ctx) return;
    const ctx = new Ctx();
    audioCtxRef.current = ctx;
    audioCursorRef.current = ctx.currentTime + 0.05;
    // Drain anything we buffered before the user pressed preview.
    for (const { floats, sample_rate } of audioPcmQueueRef.current) {
      scheduleAudioChunk(floats, sample_rate);
    }
    audioPcmQueueRef.current = [];
  };
  const [previewExpanded, setPreviewExpanded] = useState(false);  // mobile fullscreen toggle
  const [renderPct, setRenderPct]   = useState(0);
  const [statusMsg, setStatusMsg]   = useState('');
  const [genError, setGenError]     = useState(null);
  const [finalVideoUrl, setFinalVideoUrl] = useState(null);
  const [generatedSecs, setGeneratedSecs] = useState(0); // cumulative rendered seconds
  const pollRef      = useRef(null);
  const pollIdx      = useRef(0);
  const runIdRef     = useRef(null);
  const finalUrlRef  = useRef(null);
  const hlsUrlRef    = useRef(null);   // ref mirror so processEvent closure sees current value

  const avatar = AVATARS.find(a => a.id === avatarId);
  const voice  = VOICES.find(v => v.id === voiceId);

  // Fake playback sim — drives the bar ONLY when there is no media element
  // attached at all (the "Ready" demo state). When HLS or a final video is
  // playing, the bar is driven by the video element's onTimeUpdate so it
  // freezes correctly when the player buffers. Including hlsUrl in the early
  // return prevents this fake timer from racing the real one during stalls.
  useEffect(() => {
    if (!isPlaying || videoUrl || hlsUrl) return;
    const id = setInterval(() => {
      setProgress(p => {
        const next = p + 0.0035;
        if (next >= 1) { setIsPlaying(false); return 0; }
        return next;
      });
    }, 60);
    return () => clearInterval(id);
  }, [isPlaying, videoUrl, hlsUrl]);

  useEffect(() => {
    const onKey = e => {
      if (e.code === 'Space' && e.target.tagName !== 'TEXTAREA' && e.target.tagName !== 'INPUT') {
        e.preventDefault();
        setIsPlaying(p => !p);
      }
    };
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, []);

  const setHlsUrlBoth = (url) => { hlsUrlRef.current = url; setHlsUrl(url); };

  const processEvent = (ev) => {
    if (ev.type === 'status') {
      setStatusMsg(ev.message || '');
      if (ev.pct != null) setRenderPct(ev.pct);
    } else if (ev.type === 'audio_chunk') {
      // Phase 1: surface TTFA in the status the moment the first chunk lands.
      if (ev.index === 0 && ev.t_arrived_s != null) {
        setStatusMsg(`🔊 Voice generating · TTFA ${ev.t_arrived_s.toFixed(1)}s`);
        setAudioPreviewReady(true);
      }
      // Phase 1.5: decode the PCM payload + either schedule it for
      // playback (if the user already tapped preview) or buffer it until
      // they do. WebAudio playback can't start without a user gesture, so
      // we hold the decoded floats until startAudioPreview() runs.
      if (ev.pcm_b64) {
        const floats = decodePcmChunk(ev.pcm_b64);
        const sr = ev.sample_rate || 24000;
        if (audioCtxRef.current) {
          scheduleAudioChunk(floats, sr);
        } else {
          audioPcmQueueRef.current.push({ floats, sample_rate: sr });
        }
      }
    } else if (ev.type === 'audio_ready') {
      setAudioUrl(ev.audio_url);
      // Only trust positive durations — guard against a 0/missing field
      // overwriting a later, more accurate value.
      if (ev.duration && ev.duration > 0) setAudioSec(ev.duration);
      if (ev.pct != null) setRenderPct(ev.pct);
    } else if (ev.type === 'hls_ready') {
      setHlsUrlBoth(ev.hls_url);
      setStatusMsg('⏳ Generating video…');
      if (ev.generated_secs != null) setGeneratedSecs(ev.generated_secs);
      if (ev.pct != null) setRenderPct(ev.pct);
    } else if (ev.type === 'hls_chunk') {
      if (ev.generated_secs != null) setGeneratedSecs(ev.generated_secs);
      if (ev.pct != null) setRenderPct(ev.pct);
    } else if (ev.type === 'chunk') {
      // ffmpeg fallback: ignore for playback, just track progress
      if (ev.pct != null) setRenderPct(ev.pct);
      setStatusMsg('⏳ Generating video…');
    } else if (ev.type === 'done') {
      // video_url may be null when HLS has audio baked in (sentence-chunked mode)
      finalUrlRef.current = ev.video_url || null;
      setFinalVideoUrl(ev.video_url || null);
      setRenderPct(100);
      setGenerating(false);
      setRenderedTake(t => t + 1);
      // Snap audioSec to the actually-served duration so the buffer bar reaches
      // 100% (video may be slightly shorter than TTS audio when ffmpeg -t
      // truncates the tail chunk).
      setGeneratedSecs(prev => { setAudioSec(s => prev > 0 ? prev : s); return prev; });
      if (!hlsUrlRef.current) {
        if (ev.video_url) {
          setVideoUrl(ev.video_url);
          setProgress(0);
          setIsPlaying(true);
        }
      }
      // HLS active: stream naturally plays to ENDLIST; onEnded handles loop
    } else if (ev.type === 'error') {
      setGenError(ev.message || 'Generation failed');
      setGenerating(false);
    }
  };

  // Called by RightPanel video onEnded.
  const handleVideoEnded = () => {
    // Stop on the last frame; the play button has already flipped to
    // Replay (via setEnded(true) in the video element's onEnded). Clicking
    // it seeks to 0 and restarts.
    //
    // Previously we auto-switched from the HLS stream to the merged MP4
    // (`finalUrlRef.current`) at end-of-stream and called play() again,
    // which made the first user-initiated play look like "it played twice".
    // The merged MP4 is still available via the Export download link.
  };

  const stopPolling = () => {
    if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; }
  };

  const startPolling = (run_id) => {
    stopPolling();
    pollIdx.current = 0;
    const tick = async () => {
      try {
        const r = await fetch(`/api/generate/${run_id}/poll?since=${pollIdx.current}`);
        if (!r.ok) { stopPolling(); setGenError(`Poll error ${r.status}`); setGenerating(false); return; }
        const { events, done, next } = await r.json();
        pollIdx.current = next;
        events.forEach(processEvent);
        if (done) stopPolling();
      } catch (err) {
        // network hiccup — keep polling
      }
    };
    tick(); // fire immediately
    pollRef.current = setInterval(tick, 800);
  };

  // Authoritative audio duration: load the TTS WAV in a hidden Audio element
  // and use its real file duration. Catches cases where the backend event
  // carried a wrong/missing duration.
  useEffect(() => {
    if (!audioUrl) return;
    const a = new Audio();
    a.preload = 'metadata';
    const onMeta = () => {
      if (Number.isFinite(a.duration) && a.duration > 0) setAudioSec(a.duration);
    };
    a.addEventListener('loadedmetadata', onMeta);
    a.src = audioUrl;
    return () => { a.removeEventListener('loadedmetadata', onMeta); a.src = ''; };
  }, [audioUrl]);

  const handleGenerate = async () => {
    if (generating) return;
    stopPolling();
    finalUrlRef.current = null;
    hlsUrlRef.current = null;
    setGenerating(true);
    setHlsUrl(null);
    setVideoUrl(null);
    setGeneratedSecs(0);
    setFinalVideoUrl(null);
    setAudioUrl(null);
    setAudioSec(0);
    setRenderPct(0);
    setStatusMsg('Starting…');
    setGenError(null);
    // Reset the streaming-TTS audio preview state — drop any leftover
    // buffered PCM and stop the current AudioContext so the next run
    // starts with a clean playback timeline.
    setAudioPreviewReady(false);
    audioPcmQueueRef.current = [];
    if (audioCtxRef.current) {
      try { audioCtxRef.current.close(); } catch {}
      audioCtxRef.current = null;
    }
    audioCursorRef.current = 0;
    setProgress(0);
    setIsPlaying(false);

    try {
      const fd = new FormData();
      fd.append('script', script);
      // 'upload:<id>' selects a file from the customAvatars/customVoices list.
      // The avatar_id form field stays a valid preset id for backend resolve.
      const isUploadAvatar = avatarId.startsWith('upload:');
      const isUploadVoice  = voiceId.startsWith('upload:');
      fd.append('avatar_id', isUploadAvatar ? 'a01' : avatarId);
      fd.append('voice_id',  isUploadVoice  ? 'v01' : voiceId);
      if (isUploadAvatar) {
        const u = customAvatars.find(x => `upload:${x.id}` === avatarId);
        if (u) fd.append('ref_image', u.file, u.file.name);
      }
      if (isUploadVoice) {
        const u = customVoices.find(x => `upload:${x.id}` === voiceId);
        if (u) {
          fd.append('ref_audio', u.file, u.file.name);
          if (u.transcript) fd.append('ref_transcript', u.transcript);
        }
      }
      // Locked / boson-fork tier renders at half resolution to keep TTFV low.
      if (UPLOAD_LOCKED) fd.append('low_res', 'true');
      const res = await fetch('/api/generate', { method: 'POST', body: fd });
      if (!res.ok) throw new Error(`HTTP ${res.status}`);
      const { run_id } = await res.json();
      runIdRef.current = run_id;
      startPolling(run_id);
    } catch (err) {
      setGenError(err.message);
      setGenerating(false);
    }
  };

  const handleStop = async () => {
    stopPolling();
    setGenerating(false);
    const rid = runIdRef.current;
    if (rid) {
      runIdRef.current = null;
      try { await fetch(`/api/generate/${rid}/stop`, { method: 'POST' }); } catch {}
    }
  };

  const handleNew = async () => {
    await handleStop();
    finalUrlRef.current = null;
    setHlsUrlBoth(null);
    setVideoUrl(null);
    setFinalVideoUrl(null);
    setAudioUrl(null);
    setRenderPct(0);
    setStatusMsg('');
    setGenError(null);
    setProgress(0);
    setIsPlaying(false);
    setRenderedTake(0);
    setGeneratedSecs(0);
    // Reset user selections to defaults, but KEEP customAvatar / customVoice
    // around so the uploaded files stay in their tabs as selectable cards.
    setAvatarId('a01');
    setVoiceId('v01');
    setScript(SAMPLE_SCRIPT);
  };

  // sync accent color from tweaks
  useEffect(() => {
    document.documentElement.style.setProperty('--accent', tweaks.accent);
  }, [tweaks.accent]);

  const previewLine = useMemo(() => {
    // pull a short snippet from script for caption
    const s = (script || '').split(/[.\n]/).map(x => x.trim()).filter(Boolean);
    return s[Math.min(1, s.length - 1)] || s[0] || '';
  }, [script]);

  return (
    <div className={`app ${previewExpanded ? 'preview-expanded' : ''}`} data-screen-label="01 Studio">
      <TopBar videoUrl={finalVideoUrl || hlsUrl} generating={generating} />

      <div className="main">
        <LeftPanel
          tab={tab} setTab={setTab}
          avatarId={avatarId} setAvatarId={setAvatarId}
          voiceId={voiceId} setVoiceId={setVoiceId}
          customAvatars={customAvatars} addCustomAvatar={addCustomAvatar} removeCustomAvatar={removeCustomAvatar}
          customVoices={customVoices}  addCustomVoice={addCustomVoice}   removeCustomVoice={removeCustomVoice}
          uploadLocked={UPLOAD_LOCKED}
          playingVoice={playingVoice} setPlayingVoice={setPlayingVoice}
          script={script} setScript={setScript}
          language={language} setLanguage={setLanguage}
          model={tweaks.model}
          onGenerate={handleGenerate}
          onNew={handleNew}
          generating={generating}
          renderedTake={renderedTake}
          setRenderedTake={setRenderedTake}
        />
        <RightPanel
          avatar={avatar}
          voice={voice}
          showCaption={tweaks.showCaption}
          previewLine={previewLine}
          isPlaying={isPlaying}
          setIsPlaying={setIsPlaying}
          progress={progress}
          setProgress={setProgress}
          generating={generating}
          renderedTake={renderedTake}
          script={script}
          videoUrl={videoUrl}
          hlsUrl={hlsUrl}
          finalVideoUrl={finalVideoUrl || hlsUrl}
          audioUrl={audioUrl}
          audioSec={audioSec}
          renderPct={renderPct}
          statusMsg={statusMsg}
          genError={genError}
          generatedSecs={generatedSecs}
          customAvatar={avatarId.startsWith('upload:') ? (customAvatars.find(u => `upload:${u.id}` === avatarId)?.file || null) : null}
          previewExpanded={previewExpanded}
          setPreviewExpanded={setPreviewExpanded}
          showModelChip={UPLOAD_LOCKED}
          audioPreviewReady={audioPreviewReady}
          audioPreviewActive={!!audioCtxRef.current}
          startAudioPreview={startAudioPreview}
          onStop={handleStop}
          onVideoEnded={handleVideoEnded}
        />
      </div>

      <TweaksUI tweaks={tweaks} setTweak={setTweak} />
    </div>
  );
}

/* ---------- Top bar ---------- */
function TopBar({ videoUrl, generating }) {
  return (
    <div className="topbar">
      <div className="topbar-left">
        <button className="iconbtn" title="Back"><Icon.back/></button>
      </div>
      <div className="topbar-right">
        <button className="btn ghost" disabled={!videoUrl}><Icon.share/> Share</button>
        {videoUrl
          ? <a className="btn dark" href={videoUrl} download>Publish</a>
          : <button className="btn dark" disabled={generating}>
              {generating ? <><Spinner/> Generating…</> : <>Publish</>}
            </button>
        }
      </div>
    </div>
  );
}

function Spinner({ size = 12 }) {
  const r = size * 0.375;
  return <svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} style={{animation:'spin 0.9s linear infinite', flexShrink:0}}>
    <circle cx={size/2} cy={size/2} r={r} fill="none" stroke="currentColor" strokeWidth={size*0.125} strokeDasharray={`${r*1.33} ${r*4}`} strokeLinecap="round"/>
  </svg>;
}

/* ---------- Left panel ---------- */
function LeftPanel(props) {
  const { tab, setTab, model, renderedTake, setRenderedTake } = props;
  const [historyOpen, setHistoryOpen] = useState(false);
  const popRef = useRef(null);
  useEffect(() => {
    if (!historyOpen) return;
    const onDown = e => { if (popRef.current && !popRef.current.contains(e.target)) setHistoryOpen(false); };
    window.addEventListener('mousedown', onDown);
    return () => window.removeEventListener('mousedown', onDown);
  }, [historyOpen]);
  return (
    <div className="left">
      <div className="tabbar">
        <button className={`tab ${tab==='avatar' ? 'active':''}`} onClick={() => setTab('avatar')}>
          <span className="num">01</span> Avatar
        </button>
        <button className={`tab ${tab==='voice' ? 'active':''}`} onClick={() => setTab('voice')}>
          <span className="num">02</span> Voice
        </button>
        <button className={`tab ${tab==='script' ? 'active':''}`} onClick={() => setTab('script')}>
          <span className="num">03</span> Script
        </button>
        <button className={`tab ${tab==='conversation' ? 'active':''}`} onClick={() => setTab('conversation')}>
          <span className="num">04</span> Conversation
        </button>
        <div style={{flex:1}}/>
        <div className="history-wrap" ref={popRef}>
          <button
            className={`iconbtn ${historyOpen ? 'on' : ''}`}
            title="Take history"
            onClick={() => setHistoryOpen(o => !o)}
          ><Icon.history/></button>
          {historyOpen && (
            <div className="history-pop">
              <div className="history-head">
                <span>Takes</span>
                <span className="mono" style={{fontSize:10, color:'var(--muted)', letterSpacing:'0.06em'}}>3 RENDERS</span>
              </div>
              {[1,2,3].map(i => (
                <button
                  key={i}
                  className={`history-item ${i === renderedTake ? 'current' : ''}`}
                  onClick={() => { setRenderedTake(i); setHistoryOpen(false); }}
                >
                  <span className="hi-id mono">Take {String(i).padStart(2,'0')}</span>
                  <span className="hi-meta">
                    <span className="mono">{i === 1 ? '2m ago' : i === 2 ? '1m ago' : 'just now'}</span>
                    {i === renderedTake && <span className="hi-current">Current</span>}
                  </span>
                </button>
              ))}
            </div>
          )}
        </div>
      </div>

      <div className="panel-body">
        {tab === 'script' && <ScriptTab {...props}/>}
        {tab === 'avatar' && <AvatarTab {...props}/>}
        {tab === 'voice'  && <VoiceTab  {...props}/>}
        {tab === 'conversation' && <ConversationTab/>}
      </div>

      <div className="panel-foot">
        {/* Model picker shown only on the boson-locked build; all-in-one
            VIP UI hides it (the user already knows what model they're on). */}
        {props.uploadLocked ? (
          <div className="model-picker">
            <span className="lbl">Model</span>
            <button className="select">
              {model} <Icon.chevron/>
            </button>
          </div>
        ) : (
          <div className="model-picker"/>  /* keep grid column placeholder */
        )}
        <div style={{display:'flex', gap:6}}>
          <button className="btn ghost" onClick={props.onNew} title="Start over — clear preview and begin a new take">
            <Icon.plus/> New
          </button>
          <button className="btn accent" onClick={props.onGenerate} disabled={props.generating}>
            {props.generating ? <><Spinner/> Generating…</> : <><Icon.wand/> Generate</>}
          </button>
        </div>
      </div>
    </div>
  );
}

/* ---------- Avatar tab ---------- */

/* One uploaded-image card. Memoised blob URL keyed on file identity so we
   don't churn URL.createObjectURL on every parent re-render. */
function UploadedAvatarCard({ id, file, selected, onSelect, onRemove }) {
  const url = useMemo(() => URL.createObjectURL(file), [file]);
  useEffect(() => () => URL.revokeObjectURL(url), [url]);
  return (
    <button
      className={`avatar ${selected ? 'selected' : ''}`}
      onClick={onSelect}
      title={file.name}
      style={{position:'relative', overflow:'hidden', padding:0}}
    >
      <img
        src={url}
        alt="uploaded portrait"
        style={{position:'absolute', inset:0, width:'100%', height:'100%', objectFit:'cover'}}
      />
      {/* span not <button>, since this lives inside the outer <button> card —
          <button> nested in <button> is invalid HTML and React warns about it. */}
      <span
        role="button"
        tabIndex={0}
        onClick={e => { e.preventDefault(); e.stopPropagation(); onRemove(); }}
        onKeyDown={e => {
          if (e.key === 'Enter' || e.key === ' ') {
            e.preventDefault(); e.stopPropagation(); onRemove();
          }
        }}
        style={{position:'absolute', top:4, right:4, zIndex:4, width:18, height:18, borderRadius:9,
                background:'rgba(0,0,0,0.6)', color:'#fff', fontSize:11, lineHeight:'18px',
                cursor:'pointer', display:'inline-flex', alignItems:'center', justifyContent:'center',
                userSelect:'none'}}
        title="Remove upload"
      >×</span>
      {/* Selected check moves to upper-left so it doesn't overlap the × */}
      <div className="selected-mark" style={{left:8, right:'auto'}}><Icon.check/></div>
    </button>
  );
}

function AvatarTab({ avatarId, setAvatarId, customAvatars, addCustomAvatar, removeCustomAvatar, uploadLocked }) {
  const [query, setQuery] = useState('');
  const list = AVATARS.filter(a => !query || a.name.toLowerCase().includes(query.toLowerCase()));
  return (
    <div>
      <div className="section-head">
        <h3>Choose an avatar</h3>
        <span className="sub">
          {list.length} presets
          {customAvatars.length > 0 && ` · ${customAvatars.length} uploaded`}
          {!uploadLocked && customAvatars.length === 0 && ' · or upload your own'}
        </span>
      </div>

      <div className="field">
        <span className="left-icon"><Icon.search/></span>
        <input
          placeholder="Search by name, accent, look…"
          value={query}
          onChange={e => setQuery(e.target.value)}
        />
        <div className="right-tools">
          <button title="AI suggest"><Icon.sparkle/></button>
          <button title="Filter"><Icon.filter/></button>
        </div>
      </div>

      <div className="avatars">
        {/* Upload card — hidden entirely on the boson-locked build. On the
            full build it's ALWAYS rendered so the user can keep uploading
            additional images; each new upload appends to the stack. */}
        {!uploadLocked && (
          <label
            className="avatar add"
            style={{cursor:'pointer'}}
            title="Upload your own portrait — used as the reference image"
          >
            <input
              type="file"
              accept="image/*"
              style={{display:'none'}}
              onChange={e => {
                const f = e.target.files?.[0];
                if (f) addCustomAvatar(f);
                e.target.value = '';
              }}
            />
            <span className="plus">＋</span>
            <span className="lbl">Upload photo</span>
          </label>
        )}

        {/* Uploaded image cards — one per file, persist across "New" clicks. */}
        {customAvatars.map(u => (
          <UploadedAvatarCard
            key={u.id}
            id={u.id}
            file={u.file}
            selected={avatarId === `upload:${u.id}`}
            onSelect={() => setAvatarId(`upload:${u.id}`)}
            onRemove={() => removeCustomAvatar(u.id)}
          />
        ))}

        {list.map(a => (
          <button
            key={a.id}
            className={`avatar ${avatarId === a.id ? 'selected' : ''}`}
            onClick={() => setAvatarId(a.id)}
            title={a.name}
          >
            <div className="swatch" style={{ background: a.tone }}>
              <div className="head"/>
              <div className="shoulders"/>
            </div>
            <div className="meta">
              <span className="id-tag">{a.id.toUpperCase()} · {a.name}</span>
            </div>
            <div className="selected-mark"><Icon.check/></div>
          </button>
        ))}
      </div>

    </div>
  );
}

/* ---------- Voice tab ---------- */

/* Combined card: left half = file Upload, right half = mic Record.
   Both branches end at addCustomVoice → Gemini transcribe → entry appears
   in the voice stack. Browser MediaRecorder uses audio/webm (Chrome /
   Firefox / Android) or audio/mp4 (Safari); the backend converts to WAV
   before passing to TTS (browser MediaRecorder can't produce WAV directly). */
function UploadOrRecordCard({ addCustomVoice }) {
  return (
    <div
      className="voice add-voice"
      style={{display:'grid', gridTemplateColumns:'1fr 1fr', gap:0, padding:0, overflow:'hidden'}}
    >
      <label
        style={{
          display:'flex', alignItems:'center', gap:10,
          padding:'10px 12px', cursor:'pointer',
          borderRight:'1px solid var(--border)',
        }}
        title="Upload a 5–15s clip"
      >
        <input
          type="file"
          accept="audio/*"
          style={{display:'none'}}
          onChange={e => {
            const f = e.target.files?.[0];
            if (f) addCustomVoice(f);
            e.target.value = '';
          }}
        />
        <span className="play-add" style={{flexShrink:0}}><Icon.plus/></span>
        <div className="info" style={{minWidth:0}}>
          <div className="name">Upload</div>
          <div className="descr"><span>Pick an audio file</span></div>
        </div>
      </label>
      <RecorderHalf addCustomVoice={addCustomVoice}/>
    </div>
  );
}

/* Mic recorder — right half of the combined card. State lives here so
   record/stop doesn't re-render the Upload side. */
function RecorderHalf({ addCustomVoice }) {
  const [recording, setRecording] = useState(false);
  const [elapsed, setElapsed] = useState(0);
  const [err, setErr] = useState(null);
  const recRef = useRef(null);
  const chunksRef = useRef([]);
  const startTsRef = useRef(0);
  const timerRef = useRef(null);

  const pickMime = () => {
    if (typeof MediaRecorder === 'undefined') return '';
    for (const m of ['audio/webm;codecs=opus', 'audio/webm', 'audio/mp4', 'audio/ogg']) {
      try { if (MediaRecorder.isTypeSupported(m)) return m; } catch {}
    }
    return '';  // browser picks default
  };

  const start = async () => {
    setErr(null);
    try {
      const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
      const mime = pickMime();
      const opts = mime ? { mimeType: mime } : undefined;
      const rec = new MediaRecorder(stream, opts);
      chunksRef.current = [];
      rec.ondataavailable = e => { if (e.data && e.data.size) chunksRef.current.push(e.data); };
      rec.onstop = () => {
        const blob = new Blob(chunksRef.current, { type: rec.mimeType || mime || 'audio/webm' });
        const ext = (blob.type.includes('mp4') ? 'm4a'
                   : blob.type.includes('ogg') ? 'ogg' : 'webm');
        const file = new File([blob], `recording-${Date.now()}.${ext}`, { type: blob.type });
        stream.getTracks().forEach(t => t.stop());
        addCustomVoice(file);
      };
      rec.start();
      recRef.current = rec;
      startTsRef.current = Date.now();
      setElapsed(0);
      setRecording(true);
      timerRef.current = setInterval(() => {
        setElapsed(Math.floor((Date.now() - startTsRef.current) / 1000));
      }, 250);
    } catch (e) {
      setErr(e.message || 'mic permission denied');
    }
  };

  const stop = () => {
    if (timerRef.current) { clearInterval(timerRef.current); timerRef.current = null; }
    setRecording(false);
    if (recRef.current && recRef.current.state !== 'inactive') {
      try { recRef.current.stop(); } catch {}
    }
    recRef.current = null;
  };

  // Cleanup on unmount
  useEffect(() => () => {
    if (timerRef.current) clearInterval(timerRef.current);
    if (recRef.current && recRef.current.state !== 'inactive') {
      try { recRef.current.stop(); } catch {}
    }
  }, []);

  const mm = String(Math.floor(elapsed / 60)).padStart(2, '0');
  const ss = String(elapsed % 60).padStart(2, '0');
  return (
    <div
      onClick={recording ? stop : start}
      title={recording ? 'Click to stop recording' : 'Click to record a voice sample'}
      style={{
        display:'flex', alignItems:'center', gap:10,
        padding:'10px 12px', cursor:'pointer',
        background: recording ? 'oklch(0.96 0.05 25)' : 'transparent',
      }}
    >
      <span className="play-add" aria-label={recording ? 'Stop' : 'Record'} style={{flexShrink:0}}>
        {recording ? <span style={{color:'#d23', display:'inline-flex'}}><Icon.stop/></span> : <Icon.mic/>}
      </span>
      <div className="info" style={{minWidth:0}}>
        <div className="name">{recording ? `Recording… ${mm}:${ss}` : 'Record'}</div>
        <div className="descr">
          <span>
            {recording ? 'Tap to stop' : (err ? `Error: ${err}` : 'Use your mic')}
          </span>
        </div>
      </div>
    </div>
  );
}

function VoiceTab({ voiceId, setVoiceId, customVoices, addCustomVoice, removeCustomVoice, playingVoice, setPlayingVoice, uploadLocked }) {
  return (
    <div>
      <div className="section-head">
        <h3>Choose a voice</h3>
        <span className="sub">6 presets{customVoices.length > 0 && ` · ${customVoices.length} uploaded`}</span>
      </div>

      <div className="field">
        <span className="left-icon"><Icon.search/></span>
        <input placeholder="Filter by accent, mood, energy…"/>
        <div className="right-tools">
          <button title="Upload sample"><Icon.upload/></button>
          <button title="AI suggest"><Icon.sparkle/></button>
        </div>
      </div>

      <div className="voices">
        {/* Upload card — hidden on boson-locked, always visible otherwise.
            Each new upload appends a new card to the stack below. */}
        {/* Combined Upload + Record card. Two columns share a single voice-
            card surface; hidden entirely on the boson-locked build. Both
            actions push the resulting File through addCustomVoice → Gemini
            transcribe → appears as a new entry in the stack below. */}
        {!uploadLocked && (
          <UploadOrRecordCard addCustomVoice={addCustomVoice}/>
        )}

        {/* Uploaded clip cards — one per file, persist across "New". */}
        {customVoices.map(u => {
          const isSelected = voiceId === `upload:${u.id}`;
          return (
            <div
              key={u.id}
              className={`voice ${isSelected ? 'selected' : ''}`}
              onClick={() => setVoiceId(`upload:${u.id}`)}
              title={u.file.name}
              style={{cursor:'pointer'}}
            >
              <button className="play" onClick={e => e.stopPropagation()}>
                <Icon.upload/>
              </button>
              <div className="info">
                <div className="name">{u.file.name}</div>
                <div className="descr">
                  <span className="mono">{(u.file.size/1024).toFixed(0)} KB</span>
                  <span className="sep">·</span>
                  {u.transcribing
                    ? <span style={{color:'var(--muted)', fontStyle:'italic'}}>transcribing…</span>
                    : u.transcript
                      ? <span style={{color:'var(--muted)', maxWidth:200, overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap'}} title={u.transcript}>"{u.transcript}"</span>
                      : <span style={{color:'var(--muted)'}}>no transcript</span>}
                  <span className="sep">·</span>
                  <button
                    onClick={e => { e.preventDefault(); e.stopPropagation(); removeCustomVoice(u.id); }}
                    style={{border:'none', background:'transparent', color:'var(--muted)', fontSize:11, cursor:'pointer', padding:0}}
                  >Remove</button>
                </div>
              </div>
            </div>
          );
        })}

        {VOICES.map(v => {
          const playing = playingVoice === v.id;
          return (
            <div
              key={v.id}
              className={`voice ${voiceId === v.id ? 'selected' : ''} ${playing ? 'playing' : ''}`}
              onClick={() => setVoiceId(v.id)}
            >
              <button className="play" onClick={e => { e.stopPropagation(); setPlayingVoice(playing ? null : v.id); }}>
                {playing ? <Icon.pause/> : <Icon.play/>}
              </button>
              <div className="info">
                <div className="name">{v.name}</div>
                <div className="descr">
                  <span className="tag">{v.tag}</span>
                  <span>{v.desc}</span>
                  <span className="sep">·</span>
                  <span className="mono">{v.dur}</span>
                </div>
              </div>
              <Waveform count={v.barCount} seed={v.id}/>
            </div>
          );
        })}
      </div>
    </div>
  );
}

function Waveform({ count = 24, seed = 'x' }) {
  // deterministic pseudo-random based on seed
  const heights = useMemo(() => {
    let s = 0; for (const c of seed) s = (s * 31 + c.charCodeAt(0)) >>> 0;
    return Array.from({length: count}, (_, i) => {
      s = (s * 1103515245 + 12345) >>> 0;
      const r = (s % 1000) / 1000;
      // shape a peak in the middle
      const env = 0.5 + 0.5 * Math.sin((i / count) * Math.PI);
      return Math.max(3, r * 18 * env + 3);
    });
  }, [count, seed]);
  return (
    <div className="wave" aria-hidden>
      {heights.map((h, i) => <span key={i} style={{ height: h }}/>) }
    </div>
  );
}

function SliderRow({ label, value }) {
  const [v, setV] = useState(value);
  return (
    <div style={{display:'grid', gridTemplateColumns:'80px 1fr 40px', alignItems:'center', gap:12, padding:'8px 0'}}>
      <span className="muted mono" style={{fontSize:10, textTransform:'uppercase', letterSpacing:'0.06em'}}>{label}</span>
      <input type="range" min="0" max="100" value={v} onChange={e=>setV(+e.target.value)}
        style={{ width:'100%', accentColor:'var(--ink)' }}/>
      <span className="mono" style={{fontSize:11, color:'var(--ink-soft)', textAlign:'right'}}>{v}</span>
    </div>
  );
}

/* ---------- Conversation tab (coming soon) ---------- */
function ConversationTab() {
  return (
    <div className="coming-soon">
      <div className="cs-badge mono">Coming soon</div>
      <h3 className="cs-title">Two-way conversation</h3>
      <p className="cs-body">
        Have the avatar respond in real time — connect a knowledge base,
        set a persona, and let viewers talk back. Currently in private beta.
      </p>
      <div className="cs-actions">
        <button className="btn dark">Join the waitlist</button>
        <button className="btn ghost">Read the docs</button>
      </div>
      <div className="cs-preview" aria-hidden>
        <div className="cs-bubble cs-them">Hey — can you walk me through pricing?</div>
        <div className="cs-bubble cs-you">Sure. We have three tiers…</div>
        <div className="cs-bubble cs-them cs-typing"><span/><span/><span/></div>
      </div>
    </div>
  );
}

/* ---------- Script tab ---------- */
function ScriptTab({ script, setScript, language, setLanguage }) {
  const SCRIPT_CHAR_LIMIT = 500;
  const chars = (script || '').length;
  const words = (script || '').trim().split(/\s+/).filter(Boolean).length;
  const seconds = Math.max(0, Math.round(words / 2.7));
  return (
    <div>
      <div className="section-head">
        <h3>Write the script</h3>
        <span className="sub mono">{words}w · ~{seconds}s</span>
      </div>

      <div className="script-wrap">
        <textarea
          value={script}
          maxLength={SCRIPT_CHAR_LIMIT}
          onChange={e => setScript(e.target.value.slice(0, SCRIPT_CHAR_LIMIT))}
          placeholder="What should the avatar say? Keep it under 500 characters (~30s of speech)."
        />
        <div className="script-toolbar">
          <div className="tools">
            <button className="tool"><Icon.mic/> Dictate</button>
          </div>
          <div>{chars}/{SCRIPT_CHAR_LIMIT} chars · {language}</div>
        </div>
      </div>

      <div className="script-presets">
        <div className="script-presets-head">
          <span>Start from a preset</span>
          <span className="mono" style={{textTransform:'none', letterSpacing:0}}>4 templates</span>
        </div>
        <div className="preset-grid">
          {PRESETS.map(p => (
            <button key={p.title} className="preset" onClick={() => setScript(p.desc + '\n\n')}>
              <div className="ptitle">{p.title}</div>
              <div className="pdesc">{p.desc}</div>
            </button>
          ))}
        </div>
      </div>
    </div>
  );
}

/* ---------- Right panel ---------- */
function RightPanel({ avatar, voice, previewLine, isPlaying, setIsPlaying,
                      progress, setProgress, generating, renderedTake, script, showCaption,
                      videoUrl, hlsUrl, finalVideoUrl, audioUrl, audioSec, renderPct, statusMsg, genError,
                      generatedSecs, customAvatar, previewExpanded, setPreviewExpanded,
                      showModelChip, audioPreviewReady, audioPreviewActive, startAudioPreview,
                      onStop, onVideoEnded }) {
  const videoRef  = useRef(null);
  // Blob URL for the uploaded portrait — shown in the preview pane when
  // there is no video yet, so the user can confirm what's about to be sent.
  const customAvatarUrl = useMemo(
    () => customAvatar ? URL.createObjectURL(customAvatar) : null,
    [customAvatar],
  );
  useEffect(() => () => { if (customAvatarUrl) URL.revokeObjectURL(customAvatarUrl); }, [customAvatarUrl]);
  const hlsRef    = useRef(null);
  // True only when the user explicitly clicked pause. Stall-pauses (player
  // pausing on buffer underrun) leave this false, so the HLS effect can
  // auto-resume when fragments arrive without fighting a manual pause.
  const userPausedRef = useRef(false);
  // Buffering signal — set by the video element's `waiting` event and
  // cleared on `playing` / `canplay`. Drives the green dot + "Buffering"
  // label in the preview tag.
  const [isBuffering, setIsBuffering] = useState(false);
  // Rolling debug log: hls errors, video stall/waiting events, last fragment.
  // Surfaced via the bottom-right 🐛 button → copy panel.
  const debugLogRef = useRef([]);
  const [showDebug, setShowDebug] = useState(false);
  // Debug button hidden by default; enable with ?debug in the URL.
  const debugEnabled = typeof window !== 'undefined'
    && new URLSearchParams(window.location.search).has('debug');
  const pushDebug = (line) => {
    debugLogRef.current.push(`[${new Date().toISOString().slice(11,23)}] ${line}`);
    if (debugLogRef.current.length > 80) debugLogRef.current.shift();
  };
  // Track whether the video has finished — flips the play button to a replay
  // icon. Cleared on play, on a new generation (renderedTake change), or when
  // the user scrubs backward via the scrubbar.
  const [ended, setEnded] = useState(false);
  useEffect(() => { setEnded(false); }, [renderedTake]);

  // Rotating busy-phrase for the pre-video overlay.
  // Long enough list + slow interval so we never loop before first chunk arrives.
  const BUSY_PHRASES = [
    'Synthesising speech',
    'Analysing phonemes',
    'Generating lip motion',
    'Rendering eye movement',
    'Animating jaw timing',
    'Aligning head pose',
    'Compositing frames',
    'Smoothing transitions',
    'Calibrating prosody',
    'Encoding micro-expressions',
    'Timing blinks',
    'Rendering facial muscles',
    'Applying skin dynamics',
    'Finalising expression',
    'Building first frame',
  ];
  const [phraseIdx, setPhraseIdx] = useState(0);
  useEffect(() => {
    if (!generating || hlsUrl) return;
    const id = setInterval(() => setPhraseIdx(i => Math.min(i + 1, BUSY_PHRASES.length - 1)), 3500);
    return () => clearInterval(id);
  }, [generating, hlsUrl]);

  // Use real TTS audio length once known; fall back to word-count estimate
  // (~162 wpm) before the audio_ready event arrives.
  const scriptSec = audioSec > 0
    ? audioSec
    : Math.max(8, Math.round(((script||'').trim().split(/\s+/).filter(Boolean).length || 1) / 2.7));
  // While generating: right bound = generated so far. After done: actual video duration.
  // Always use the estimated full duration so the bar looks complete from the start.
  // The grey buffer fill grows to show how much has been rendered.
  const totalSec = scriptSec;
  const fmt = s => `${Math.floor(s/60)}:${String(Math.round(s)%60).padStart(2,'0')}`;

  // HLS live stream: attach / detach hls.js when hlsUrl changes
  useEffect(() => {
    if (!videoRef.current || !hlsUrl) {
      if (hlsRef.current) { hlsRef.current.destroy(); hlsRef.current = null; }
      return;
    }
    const Hls = window.Hls;
    if (Hls && Hls.isSupported()) {
      if (hlsRef.current) hlsRef.current.destroy();
      const hls = new Hls({
        enableWorker: false,
        startPosition: 0,
        backBufferLength: 90,
        // Generation pace ≈ playback pace at 1×, so a thin buffer underruns
        // constantly. Wait for ~3s of pre-buffer before starting playback.
        maxBufferLength: 60,
        maxMaxBufferLength: 120,
      });
      hlsRef.current = hls;
      // Don't autoplay on MANIFEST_PARSED — that fires after the first segment
      // No autoplay — the user must tap the play button to start. After
      // that first tap, FRAG_BUFFERED auto-resumes any mid-stream stalls
      // (as long as the user didn't pause manually).
      let started = false;
      const onFirstPlay = () => { started = true; };
      videoRef.current.addEventListener('play', onFirstPlay, { once: true });
      userPausedRef.current = false;  // reset for the new stream
      // Auto-resume on stall ONLY after the user has manually pressed play.
      // First-frame rendering is enough — let the user start playback by
      // tapping the play button (no autoplay anywhere).
      const onFragBuffered = () => {
        if (!videoRef.current || !started) return;
        const v = videoRef.current;
        const b = v.buffered;
        const head = b.length ? b.end(b.length - 1) : 0;
        if (v.paused && !userPausedRef.current && !v.ended && head > v.currentTime + 0.05) {
          v.play().catch(() => {});
        }
      };
      hls.loadSource(hlsUrl);
      hls.attachMedia(videoRef.current);
      hls.on(Hls.Events.FRAG_BUFFERED, onFragBuffered);
      hls.on(Hls.Events.ERROR, (_, data) => {
        console.warn('[hls.error]', data.type, data.details, data.fatal ? 'FATAL' : '', data);
        pushDebug(`hls.error ${data.type} ${data.details}${data.fatal?' FATAL':''}`);
        if (data.fatal) {
          console.error('[hls.fatal]', data.details, 'buffered=', videoRef.current ?
            Array.from({length: videoRef.current.buffered.length}, (_,i) =>
              [videoRef.current.buffered.start(i).toFixed(2), videoRef.current.buffered.end(i).toFixed(2)]) : 'n/a',
            'currentTime=', videoRef.current?.currentTime);
          hls.destroy(); hlsRef.current = null;
        }
      });
      hls.on(Hls.Events.BUFFER_APPENDED, (_, data) => {
        console.log('[hls.appended]', data.type, 'timeRanges=',
          Array.from({length: data.timeRanges[data.type]?.length || 0}, (_,i) =>
            [data.timeRanges[data.type].start(i).toFixed(2), data.timeRanges[data.type].end(i).toFixed(2)]));
      });
      hls.on(Hls.Events.FRAG_LOADED, (_, data) => {
        console.log('[hls.frag_loaded]', 'sn=', data.frag.sn, 'start=', data.frag.start.toFixed(2), 'end=', (data.frag.start+data.frag.duration).toFixed(2));
      });
      hls.on(Hls.Events.BUFFER_EOS, (_, data) => console.log('[hls.buffer_eos]', data));
      videoRef.current.addEventListener('ended', () => {
        console.log('[video.ended] currentTime=', videoRef.current.currentTime, 'duration=', videoRef.current.duration,
          'buffered=', Array.from({length: videoRef.current.buffered.length}, (_,i) =>
            [videoRef.current.buffered.start(i).toFixed(2), videoRef.current.buffered.end(i).toFixed(2)]));
      });
      videoRef.current.addEventListener('stalled', () => {
        const t = videoRef.current?.currentTime?.toFixed(2);
        console.warn('[video.stalled] currentTime=', t);
        pushDebug(`video.stalled t=${t}`);
      });
      videoRef.current.addEventListener('waiting', () => {
        const t = videoRef.current?.currentTime?.toFixed(2);
        console.log('[video.waiting] currentTime=', t);
        pushDebug(`video.waiting t=${t}`);
        setIsBuffering(true);
      });
      videoRef.current.addEventListener('playing', () => setIsBuffering(false));
      videoRef.current.addEventListener('canplay',  () => setIsBuffering(false));
    } else if (videoRef.current.canPlayType('application/vnd.apple.mpegurl')) {
      // Native HLS (Safari / iOS). Load metadata so the first frame can
      // render, but DON'T autoplay — the user starts playback by tapping
      // the play button.
      const v = videoRef.current;
      v.src = hlsUrl;
      v.load();
    }
    return () => { if (hlsRef.current) { hlsRef.current.destroy(); hlsRef.current = null; } };
  }, [hlsUrl]);

  // Direct src playback — only when HLS is not active (final video after
  // stream ends). Load the metadata for the first frame; let the user
  // press play.
  useEffect(() => {
    if (!videoRef.current || !videoUrl || hlsUrl) return;
    videoRef.current.src = videoUrl;
    videoRef.current.load();
  }, [videoUrl, hlsUrl]);

  // Sync manual play/pause toggle with video element
  useEffect(() => {
    if (!videoRef.current || (!videoUrl && !hlsUrl)) return;
    if (isPlaying) videoRef.current.play().catch(() => {});
    else videoRef.current.pause();
  }, [isPlaying]);

  const hasVideo = !!(hlsUrl || videoUrl);
  // Show pre-video overlay (rotating phrases) while generating before first chunk arrives
  const isPreVideoPhase = generating && !hlsUrl;
  const takeLabel = renderedTake > 0 ? `Take ${String(renderedTake).padStart(2,'0')}` : 'Ready';

  // ─── Debug snapshot ─────────────────────────────────────────────────────
  const buildDebugReport = () => {
    const v = videoRef.current;
    const buf = v?.buffered;
    const bufRanges = buf
      ? Array.from({length: buf.length}, (_, i) => `[${buf.start(i).toFixed(2)},${buf.end(i).toFixed(2)}]`).join(' ')
      : '(no video element)';
    const lines = [
      `=== avatar-ui debug snapshot @ ${new Date().toISOString()} ===`,
      `location.href:        ${typeof window !== 'undefined' ? window.location.href : ''}`,
      `userAgent:            ${typeof navigator !== 'undefined' ? navigator.userAgent : ''}`,
      `--- React state ---`,
      `generating:           ${generating}`,
      `hlsUrl:               ${hlsUrl || '-'}`,
      `videoUrl/finalUrl:    ${videoUrl || finalVideoUrl || '-'}`,
      `audioUrl:             ${audioUrl || '-'}`,
      `isPlaying:            ${isPlaying}`,
      `ended (state):        ${ended}`,
      `progress (0..1):      ${progress.toFixed(4)}`,
      `audioSec:             ${audioSec}`,
      `scriptSec:            ${scriptSec}`,
      `generatedSecs (backend): ${generatedSecs}`,
      `renderPct:            ${renderPct}`,
      `statusMsg:            ${statusMsg || '-'}`,
      `genError:             ${genError || '-'}`,
      `renderedTake:         ${renderedTake}`,
      `userPausedRef:        ${userPausedRef.current}`,
      `--- video element ---`,
      `present:              ${!!v}`,
      `currentTime:          ${v ? v.currentTime.toFixed(3) : '-'}`,
      `duration:             ${v ? (Number.isFinite(v.duration) ? v.duration.toFixed(3) : v.duration) : '-'}`,
      `paused:               ${v ? v.paused : '-'}`,
      `ended (element):      ${v ? v.ended : '-'}`,
      `readyState:           ${v ? v.readyState : '-'} (0=HAVE_NOTHING..4=HAVE_ENOUGH_DATA)`,
      `networkState:         ${v ? v.networkState : '-'} (0=EMPTY..3=NO_SOURCE)`,
      `playbackRate:         ${v ? v.playbackRate : '-'}`,
      `videoSize:            ${v ? `${v.videoWidth}x${v.videoHeight}` : '-'}`,
      `buffered ranges:      ${bufRanges}`,
      `error:                ${v?.error ? `code=${v.error.code} msg=${v.error.message}` : '-'}`,
      `--- hls.js ---`,
      `attached:             ${!!hlsRef.current}`,
      hlsRef.current ? `levels:               ${(hlsRef.current.levels || []).length}` : '',
      hlsRef.current ? `currentLevel:         ${hlsRef.current.currentLevel}` : '',
      hlsRef.current ? `liveSyncPos:          ${hlsRef.current.liveSyncPosition?.toFixed?.(2) || '-'}` : '',
      `--- event log (last ${debugLogRef.current.length}) ---`,
      ...debugLogRef.current,
    ].filter(Boolean);
    return lines.join('\n');
  };

  return (
    <div className="right">
      {showDebug && (
        <div
          onClick={() => setShowDebug(false)}
          style={{
            position:'fixed', inset:0, background:'rgba(0,0,0,0.55)',
            zIndex:1000, display:'flex', alignItems:'center', justifyContent:'center',
          }}
        >
          <div
            onClick={e => e.stopPropagation()}
            style={{
              background:'#fff', width:'min(720px, 92vw)', maxHeight:'80vh',
              borderRadius:8, padding:16, display:'flex', flexDirection:'column', gap:10,
              boxShadow:'0 12px 48px rgba(0,0,0,0.3)',
            }}
          >
            <div style={{display:'flex', justifyContent:'space-between', alignItems:'center'}}>
              <div style={{fontSize:13, fontWeight:600}}>Debug snapshot</div>
              <div style={{display:'flex', gap:6}}>
                <button
                  className="btn ghost"
                  style={{fontSize:11}}
                  onClick={async () => {
                    try {
                      await navigator.clipboard.writeText(buildDebugReport());
                      const b = document.activeElement;
                      if (b) { const o = b.textContent; b.textContent = 'Copied!'; setTimeout(() => { b.textContent = o; }, 1200); }
                    } catch (err) { alert('Copy failed: ' + err.message); }
                  }}
                >Copy</button>
                <button className="btn ghost" style={{fontSize:11}} onClick={() => setShowDebug(false)}>Close</button>
              </div>
            </div>
            <textarea
              readOnly
              value={buildDebugReport()}
              style={{
                flex:1, minHeight:300, fontFamily:'JetBrains Mono, monospace',
                fontSize:11, padding:10, border:'1px solid #ddd', borderRadius:4,
                whiteSpace:'pre', overflow:'auto', background:'#fafafa',
              }}
            />
          </div>
        </div>
      )}
      {debugEnabled && <button
        onClick={() => setShowDebug(true)}
        title="Open debug snapshot"
        style={{
          position:'fixed', right:14, bottom:14, zIndex:900,
          width:36, height:36, borderRadius:18, border:'1px solid rgba(0,0,0,0.18)',
          background:'rgba(255,255,255,0.92)', cursor:'pointer', fontSize:14,
          boxShadow:'0 2px 8px rgba(0,0,0,0.12)',
        }}
      >🐛</button>}
      <div className="canvas">
        <div className="preview-frame">
          <div className="preview-bg"/>

          {/* Video element — rendered once we have HLS stream or final src */}
          {hasVideo && (
            <video
              ref={videoRef}
              src={hlsUrl ? undefined : videoUrl}
              style={{ position:'absolute', inset:0, width:'100%', height:'100%', objectFit:'cover', zIndex:2 }}
              playsInline
              webkit-playsinline="true"
              preload="auto"
              onTimeUpdate={e => {
                if (scriptSec <= 0) return;
                const v = e.currentTarget;
                // Cap the playhead at the end of the buffered range. HTML
                // video's currentTime keeps ticking on its internal clock
                // during stalls + bufferNudgeOnStall, which would push the
                // bar past actual playback. Buffer end is the truthful
                // upper bound for "what's been generated and seen."
                const bufEnd = v.buffered.length ? v.buffered.end(v.buffered.length - 1) : 0;
                const capped = Math.min(v.currentTime, bufEnd);
                setProgress(capped / scriptSec);
              }}
              onEnded={() => {
                pushDebug(`video.ended t=${videoRef.current?.currentTime?.toFixed(2)} dur=${videoRef.current?.duration?.toFixed(2)}`);
                setEnded(true); onVideoEnded();
              }}
              onPlay={() => { pushDebug(`video.play t=${videoRef.current?.currentTime?.toFixed(2)}`); setIsPlaying(true); setEnded(false); }}
              onPause={() => { pushDebug(`video.pause t=${videoRef.current?.currentTime?.toFixed(2)} userPaused=${userPausedRef.current}`); setIsPlaying(false); }}
            />
          )}

          {/* Portrait placeholder — shown when no video yet. If the user
              uploaded a custom photo, show that as the preview. Otherwise
              the abstract head+shoulders sketch in the avatar's tone. */}
          {!hasVideo && customAvatarUrl && (
            <img
              src={customAvatarUrl}
              alt="uploaded portrait"
              style={{
                position:'absolute', inset:0, width:'100%', height:'100%',
                objectFit:'cover', zIndex:1,
              }}
            />
          )}
          {!hasVideo && !customAvatarUrl && (
            <div className="preview-portrait">
              <div
                className={`head ${isPlaying ? 'talking' : ''}`}
                style={{ background: avatar?.tone, borderColor: 'oklch(0.78 0.02 60)' }}
              />
              <div className="shoulders" style={{ background: avatar?.tone, opacity: 0.85 }}/>
            </div>
          )}

          {/* Pre-video overlay — full frame while generating before first chunk */}
          {isPreVideoPhase && (
            <div style={{
              position:'absolute', inset:0, display:'flex', flexDirection:'column',
              alignItems:'center', justifyContent:'center', gap:20,
              background:'oklch(0.985 0.005 70 / 0.92)', backdropFilter:'blur(4px)', zIndex:5,
            }}>
              <Spinner size={20}/>
              <div className="mono" style={{
                fontSize:10, letterSpacing:'0.12em', color:'var(--muted)',
                textTransform:'uppercase', textAlign:'center',
              }}>
                {BUSY_PHRASES[Math.min(phraseIdx, BUSY_PHRASES.length - 1)]}
                <span style={{opacity:0.5}}>{'...'}</span>
              </div>
              {/* Streaming-TTS preview: once the first audio chunk has
                  arrived, let the user hear the voice while the video is
                  still rendering. WebAudio needs a user gesture, so this
                  button has to exist (no autoplay). */}
              {audioPreviewReady && !audioPreviewActive && (
                <button className="btn" style={{fontSize:11, marginTop:6}} onClick={startAudioPreview}>
                  🔊 Preview voice
                </button>
              )}
              <button className="btn ghost" style={{fontSize:11, marginTop:4}} onClick={onStop}>Cancel</button>
            </div>
          )}

          {/* Error overlay */}
          {!generating && genError && (
            <div style={{
              position:'absolute', inset:0, display:'grid', placeItems:'center',
              background:'oklch(0.985 0.005 70 / 0.92)', backdropFilter:'blur(2px)', zIndex:5,
            }}>
              <div style={{textAlign:'center', display:'grid', gap:12}}>
                <div className="mono" style={{fontSize:10, letterSpacing:'0.1em', color:'var(--muted)', textTransform:'uppercase'}}>Generation failed</div>
                <div style={{fontSize:12, color:'var(--ink)', maxWidth:280, lineHeight:1.5}}>{genError}</div>
              </div>
            </div>
          )}

          {/* Controls — only shown once video is ready */}
          {!isPreVideoPhase && (
            <>
              <div className={`preview-tag ${(isPlaying || isBuffering || generating) ? 'live' : ''}`} style={{zIndex:6}}>
                <span className="dot"/>
                {/* Priority: Buffering > Playing > Generating > Take label > Ready.
                    `generating` wins over hasVideo because while a run is in
                    flight, hasVideo can be true (HLS attached) but renderedTake
                    is still 0 — takeLabel falls back to "Ready" in that window
                    and lies about the state. */}
                {isBuffering
                  ? 'Buffering'
                  : isPlaying
                    ? 'Playing preview'
                    : generating
                      ? 'Generating'
                      : hasVideo
                        ? takeLabel
                        : 'Ready'}
              </div>
              {showCaption && previewLine && !hasVideo && !generating && (
                <div className="preview-caption">"{previewLine}"</div>
              )}
              {hasVideo && (
                <div className="scrubbar" style={{zIndex:6}}>
                  <button
                    className="playbtn"
                    title={ended ? 'Replay' : (isPlaying ? 'Pause' : 'Play')}
                    onClick={() => {
                      const v = videoRef.current;
                      if (!v) return;
                      if (ended) {
                        v.currentTime = 0;
                        setProgress(0);
                        setEnded(false);
                        v.play().catch(() => {});
                        return;
                      }
                      // Drive off the video element's actual state (v.paused),
                      // not the React isPlaying state. They can desync when
                      // hls.js stalls near the end of the stream, which would
                      // make a single click toggle the wrong direction.
                      if (v.paused) {
                        userPausedRef.current = false;
                        v.play().catch(() => {});
                      } else {
                        userPausedRef.current = true;
                        v.pause();
                      }
                    }}
                  >
                    {ended ? <Icon.replay/> : (isPlaying ? <Icon.pause/> : <Icon.play/>)}
                  </button>
                  <span className="time">{fmt(Math.floor(progress * scriptSec))}</span>
                  <div className="progress" style={{position:'relative'}} onClick={e => {
                    const r = e.currentTarget.getBoundingClientRect();
                    const t = Math.min(1, Math.max(0, (e.clientX - r.left) / r.width));
                    const seek = t * scriptSec;
                    // Clamp seek to buffered portion — can't play past what's been generated
                    const maxSeek = generatedSecs > 0 ? generatedSecs : (videoRef.current?.duration || 0);
                    const clamped = Math.min(seek, maxSeek);
                    if (videoRef.current) videoRef.current.currentTime = clamped;
                    setProgress(clamped / scriptSec);
                    // Scrubbing away from the end clears the replay state.
                    if (clamped < maxSeek - 0.1) setEnded(false);
                  }}>
                    {/* grey buffer fill — grows left-to-right as HLS chunks arrive */}
                    <div style={{
                      position:'absolute', left:0, top:0, bottom:0,
                      width:`${Math.min(100, scriptSec > 0 ? (generatedSecs / scriptSec) * 100 : 0)}%`,
                      background:'var(--ink)', opacity:0.13, borderRadius:'inherit',
                      transition:'width 600ms ease',
                    }}/>
                    {/* Cap play bar at buffer bar — playhead can never visually
                        pass what's been generated. */}
                    <div className="fill" style={{
                      width: `${Math.min(progress, scriptSec > 0 ? generatedSecs / scriptSec : 1) * 100}%`,
                      position:'relative', zIndex:1,
                    }}/>
                  </div>
                  <span className="time">{fmt(scriptSec)}</span>
                </div>
              )}
            </>
          )}
        </div>
      </div>

      <div className="right-bottom">
        <div className="left-cluster">
          <div className="specchip"><span className="k">avatar</span> {avatar?.name}</div>
          <div className="specchip"><span className="k">voice</span> {voice?.name}</div>
          {/* Shown only on the boson-locked build; the all-in-one VIP UI
              hides this since the user already knows what model they're on. */}
          {showModelChip && (
            <div className="specchip"><span className="k">model</span> BosonAvatar-01</div>
          )}
        </div>
        <div className="right-cluster">
          <button
            className="btn ghost expand-toggle"
            onClick={() => setPreviewExpanded(e => !e)}
            aria-label={previewExpanded ? 'Collapse preview' : 'Expand preview'}
            title={previewExpanded ? 'Collapse preview' : 'Expand preview'}
          >
            {previewExpanded ? <Icon.collapseUp/> : <Icon.expandDown/>}
            <span className="expand-label">{previewExpanded ? 'Collapse' : 'Fullscreen'}</span>
          </button>
          {finalVideoUrl
            ? <a className="btn" href={finalVideoUrl} download><Icon.download/> Export</a>
            : <button className="btn" disabled><Icon.download/> Export</button>
          }
        </div>
      </div>
    </div>
  );
}

/* Animated waveform for the TTS phase */
function AudioWaveAnim() {
  const bars = 28;
  // Precompute per-bar params — deterministic, no CSS vars in keyframes
  const barParams = Array.from({length: bars}, (_, i) => ({
    dur:   (0.5 + Math.abs(Math.sin(i * 1.3)) * 0.6).toFixed(2),
    delay: (-i * 0.06).toFixed(2),
    scale: 0.3 + Math.abs(Math.sin(i * 0.9)) * 0.7,
    op:    0.55 + 0.45 * Math.abs(Math.sin(i * 1.1)),
  }));
  return (
    <div style={{display:'flex', alignItems:'center', gap:3, height:48}}>
      <style>{`
        @keyframes wb { from { transform: scaleY(0.12); } to { transform: scaleY(1); } }
      `}</style>
      {barParams.map(({dur, delay, scale, op}, i) => (
        <div key={i} style={{
          width: 3,
          height: `${Math.round(10 + scale * 34)}px`,
          borderRadius: 2,
          background: 'var(--accent)',
          opacity: op,
          transformOrigin: 'bottom',
          animation: `wb ${dur}s ease-in-out ${delay}s infinite alternate`,
        }}/>
      ))}
    </div>
  );
}

/* ---------- Tweaks ---------- */
function TweaksUI({ tweaks, setTweak }) {
  return (
    <TweaksPanel title="Tweaks">
      <TweakSection label="Accent">
        <TweakColor
          label="Accent"
          value={tweaks.accent}
          onChange={v => setTweak('accent', v)}
          options={['#c0664a', '#3a6b66', '#5557a8', '#2b2b2b']}
        />
      </TweakSection>
      <TweakSection label="Preview">
        <TweakToggle
          label="Caption"
          value={tweaks.showCaption}
          onChange={v => setTweak('showCaption', v)}
        />
      </TweakSection>
      <TweakSection label="Model">
        <TweakSelect
          label="Version"
          value={tweaks.model}
          onChange={v => setTweak('model', v)}
          options={['BosonAvatar-00', 'BosonAvatar-01', 'BosonAvatar-01 HD', 'BosonAvatar Live']}
        />
      </TweakSection>
    </TweaksPanel>
  );
}

/* ---------- Mount ---------- */
ReactDOM.createRoot(document.getElementById('root')).render(<App/>);
