import { useEffect, useRef, useState } from 'react'
import { api, upload, runJob } from '../api'
import { useJob, ProgressBox, ErrorBox } from '../hooks'
import PromptPanel from '../components/PromptPanel'

// Preview canvas is the 9:16 frame scaled down.
const PW = 240, PH = 427, SCALE = PW / 1080
// CSS corner-pin: matrix3d mapping the w×h box to 4 destination corners (px).
// corners = [TL,TR,BR,BL] as [x,y]. Returns a matrix3d() string (origin 0 0).
function cornerPin(w, h, corners) {
  const adj = (m) => [m[4]*m[8]-m[5]*m[7], m[2]*m[7]-m[1]*m[8], m[1]*m[5]-m[2]*m[4],
                      m[5]*m[6]-m[3]*m[8], m[0]*m[8]-m[2]*m[6], m[2]*m[3]-m[0]*m[5],
                      m[3]*m[7]-m[4]*m[6], m[1]*m[6]-m[0]*m[7], m[0]*m[4]-m[1]*m[3]]
  const mm = (a, b) => { const c = []; for (let i=0;i<3;i++) for (let j=0;j<3;j++){ let s=0; for (let k=0;k<3;k++) s+=a[3*i+k]*b[3*k+j]; c[3*i+j]=s } return c }
  const mv = (m, v) => [m[0]*v[0]+m[1]*v[1]+m[2]*v[2], m[3]*v[0]+m[4]*v[1]+m[5]*v[2], m[6]*v[0]+m[7]*v[1]+m[8]*v[2]]
  const basis = (p) => { const m=[p[0][0],p[1][0],p[2][0], p[0][1],p[1][1],p[2][1], 1,1,1]; const v=mv(adj(m),[p[3][0],p[3][1],1]); return mm(m,[v[0],0,0, 0,v[1],0, 0,0,v[2]]) }
  const src = [[0,0],[w,0],[0,h],[w,h]]            // TL,TR,BL,BR
  const dst = [corners[0], corners[1], corners[3], corners[2]]
  let t = mm(basis(dst), adj(basis(src)))
  t = t.map((x) => x / t[8])
  return `matrix3d(${[t[0],t[3],0,t[6], t[1],t[4],0,t[7], 0,0,1,0, t[2],t[5],0,t[8]].join(',')})`
}
// hex colour -> rgba() string with an alpha (for translucent shape fills).
const hexToRgba = (hex, a) => {
  const h = (hex || '#FF3B30').replace('#', '')
  const n = parseInt(h.length === 3 ? h.split('').map((c) => c + c).join('') : h, 16)
  return `rgba(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}, ${a})`
}
// Edge-trim grab zones on timeline bars (left / right).
const TL_EDGE_L = { position: 'absolute', left: 0, top: 0, bottom: 0, width: 6, cursor: 'ew-resize', background: 'rgba(255,255,255,0.4)' }
const TL_EDGE_R = { position: 'absolute', right: 0, top: 0, bottom: 0, width: 6, cursor: 'ew-resize', background: 'rgba(255,255,255,0.4)' }

let _uid = 0
const newId = () => `ov${Date.now()}_${_uid++}`

function interp(kfs, t) {
  if (!kfs || !kfs.length) return { x: 0.5, y: 0.5, scale: 1, rotation: 0, opacity: 1 }
  const s = [...kfs].sort((a, b) => a.t - b.t)
  if (t <= s[0].t) return s[0]
  if (t >= s[s.length - 1].t) return s[s.length - 1]
  for (let i = 0; i < s.length - 1; i++) {
    const a = s[i], b = s[i + 1]
    if (a.t <= t && t <= b.t) {
      const f = (t - a.t) / ((b.t - a.t) || 1e-6)
      const l = (p, q) => p + (q - p) * f
      return { t, x: l(a.x, b.x), y: l(a.y, b.y), scale: l(a.scale, b.scale), rotation: l(a.rotation, b.rotation), opacity: l(a.opacity, b.opacity) }
    }
  }
  return s[s.length - 1]
}
const blankKf = (t) => ({ t, x: 0.5, y: 0.5, scale: 1, rotation: 0, opacity: 1 })

// Live (approximate) preview of motion effects as a CSS transform on the clip.
function motionTransform(effects, t) {
  let scale = 1, tx = 0, ty = 0
  for (const e of effects) {
    if (!(t >= e.start && t < e.end)) continue
    const p = Math.min(Math.max((t - e.start) / Math.max(0.05, e.end - e.start), 0), 1)
    const I = e.intensity ?? 0.5
    if (e.type === 'zoom') scale *= 1 + (0.15 + 0.35 * I) * Math.sin(Math.PI * p)
    else if (e.type === 'pulse') scale *= 1 + (0.15 + 0.35 * I) * Math.abs(Math.sin(Math.PI * 3 * p))
    else if (e.type === 'shake') { tx += 8 * I * Math.sin(t * 37); ty += 8 * I * Math.sin(t * 43) }
    else if (e.type === 'pan') { scale *= 1.12; tx += -10 * I + 20 * I * p }
    else if (e.type === 'zoomout') scale *= 1 + (0.15 + 0.35 * I) * (1 - Math.sin(Math.PI / 2 * p))
    else if (e.type === 'slowzoom') scale *= 1 + (0.08 + 0.22 * I) * p
    else if (e.type === 'glitch') { tx += 12 * I * Math.sin(t * 120) * Math.sin(t * 9); ty += 12 * I * Math.cos(t * 111) * Math.sin(t * 7) }
  }
  return `translate(${tx}px,${ty}px) scale(${scale})`
}
// Black-fade opacity at time t (fadein dips to black then clears; fadeout to black).
function fadeBlack(effects, t) {
  let op = 0
  for (const e of effects) {
    if (!(t >= e.start && t < e.end)) continue
    const p = Math.min(Math.max((t - e.start) / Math.max(0.05, e.end - e.start), 0), 1)
    if (e.type === 'fadein') op = Math.max(op, 1 - p)
    else if (e.type === 'fadeout') op = Math.max(op, p)
  }
  return op
}

const EFFECT_COLORS = { zoom: '#6c8cff', pulse: '#9b6cff', shake: '#ff8c42', pan: '#42c0ff',
  zoomout: '#6cffb0', slowzoom: '#b0ff6c', glitch: '#ff5c8a', fadein: '#aaaaaa', fadeout: '#666666' }

// Client mirror of core/captions.py presets so the preview matches the render.
const CAP = {
  'bold-center-karaoke': { size: 88, y: 0.52, base: '#fff', hi: '#FFD60A', upper: true, mode: 'karaoke', wpc: 3, box: false },
  'clean-lower-third': { size: 58, y: 0.78, base: '#fff', hi: '#78dcff', upper: false, mode: 'karaoke', wpc: 5, box: true },
  'minimal': { size: 48, y: 0.85, base: '#f5f5f5', hi: '#f5f5f5', upper: false, mode: 'static', wpc: 6, box: false },
  'typewriter': { size: 72, y: 0.55, base: '#fff', hi: '#FFD60A', upper: true, mode: 'typewriter', wpc: 5, box: false },
  'punch': { size: 82, y: 0.52, base: '#fff', hi: '#fff', upper: true, mode: 'karaoke', wpc: 3, box: false, emph: '#30e678', emphScale: 1.26 },
}
const chunkArr = (a, n) => { const o = []; for (let i = 0; i < a.length; i += n) o.push(a.slice(i, i + n)); return o }

// Mirror of core/captions._is_emphatic so the preview matches the render.
const POWER_WORDS = new Set(['best', 'worst', 'never', 'always', 'free', 'now', 'new',
  'secret', 'proven', 'instantly', 'stop', 'every', 'everything', 'nothing', 'huge',
  'massive', 'biggest', 'most', 'first', 'last', 'only', 'real', 'truth', 'fast',
  'easy', 'more', 'less', 'double', 'triple', 'million', 'billion', 'won', 'win',
  'lost', 'died', 'crazy', 'insane', 'shocking', 'wild', 'guaranteed'])
const isEmphatic = (raw) => {
  if (raw.includes('*')) return true
  const core = raw.replace(/[^A-Za-z0-9%$]/g, '')
  if (!core) return false
  if (/[0-9]/.test(core)) return true
  if (core.length > 1 && core === core.toUpperCase()) return true
  return POWER_WORDS.has(core.toLowerCase())
}

// Approximate a transition (progress p, 0→1) as CSS for the outgoing/incoming
// clip layers + an optional color flash overlay. Mirrors the ffmpeg xfade feel.
function transitionStyles(type, p) {
  const out = {}, inc = {}; let overlay = null
  const pctv = (v) => `${v}%`
  switch (type) {
    case 'slideleft': inc.transform = `translateX(${(1 - p) * 100}%)`; break
    case 'slideright': inc.transform = `translateX(${-(1 - p) * 100}%)`; break
    case 'slideup': inc.transform = `translateY(${(1 - p) * 100}%)`; break
    case 'slidedown': inc.transform = `translateY(${-(1 - p) * 100}%)`; break
    case 'wipeleft': inc.clipPath = `inset(0 ${pctv((1 - p) * 100)} 0 0)`; break
    case 'wiperight': inc.clipPath = `inset(0 0 0 ${pctv((1 - p) * 100)})`; break
    case 'wipeup': inc.clipPath = `inset(${pctv((1 - p) * 100)} 0 0 0)`; break
    case 'wipedown': inc.clipPath = `inset(0 0 ${pctv((1 - p) * 100)} 0)`; break
    case 'circleopen': inc.clipPath = `circle(${p * 75}% at 50% 50%)`; break
    case 'circleclose': out.clipPath = `circle(${(1 - p) * 75}% at 50% 50%)`; break
    case 'zoomin': inc.opacity = p; inc.transform = `scale(${0.3 + 0.7 * p})`; out.opacity = 1 - p; break
    case 'fadeblack': overlay = { background: '#000', opacity: 1 - Math.abs(2 * p - 1) }; out.opacity = p < 0.5 ? 1 : 0; inc.opacity = p < 0.5 ? 0 : 1; break
    case 'fadewhite': overlay = { background: '#fff', opacity: 1 - Math.abs(2 * p - 1) }; out.opacity = p < 0.5 ? 1 : 0; inc.opacity = p < 0.5 ? 0 : 1; break
    default: out.opacity = 1 - p; inc.opacity = p // fade / dissolve / radial / pixelize / smooth
  }
  return { out, inc, overlay }
}

// Which words show (and which is highlighted) for a line at local time, per preset.
function captionWords(line, localT, p) {
  const wt = line?.word_timings || []
  if (!wt.length || p.mode === 'static') {
    const words = (line?.text || '').split(/\s+/).filter(Boolean)
    if (!words.length) return null
    const chunks = chunkArr(words, p.wpc)
    const per = (line.duration_sec || 1) / chunks.length
    const ci = Math.min(chunks.length - 1, Math.max(0, Math.floor(localT / per)))
    return chunks[ci].map((w) => ({ text: w.replace(/\*/g, ''), hi: false, em: isEmphatic(w) }))
  }
  const chunks = chunkArr(wt, p.wpc)
  for (let ci = 0; ci < chunks.length; ci++) {
    const ch = chunks[ci]
    const chStart = ch[0].start
    const chEnd = ci + 1 < chunks.length ? chunks[ci + 1][0].start : (line.duration_sec || ch[ch.length - 1].end)
    if (localT >= chStart && localT < chEnd) {
      let cw = 0
      for (let wi = 0; wi < ch.length; wi++) if (localT >= ch[wi].start) cw = wi
      const list = p.mode === 'typewriter' ? ch.slice(0, cw + 1) : ch
      return list.map((w, wi) => ({ text: w.word.replace(/\*/g, ''), hi: wi === cw, em: isEmphatic(w.word) }))
    }
  }
  return null
}
const TRANSITION_TYPES = ['none', 'fade', 'fadeblack', 'fadewhite', 'dissolve',
  'slideleft', 'slideright', 'slideup', 'slidedown', 'wipeleft', 'wiperight',
  'wipeup', 'wipedown', 'circleopen', 'circleclose', 'radial', 'smoothleft',
  'smoothright', 'zoomin', 'pixelize']
const LOOK_TYPES = ['none', 'fisheye', 'vignette', 'grain', 'bw', 'sepia', 'vintage',
  'warm', 'cold', 'vibrant', 'blur', 'dreamy', 'vhs', 'sharpen']
// CSS approximations for the live preview ('' = render-only, can't do in CSS).
const LOOK_CSS = {
  bw: 'grayscale(1)', sepia: 'sepia(0.85)', vintage: 'sepia(0.4) contrast(0.9) saturate(0.8)',
  warm: 'saturate(1.15) sepia(0.2) hue-rotate(-8deg)', cold: 'saturate(1.1) hue-rotate(10deg) brightness(1.02)',
  vibrant: 'saturate(1.6) contrast(1.1)', blur: 'blur(3px)', dreamy: 'blur(1.5px) brightness(1.05) saturate(1.2)',
  vhs: 'saturate(1.3) contrast(1.1)', sharpen: 'contrast(1.12)',
}
const BLEND_MODES = ['screen', 'overlay', 'lighten', 'addition', 'softlight']

export default function EffectsStep({ reelData, refreshReel, projectId, reelId, setStep }) {
  const base = `/api/projects/${projectId}/reels/${reelId}`
  const [autoBusy, setAutoBusy] = useState(false)
  const [autoMsg, setAutoMsg] = useState('')
  const [autoPrompt, setAutoPrompt] = useState(null)  // null = backend default
  const autoTriedRef = useRef(false)
  const [overlays, setOverlays] = useState([])
  const [effects, setEffects] = useState([])
  const [cues, setCues] = useState([])
  const [transitions, setTransitions] = useState([]) // [{after, type}]
  const [transDur, setTransDur] = useState(0.4)
  const [captionPreset, setCaptionPreset] = useState('bold-center-karaoke')
  const [captionsOn, setCaptionsOn] = useState(true)
  const [captionHidden, setCaptionHidden] = useState([]) // line indices with captions off
  const [voiceMuted, setVoiceMuted] = useState([])       // line indices with narration muted
  const [clipOrder, setClipOrder] = useState([])   // line indices in display order
  const [clipDisabled, setClipDisabled] = useState([])
  const [clipStarts, setClipStarts] = useState({}) // {lineIndex: seconds into source clip}
  const [rawScrub, setRawScrub] = useState(null)   // {idx, t}: preview a raw position anywhere in the source clip
  const [vidDur, setVidDur] = useState({})         // real clip lengths read from the <video> (truth over stored metadata)
  const [reframes, setReframes] = useState({})     // {lineIndex: [{t,x,y,zoom}]} pan/zoom keyframes (t = secs into clip)
  const [reframeOn, setReframeOn] = useState(false) // reframe-edit mode (shows the draggable focus dot)
  const [holds, setHolds] = useState({})       // {lineIndex: extra seconds held after the voice}
  const [speeds, setSpeeds] = useState({})     // {lineIndex: {speed, freeze}} slow-mo / freeze-frame
  const [clipLooks, setClipLooks] = useState([]) // [{line, look}]
  const [timedLooks, setTimedLooks] = useState([]) // [{id, start, end, look}] time-ranged looks
  const [atmo, setAtmo] = useState({ file: '', url: '', blend: 'screen', opacity: 0.7 })
  const [atmoBusy, setAtmoBusy] = useState(false)
  const atmoInput = useRef(null)
  const atmoRef = useRef(null)
  const [musicItems, setMusicItems] = useState([])
  const auditionRef = useRef(null)
  const [auditionUrl, setAuditionUrl] = useState('')
  const [selId, setSelId] = useState(null)
  const [selClip, setSelClip] = useState(null)  // which timeline clip is armed for trimming
  const [warpOn, setWarpOn] = useState(false)   // show draggable corner-pin handles on a shape
  const [guides, setGuides] = useState({ x: false, y: false })  // alignment guide lines while dragging
  const [time, setTime] = useState(0)
  const [playing, setPlaying] = useState(false)
  const [dirty, setDirty] = useState(false)
  const [error, setError] = useState('')

  const canvasRef = useRef(null)
  const bgARef = useRef(null)  // outgoing / single clip
  const bgBRef = useRef(null)  // incoming clip during a transition
  const tlRef = useRef(null)
  const tlScrollRef = useRef(null)  // horizontal scroll container for long reels
  const dragRef = useRef(null)       // canvas overlay drag
  const reDragRef = useRef(false)    // canvas reframe focus-dot drag
  const handleRef = useRef(null)     // overlay resize/rotate handle drag
  const tlDragRef = useRef(null)     // timeline drag {mode, id, grabT}
  const sfxInput = useRef(null)
  // playback
  const rafRef = useRef(0)
  const timersRef = useRef([])
  const audiosRef = useRef([])
  const runRef = useRef(0)  // playback run token; invalidates stale scheduled audio
  const playingRef = useRef(false)
  const rerender = useJob()

  useEffect(() => {
    setOverlays((reelData?.overlays?.overlays || []).map((o) => ({ ...o })))
    setEffects((reelData?.overlays?.effects || []).map((e) => ({ ...e })))
    setTransitions((reelData?.overlays?.transitions || []).map((t) => ({ ...t })))
    setTransDur(reelData?.overlays?.transition_duration ?? 0.4)
    setCaptionPreset(reelData?.overlays?.caption_preset || 'bold-center-karaoke')
    setCaptionsOn(reelData?.overlays?.captions_on !== false)
    setCaptionHidden(reelData?.overlays?.caption_hidden || [])
    setVoiceMuted(reelData?.overlays?.voice_muted || [])
    const natural = (reelData?.voice?.lines || []).map((l) => l.index)
    const savedOrder = reelData?.overlays?.clip_order || []
    // ensure the order contains every line (append any missing at the end)
    const fullOrder = [...savedOrder.filter((i) => natural.includes(i)), ...natural.filter((i) => !savedOrder.includes(i))]
    setClipOrder(fullOrder.length ? fullOrder : natural)
    setClipDisabled(reelData?.overlays?.clip_disabled || [])
    setClipLooks((reelData?.overlays?.clip_looks || []).map((c) => ({ ...c })))
    setTimedLooks((reelData?.overlays?.timed_looks || []).map((t) => ({ ...t })))
    const a = reelData?.overlays?.atmosphere
    if (a) setAtmo({ file: a.file || '', url: a.url || '', blend: a.blend || 'screen', opacity: a.opacity ?? 0.7 })
    setCues((reelData?.sfx?.cues || []).map((c) => ({ ...c })))
    setDirty(false)
  }, [reelData?.overlays, reelData?.sfx])

  // Per-clip start offset (trim) lives in the media manifest — load it here so
  // the trim slider and live preview reflect the saved value.
  useEffect(() => {
    const m = {}
    const r = {}
    const e = {}
    const sp = {}
    for (const sm of (reelData?.media?.scenes || [])) {
      if (sm.clip_start) m[sm.line_index] = sm.clip_start
      if (sm.reframe?.length) r[sm.line_index] = sm.reframe.map((k) => ({ ...k }))
      if (sm.extend) e[sm.line_index] = sm.extend
      if ((sm.speed && sm.speed !== 1) || sm.freeze) sp[sm.line_index] = { speed: sm.speed ?? 1, freeze: sm.freeze ?? 0 }
    }
    setClipStarts(m)
    setReframes(r)
    setHolds(e)
    setSpeeds(sp)
  }, [reelData?.media])

  // Interpolate the pan/zoom framing at a scene-relative time (secs into the clip).
  const frameAt = (kfs, lt) => {
    if (!kfs || !kfs.length) return { x: 0.5, y: 0.5, zoom: 1 }
    const s = [...kfs].sort((a, b) => a.t - b.t)
    if (lt <= s[0].t) return { x: s[0].x, y: s[0].y, zoom: s[0].zoom || 1 }
    const last = s[s.length - 1]
    if (lt >= last.t) return { x: last.x, y: last.y, zoom: last.zoom || 1 }
    for (let i = 0; i < s.length - 1; i++) {
      const a = s[i], b = s[i + 1]
      if (lt >= a.t && lt <= b.t) {
        let f = (lt - a.t) / ((b.t - a.t) || 1)
        if (a.ease || b.ease) f = f * f * (3 - 2 * f)  // smoothstep — match the render
        return { x: a.x + (b.x - a.x) * f, y: a.y + (b.y - a.y) * f, zoom: (a.zoom || 1) + ((b.zoom || 1) - (a.zoom || 1)) * f }
      }
    }
    return { x: 0.5, y: 0.5, zoom: 1 }
  }

  const transTypeFor = (boundary) => transitions.find((t) => t.after === boundary)?.type || 'none'
  const setTransition = (boundary, type) => {
    const rest = transitions.filter((t) => t.after !== boundary)
    setTransitions(type === 'none' ? rest : [...rest, { after: boundary, type }])
    setDirty(true)
  }

  // Scene timeline in the chosen clip order (disabled clips excluded), with the
  // chosen clip per scene — drives the preview, lanes, and playback.
  const lineByIndex = {}
  for (const ln of (reelData?.voice?.lines || [])) lineByIndex[ln.index] = ln
  const mediaByLine = {}
  for (const sm of (reelData?.media?.scenes || [])) mediaByLine[sm.line_index] = sm
  const scenes = []
  {
    const order = clipOrder.length ? clipOrder : Object.keys(lineByIndex).map(Number)
    let acc = 0
    for (const idx of order) {
      const ln = lineByIndex[idx]
      if (!ln || clipDisabled.includes(idx)) continue
      const sm = mediaByLine[idx]
      const cand = sm ? sm.candidates[sm.selected] : null
      const ext = holds[idx] ?? (sm?.extend || 0)
      const dur = Math.max(0.3, ln.duration_sec + ext)
      const sp = speeds[idx] || { speed: sm?.speed ?? 1, freeze: sm?.freeze ?? 0 }
      scenes.push({ index: idx, start: acc, end: acc + dur, text: ln.text,
                    clipUrl: cand?.local_url || '', voiceUrl: ln.url || '', line: ln,
                    clipDur: cand?.duration_sec || 0, lineDur: ln.duration_sec, extend: ext, dur,
                    speed: sp.speed || 1, freeze: sp.freeze || 0,
                    clipStart: clipStarts[idx] ?? (sm?.clip_start || 0) })
      acc += dur
    }
  }
  const total = scenes.length ? scenes[scenes.length - 1].end : (reelData?.voice?.total_duration_sec || 30)
  const curScene = scenes.find((s) => time >= s.start && time < s.end) || scenes[scenes.length - 1] || null

  // Current pan/zoom framing for the live preview (interpolated at the playhead).
  const curReframe = curScene ? (reframes[curScene.index] || []) : []
  const curFrame = frameAt(curReframe, curScene ? time - curScene.start : 0)
  // Upsert a framing keyframe at the playhead. Unset fields inherit the current
  // interpolated framing so nudging one axis doesn't reset the others.
  const setFrameKey = (patch, { allZoom = false } = {}) => {
    if (!curScene) return
    const idx = curScene.index
    const t = Math.max(0, round(time - curScene.start))
    setReframes((prev) => {
      const list = [...(prev[idx] || [])]
      const here = frameAt(list, t)
      const i = list.findIndex((k) => Math.abs(k.t - t) < 0.06)
      const merged = { t, x: here.x, y: here.y, zoom: here.zoom, ...patch }
      if (i === -1) list.push(merged); else list[i] = { ...list[i], ...patch }
      let out = list.sort((a, b) => a.t - b.t)
      if (allZoom && patch.zoom != null) out = out.map((k) => ({ ...k, zoom: patch.zoom }))
      return { ...prev, [idx]: out }
    })
    setDirty(true)
  }
  const delFrameKey = (idx, t) => {
    setReframes((prev) => ({ ...prev, [idx]: (prev[idx] || []).filter((k) => k.t !== t) }))
    setDirty(true)
  }
  const clearReframe = (idx) => { setReframes((prev) => ({ ...prev, [idx]: [] })); setDirty(true) }

  // Clip arrangement helpers (non-destructive; reuses existing voice/media).
  const moveClip = (pos, dir) => {
    const a = [...clipOrder]; const j = pos + dir
    if (j < 0 || j >= a.length) return
    ;[a[pos], a[j]] = [a[j], a[pos]]; setClipOrder(a); setDirty(true)
  }
  const toggleClipEnabled = (idx) => {
    setClipDisabled(clipDisabled.includes(idx) ? clipDisabled.filter((i) => i !== idx) : [...clipDisabled, idx])
    setDirty(true)
  }
  const toggleClipVoice = (idx) => {
    setVoiceMuted(voiceMuted.includes(idx) ? voiceMuted.filter((i) => i !== idx) : [...voiceMuted, idx])
    setDirty(true)
  }
  // Trim: where in the source clip this scene's slice begins. Updating it live
  // re-seeks the preview (the bg <video> currentTime tracks scene.clipStart).
  const setClipTrim = (idx, start) => {
    setClipStarts((s) => ({ ...s, [idx]: start })); setDirty(true)
  }
  // Slow-mo / fast / freeze-frame for a clip. ``base`` seeds from the scene if unset.
  const setClipSpeed = (idx, patch, base) => {
    setSpeeds((p) => ({ ...p, [idx]: { speed: 1, freeze: 0, ...(base || {}), ...(p[idx] || {}), ...patch } }))
    setDirty(true)
  }

  // Is the playhead inside a transition window? Returns the two clips + progress.
  const bgPlan = (() => {
    for (let i = 0; i < scenes.length - 1; i++) {
      const type = transTypeFor(i)
      if (type === 'none') continue
      const b = scenes[i].end, half = transDur / 2
      if (time >= b - half && time < b + half) {
        return { type, p: (time - (b - half)) / transDur, out: scenes[i], inc: scenes[i + 1] }
      }
    }
    return { single: curScene }
  })()

  // Record the TRUE clip length as each bg video loads — stored API metadata is
  // sometimes wrong (e.g. says 12s for a 20s clip), so the slider must use this.
  const onVidMeta = (e) => {
    const d = e.target.duration
    if (d && isFinite(d)) setVidDur((m) => (m[e.target.src] === d ? m : { ...m, [e.target.src]: d }))
  }
  const realClipDur = (url) => {
    if (!url) return 0
    for (const k of Object.keys(vidDur)) if (k === url || k.endsWith(url)) return vidDur[k]
    return 0
  }

  // Map reel time -> source-clip time for a scene, honoring freeze + speed.
  const clipTimeAt = (sc, outT) => (sc.clipStart || 0) + Math.max(0, (outT - sc.start) - (sc.freeze || 0)) * (sc.speed || 1)
  // Keep both clip layers synced to the playhead.
  useEffect(() => {
    const setSrcTime = (el, scene) => {
      if (!el || !scene?.clipUrl) return
      if (!el.src.endsWith(scene.clipUrl)) {
        el.src = scene.clipUrl
        el.onloadedmetadata = () => { try { el.currentTime = clipTimeAt(scene, time) } catch { /* */ } }
        if (playing) el.play().catch(() => {})  // resume after the reload, or it freezes
      }
      el.playbackRate = scene.speed || 1
      if (!playing) {
        // While scrubbing this clip, show the raw source position (the WHOLE clip,
        // including the tail past the slice); otherwise show the slice's frame.
        const raw = rawScrub && rawScrub.idx === scene.index
        const t = raw ? rawScrub.t : clipTimeAt(scene, time)
        try { el.currentTime = Math.max(0, t) } catch { /* */ }
      } else if ((scene.freeze || 0) > 0) {
        // Freeze window: hold the start frame; resume after it.
        const inFreeze = (time - scene.start) < scene.freeze
        if (inFreeze) { try { el.currentTime = scene.clipStart || 0 } catch { /* */ }; if (!el.paused) el.pause() }
        else if (el.paused) el.play().catch(() => {})
      }
    }
    if (bgPlan.single) setSrcTime(bgARef.current, bgPlan.single)
    else { setSrcTime(bgARef.current, bgPlan.out); setSrcTime(bgBRef.current, bgPlan.inc) }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [time, playing, rawScrub, bgPlan.single?.clipUrl, bgPlan.out?.clipUrl, bgPlan.inc?.clipUrl,
      bgPlan.single?.clipStart, bgPlan.out?.clipStart, bgPlan.inc?.clipStart,
      bgPlan.single?.speed, bgPlan.single?.freeze])

  const sel = overlays.find((o) => o.id === selId) || null
  const mutate = (fn) => { const next = structuredClone(overlays); fn(next); setOverlays(next); setDirty(true) }

  const addOverlay = (type, opts = {}) => {
    const ring = type === 'circle' || type === 'box'
    const o = {
      id: newId(), type: type === 'type' ? 'text' : type,
      text: type === 'text' ? 'GOAL!' : type === 'type' ? 'GOOOAL!' : type === 'card' ? 'PLAYER' : '',
      emoji: type === 'emoji' ? '🔥' : '',
      color: (type === 'arrow' || ring) ? '#FF3B30' : type === 'flash' ? '#FFFFFF' : '#FFD60A',
      size: type === 'arrow' ? 200 : ring ? 180 : 100, bend: 0, aspect: 1,
      fill: false, fillOpacity: 0.35, warp: [],
      typewriter: type === 'type',
      start: round(time), end: round(Math.min(total, time + (type === 'flash' ? 0.4 : 4))),
      keyframes: type === 'flash'
        ? [{ ...blankKf(round(time)), opacity: 0 }, { ...blankKf(round(time + 0.15)), opacity: 0.85 }, { ...blankKf(round(time + 0.4)), opacity: 0 }]
        : [{ ...blankKf(round(time)) }],
      ...opts,
    }
    setOverlays([...overlays, o]); setSelId(o.id); setDirty(true)
    return o
  }
  const removeOverlay = (id) => { setOverlays(overlays.filter((o) => o.id !== id)); if (selId === id) setSelId(null); setDirty(true) }
  // Typed text + a matching typing sound, in one click.
  const [typeBusy, setTypeBusy] = useState(false)
  const addTypedText = async () => {
    const o = addOverlay('type')
    setTypeBusy(true); setError('')
    try {
      const s = await api.post(`${base}/sfx/typewriter`, { start: o.start, duration: o.end - o.start, chars: (o.text || '').length })
      setCues(s.cues || [])
    } catch (e) { setError(`Added the text; typing sound failed: ${e.message}`) } finally { setTypeBusy(false) }
  }

  const addEffect = (type) => { setEffects([...effects, { id: newId(), type, start: round(time), end: round(Math.min(total, time + 2)), intensity: 0.6 }]); setDirty(true) }
  const editEffect = (i, f, v) => { setEffects(effects.map((e, j) => (j === i ? { ...e, [f]: v } : e))); setDirty(true) }
  const delEffect = (i) => { setEffects(effects.filter((_, j) => j !== i)); setDirty(true) }

  function round(n) { return Number(Number(n).toFixed(2)) }
  const fmt = (n) => Number(n).toFixed(1)

  // ---- canvas overlay drag (position via nearest keyframe) ----
  const onDown = (e, o) => { e.preventDefault(); setSelId(o.id); dragRef.current = o.id }
  // Resize/rotate handle on the selected overlay. ``mode`` is 'resize' | 'rotate'.
  const onHandleDown = (e, o, mode) => {
    e.preventDefault(); e.stopPropagation(); setSelId(o.id)
    const k = interp(o.keyframes, time)
    const r = canvasRef.current.getBoundingClientRect()
    const pivotX = r.left + k.x * PW, pivotY = r.top + k.y * PH
    const dx = e.clientX - pivotX, dy = e.clientY - pivotY
    handleRef.current = { mode, id: o.id, pivotX, pivotY,
      startDist: Math.hypot(dx, dy) || 1, startSize: o.size || 100,
      startAngle: Math.atan2(dy, dx), startRot: k.rotation || 0 }
  }
  // Drag a shape's corner to warp it (corner-pin perspective).
  const onWarpDown = (e, o, idx) => {
    e.preventDefault(); e.stopPropagation(); setSelId(o.id)
    const k = interp(o.keyframes, time)
    const r = canvasRef.current.getBoundingClientRect()
    const w = o.size * SCALE, h = w / (o.aspect || 1)
    handleRef.current = { mode: 'warp', id: o.id, idx, w, h, scale: k.scale || 1, cx: r.left + k.x * PW, cy: r.top + k.y * PH }
  }
  const kfNearTime = (o) => {  // the keyframe closest to the playhead (drag target)
    let k = o.keyframes[0], best = Infinity
    for (const kf of o.keyframes) { const d = Math.abs(kf.t - time); if (d < best) { best = d; k = kf } }
    return k
  }
  const onMove = (e) => {
    if (handleRef.current) {
      const h = handleRef.current
      if (h.mode === 'warp') {
        const lx = (e.clientX - h.cx) / h.scale + h.w / 2, ly = (e.clientY - h.cy) / h.scale + h.h / 2
        const def = [[0, 0], [h.w, 0], [h.w, h.h], [0, h.h]][h.idx]
        mutate((next) => {
          const o = next.find((v) => v.id === h.id)
          const wp = (o.warp && o.warp.length >= 4) ? o.warp.map((c) => [...c]) : [[0, 0], [0, 0], [0, 0], [0, 0]]
          wp[h.idx] = [(lx - def[0]) / h.w, (ly - def[1]) / h.h]
          o.warp = wp
        })
        return
      }
      const dx = e.clientX - h.pivotX, dy = e.clientY - h.pivotY
      if (h.mode === 'resize') {
        const ns = Math.max(20, Math.min(400, Math.round(h.startSize * (Math.hypot(dx, dy) / h.startDist))))
        setOverlays((prev) => prev.map((o) => (o.id === h.id ? { ...o, size: ns } : o))); setDirty(true)
      } else {
        const deg = Math.round(h.startRot + (Math.atan2(dy, dx) - h.startAngle) * 180 / Math.PI)
        mutate((next) => { const o = next.find((v) => v.id === h.id); if (o?.keyframes.length) kfNearTime(o).rotation = deg })
      }
      return
    }
    if (!dragRef.current && !reDragRef.current) return
    const r = canvasRef.current.getBoundingClientRect()
    const x = Math.max(0, Math.min((e.clientX - r.left) / r.width, 1))
    const y = Math.max(0, Math.min((e.clientY - r.top) / r.height, 1))
    if (reDragRef.current) { setFrameKey({ x, y }); return }
    // Snap to canvas centre lines and show guides.
    const gx = Math.abs(x - 0.5) < 0.02, gy = Math.abs(y - 0.5) < 0.02
    setGuides({ x: gx, y: gy })
    mutate((next) => {
      const o = next.find((v) => v.id === dragRef.current)
      if (!o?.keyframes.length) return
      const k = kfNearTime(o)
      k.x = gx ? 0.5 : x; k.y = gy ? 0.5 : y
    })
  }
  const onUp = () => { dragRef.current = null; reDragRef.current = false; handleRef.current = null; setGuides({ x: false, y: false }) }

  const addKeyframe = () => {
    if (!sel) return
    mutate((next) => {
      const o = next.find((v) => v.id === sel.id)
      const cur = interp(o.keyframes, time)
      o.keyframes = [...o.keyframes.filter((k) => Math.abs(k.t - time) > 0.05), { ...cur, t: round(time) }].sort((a, b) => a.t - b.t)
    })
  }
  const editSel = (f, v) => mutate((next) => { next.find((o) => o.id === sel.id)[f] = v })
  // Auto-track: follow a player from the playhead. Places motion keyframes for you.
  const [trackBusy, setTrackBusy] = useState(false)
  const trackOverlay = async () => {
    if (!sel || !curScene) return
    const startOff = Math.max(0, time - curScene.start)
    const pos = interp(sel.keyframes, time)  // where you put it now = the player
    setTrackBusy(true); setError('')
    try {
      const box = { x: Math.min(Math.max(pos.x, 0), 1), y: Math.min(Math.max(pos.y, 0), 1), w: 0.14, h: 0.2 }
      const r = await api.post(`${base}/track`, { line_index: curScene.index, box, start_offset: round(startOff) })
      const ks = r.keyframes || []
      if (ks.length < 2) { setError('Couldn’t lock onto anything — place the card right on the player and try again.'); return }
      mutate((next) => {
        const o = next.find((v) => v.id === sel.id)
        // Keep any keyframes before the playhead; replace the rest with the tracked path.
        const kept = o.keyframes.filter((k) => k.t < time - 0.05)
        const tracked = ks.map((k) => ({ t: round(time + k.t), x: k.x, y: k.y, scale: pos.scale ?? 1, rotation: pos.rotation ?? 0, opacity: pos.opacity ?? 1 }))
        o.keyframes = [...kept, ...tracked].sort((a, b) => a.t - b.t)
        o.start = Math.min(o.start, round(time))
        o.end = Math.max(o.end, round(curScene.end))
      })
    } catch (e) { setError(e.message) } finally { setTrackBusy(false) }
  }
  // Anchored tracking: use the overlay's keyframes (that you placed on the player)
  // as correction points and track the gaps with drift correction — robust in crowds.
  const refineTrack = async () => {
    if (!sel || !curScene) return
    const sc = curScene
    const anchors = sel.keyframes
      .filter((k) => k.t >= sc.start - 1e-3 && k.t <= sc.end + 1e-3)
      .map((k) => { const p = interp(sel.keyframes, k.t); return { t: round(k.t - sc.start), x: p.x, y: p.y } })
      .sort((a, b) => a.t - b.t)
    if (anchors.length < 2) { setError('Place the card on the player at 2+ points (move the playhead, drag it on him, repeat), then Refine.'); return }
    const pos = interp(sel.keyframes, sc.start)
    setTrackBusy(true); setError('')
    try {
      const r = await api.post(`${base}/track`, { line_index: sc.index, box: { x: anchors[0].x, y: anchors[0].y, w: 0.1, h: 0.14 }, anchors })
      const ks = r.keyframes || []
      if (ks.length < 2) { setError('Tracking came up empty — try anchors closer together.'); return }
      mutate((next) => {
        const o = next.find((v) => v.id === sel.id)
        const outside = o.keyframes.filter((k) => k.t < sc.start - 1e-3 || k.t > sc.end + 1e-3)
        const tracked = ks.map((k) => ({ t: round(sc.start + k.t), x: k.x, y: k.y, scale: pos.scale ?? 1, rotation: pos.rotation ?? 0, opacity: pos.opacity ?? 1 }))
        o.keyframes = [...outside, ...tracked].sort((a, b) => a.t - b.t)
      })
    } catch (e) { setError(e.message) } finally { setTrackBusy(false) }
  }
  const editKf = (i, f, v) => mutate((next) => { next.find((o) => o.id === sel.id).keyframes[i][f] = v })
  const delKf = (i) => mutate((next) => { const o = next.find((v) => v.id === sel.id); if (o.keyframes.length > 1) o.keyframes.splice(i, 1) })

  // ---- timeline interactions ----
  const timeAt = (clientX) => {
    const r = tlRef.current.getBoundingClientRect()
    return Math.max(0, Math.min((clientX - r.left) / r.width, 1)) * total
  }
  const tlDown = (e) => { stopPlayback(); setRawScrub(null); setSelClip(null); tlDragRef.current = { mode: 'playhead' }; setTime(round(timeAt(e.clientX))) }
  const barDown = (e, kind, id) => {
    e.stopPropagation(); stopPlayback()
    const item = kind === 'overlay' ? overlays.find((o) => o.id === id) : kind === 'effect' ? effects.find((x) => x.id === id) : kind === 'timedlook' ? timedLooks.find((t) => t.id === id) : cues.find((c) => c.id === id)
    tlDragRef.current = { mode: 'bar', kind, id, grabT: timeAt(e.clientX) - (item?.start || 0) }
    if (kind === 'overlay') setSelId(id)
  }
  // Drag an overlay/effect bar's left or right edge to trim it.
  const edgeDown = (e, kind, id, edge) => {
    e.stopPropagation(); stopPlayback()
    tlDragRef.current = { mode: 'edge', kind, id, edge }
    if (kind === 'overlay') setSelId(id)
  }
  // Drag a clip's left (front) or right (back) edge to trim/extend it; ripple later items.
  // Right edge changes the hold (back trim/extend). Left edge skips into the source
  // (front trim) and shortens the scene. Both keep scenes gap-free by rippling.
  const clipTrimDown = (e, scene, edge) => {
    e.stopPropagation(); stopPlayback()
    tlDragRef.current = { mode: 'cliptrim', idx: scene.index, edge, sceneStart: scene.start,
      lineDur: scene.lineDur, cs0: scene.clipStart, ext0: scene.extend,
      clipLen: realClipDur(scene.clipUrl) || scene.clipDur || 0, lastDur: scene.dur }
  }
  const tlMove = (e) => {
    const d = tlDragRef.current
    if (!d) return
    if (d.mode === 'playhead') { setTime(round(timeAt(e.clientX))); return }
    if (d.mode === 'cliptrim') {
      let newDur, newExt, newCs = d.cs0
      if (d.edge === 'r') {
        newDur = Math.max(0.3, round(timeAt(e.clientX) - d.sceneStart))           // back edge sets total length
        newExt = round(newDur - d.lineDur)
      } else {
        // Front edge: how far we drag in from the left = how much of the source we skip.
        const maxTrim = (d.lineDur + d.ext0) - 0.3
        const room = d.clipLen ? d.clipLen - d.cs0 - 0.3 : maxTrim  // can't skip past the clip's end
        const trim = Math.max(0, Math.min(round(timeAt(e.clientX) - d.sceneStart), maxTrim, room))
        newCs = round(d.cs0 + trim); newExt = round(d.ext0 - trim); newDur = d.lineDur + newExt
      }
      const ddur = newDur - d.lastDur
      if (ddur) {
        const after = d.sceneStart + d.lastDur - 1e-3  // ripple everything past this clip's end
        setOverlays((prev) => prev.map((o) => o.start >= after
          ? { ...o, start: round(o.start + ddur), end: round(o.end + ddur), keyframes: o.keyframes.map((k) => ({ ...k, t: round(k.t + ddur) })) } : o))
        setEffects((prev) => prev.map((x) => x.start >= after ? { ...x, start: round(x.start + ddur), end: round(x.end + ddur) } : x))
        setCues((prev) => prev.map((c) => c.start >= after ? { ...c, start: round(c.start + ddur) } : c))
        d.lastDur = newDur
      }
      setHolds((prev) => ({ ...prev, [d.idx]: newExt }))
      if (d.edge === 'l') setClipStarts((prev) => ({ ...prev, [d.idx]: newCs }))
      setDirty(true)
      return
    }
    if (d.mode === 'edge') {
      const t = round(timeAt(e.clientX))
      if (d.kind === 'overlay') {
        mutate((next) => {
          const o = next.find((v) => v.id === d.id)
          if (d.edge === 'l') o.start = Math.min(t, o.end - 0.2); else o.end = Math.max(t, o.start + 0.2)
        })
      } else if (d.kind === 'effect') {
        setEffects(effects.map((x) => {
          if (x.id !== d.id) return x
          return d.edge === 'l' ? { ...x, start: Math.min(t, x.end - 0.2) } : { ...x, end: Math.max(t, x.start + 0.2) }
        })); setDirty(true)
      } else if (d.kind === 'timedlook') {
        setTimedLooks(timedLooks.map((x) => {
          if (x.id !== d.id) return x
          return d.edge === 'l' ? { ...x, start: Math.min(t, x.end - 0.2) } : { ...x, end: Math.max(t, x.start + 0.2) }
        })); setDirty(true)
      }
      return
    }
    const nt = Math.max(0, round(timeAt(e.clientX) - d.grabT))
    if (d.kind === 'overlay') {
      mutate((next) => {
        const o = next.find((v) => v.id === d.id); const span = o.end - o.start; const delta = nt - o.start
        o.start = nt; o.end = nt + span; o.keyframes = o.keyframes.map((k) => ({ ...k, t: round(k.t + delta) }))
      })
    } else if (d.kind === 'effect') {
      setEffects(effects.map((x) => x.id === d.id ? { ...x, end: round(nt + (x.end - x.start)), start: nt } : x)); setDirty(true)
    } else if (d.kind === 'timedlook') {
      setTimedLooks(timedLooks.map((x) => x.id === d.id ? { ...x, end: round(nt + (x.end - x.start)), start: nt } : x)); setDirty(true)
    } else {
      setCues(cues.map((c) => c.id === d.id ? { ...c, start: nt } : c)); setDirty(true)
    }
  }
  const tlUp = () => { tlDragRef.current = null }

  // ---- live preview playback ----
  const killAudio = (a) => {
    // Aggressive stop: pause() alone can be ignored if play() is still
    // resolving, so also mute and detach the source.
    try { a.muted = true; a.pause(); a.currentTime = 0; a.src = ''; a.load?.() } catch { /* */ }
  }
  const stopPlayback = () => {
    runRef.current++  // invalidate any pending scheduled audio from this run
    playingRef.current = false
    cancelAnimationFrame(rafRef.current)
    timersRef.current.forEach(clearTimeout); timersRef.current = []
    audiosRef.current.forEach(killAudio); audiosRef.current = []
    bgARef.current?.pause(); bgBRef.current?.pause(); atmoRef.current?.pause()
    setPlaying(false)
  }
  const play = () => {
    stopPlayback()  // clean slate — cancel any lingering rAF/timers/audio first
    setRawScrub(null)  // leave clip-scrub mode so playback isn't pinned to one frame
    const run = runRef.current  // this playback's token
    const t0 = time >= total - 0.05 ? 0 : time
    playingRef.current = true
    setPlaying(true)
    const wall = performance.now() - t0 * 1000
    const startAudio = (url, vol, seek = 0) => {
      if (runRef.current !== run) return  // stopped — don't start
      const a = new Audio(url); a.volume = Math.min(vol, 1)
      if (seek > 0) { try { a.currentTime = seek } catch { /* */ } }
      a.play().catch(() => {}); audiosRef.current.push(a)
    }
    // Schedule a sound at absolute time `at`. Skip ones fully in the past;
    // resume one that's currently mid-play from the right offset.
    const sched = (url, at, vol, dur) => {
      if (!url) return
      if (at >= t0 - 0.02) {
        timersRef.current.push(setTimeout(() => startAudio(url, vol, 0), Math.max(0, (at - t0) * 1000)))
      } else if (dur && t0 < at + dur) {
        startAudio(url, vol, t0 - at)
      }
    }
    scenes.forEach((s) => { if (!voiceMuted.includes(s.index)) sched(s.voiceUrl, s.start, 1, s.end - s.start) })
    cues.forEach((c) => sched(c.url, c.start, c.volume, 0.05))  // point events
    if (media.music_url) {
      const begin = media.music_begin || 0, startOff = media.music_start || 0
      const startMusic = (reelT) => {
        if (runRef.current !== run) return
        const m = new Audio(media.music_url); m.loop = true; m.volume = Math.min(media.music_volume ?? 0.15, 1)
        try { m.currentTime = startOff + Math.max(0, reelT - begin) } catch { /* */ }
        m.play().catch(() => {}); audiosRef.current.push(m)
      }
      if (t0 >= begin) startMusic(t0)
      else timersRef.current.push(setTimeout(() => startMusic(begin), (begin - t0) * 1000))
    }
    bgARef.current?.play().catch(() => {}); bgBRef.current?.play().catch(() => {}); atmoRef.current?.play().catch(() => {})
    const tick = () => {
      const tt = (performance.now() - wall) / 1000
      if (tt >= total) { setTime(total); stopPlayback(); return }
      setTime(tt)
      rafRef.current = requestAnimationFrame(tick)
    }
    rafRef.current = requestAnimationFrame(tick)
  }
  useEffect(() => () => stopPlayback(), []) // cleanup on unmount

  const save = async () => {
    // Each part saves independently — one failing request (e.g. a missing route)
    // must never silently abort the others. Failures are collected and surfaced.
    const fails = []
    const tryDo = async (label, fn) => { try { await fn() } catch (e) { fails.push(`${label}: ${e.message}`) } }
    await tryDo('overlays', () => api.put(`${base}/overlays`, {
      overlays, effects, transitions, transition_duration: transDur,
      caption_preset: captionPreset, captions_on: captionsOn, caption_hidden: captionHidden,
      clip_looks: clipLooks, timed_looks: timedLooks, atmosphere: { file: atmo.file, blend: atmo.blend, opacity: atmo.opacity },
      clip_order: clipOrder, clip_disabled: clipDisabled, voice_muted: voiceMuted,
    }))
    await tryDo('sfx', () => api.put(`${base}/sfx`, { cues }))
    for (const [idx, start] of Object.entries(clipStarts)) await tryDo('trim', () => api.post(`${base}/media/clip-start`, { line_index: Number(idx), start }))
    for (const [idx, kfs] of Object.entries(reframes)) await tryDo('reframe', () => api.post(`${base}/media/reframe`, { line_index: Number(idx), reframe: kfs }))
    for (const [idx, ext] of Object.entries(holds)) await tryDo('hold', () => api.post(`${base}/media/extend`, { line_index: Number(idx), extend: ext }))
    for (const [idx, sp] of Object.entries(speeds)) await tryDo('speed/freeze', () => api.post(`${base}/media/speed`, { line_index: Number(idx), speed: sp.speed ?? 1, freeze: sp.freeze ?? 0 }))
    await refreshReel()
    if (fails.length) { setError(`Some edits did NOT save (try again): ${[...new Set(fails.map((f) => f.split(':')[0]))].join(', ')}`) }
    else { setDirty(false); setError('') }
  }
  const saveAndRender = async () => { await save(); setStep(6) }

  // AI auto-edit: build a first-pass edit in the channel's style.
  const autoEdit = async () => {
    setAutoBusy(true); setError(''); setAutoMsg('Designing an edit in your channel style…')
    try {
      await runJob(`${base}/overlays/auto-edit`, { prompt: autoPrompt }, (p) => setAutoMsg(p[p.length - 1] || 'Designing…'))
      await refreshReel()
    } catch (e) { setError(e.message) } finally { setAutoBusy(false); setAutoMsg('') }
  }
  // On first arrival with a blank reel, auto-edit it so the editor isn't empty.
  useEffect(() => {
    const o = reelData?.overlays
    const blank = o && !(o.overlays?.length || o.effects?.length || o.clip_looks?.length || o.transitions?.length || o.timed_looks?.length)
    if (!autoTriedRef.current && blank && reelData?.voice?.lines?.length && !autoBusy) {
      autoTriedRef.current = true
      autoEdit()
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [reelData?.overlays, reelData?.voice])

  // ---- SFX search/upload ----
  const [sfxQuery, setSfxQuery] = useState('')
  const [sfxResults, setSfxResults] = useState([])
  const [sfxBusy, setSfxBusy] = useState(false)
  const searchSfx = async () => {
    setSfxBusy(true); setError('')
    try { const r = await api.get(`/api/sfx/search?q=${encodeURIComponent(sfxQuery)}`); setSfxResults(r.results || []); if (!r.enabled) setError('Set FREESOUND_API_KEY to search SFX (or upload your own).') }
    catch (e) { setError(e.message) } finally { setSfxBusy(false) }
  }
  const placeSfx = async (result) => {
    setSfxBusy(true)
    try { const s = await api.post(`${base}/sfx/import`, { result, start: round(time) }); setCues(s.cues); setSfxResults([]) }
    catch (e) { setError(e.message) } finally { setSfxBusy(false) }
  }
  const [sfxGenText, setSfxGenText] = useState('')
  const generateSfx = async () => {
    if (!sfxGenText.trim()) return
    setSfxBusy(true); setError('')
    try { const s = await api.post(`${base}/sfx/generate`, { text: sfxGenText.trim(), start: round(time) }); setCues(s.cues); setSfxGenText('') }
    catch (e) { setError(e.message) } finally { setSfxBusy(false) }
  }
  const uploadSfx = async (file) => {
    if (!file) return
    setSfxBusy(true)
    try { const fd = new FormData(); fd.append('start', round(time)); fd.append('file', file); const s = await upload(`${base}/sfx/upload`, fd); setCues(s.cues) }
    catch (e) { setError(e.message) } finally { setSfxBusy(false) }
  }
  const delCue = (id) => { setCues(cues.filter((c) => c.id !== id)); setDirty(true) }
  const editCue = (id, f, v) => { setCues(cues.map((c) => c.id === id ? { ...c, [f]: v } : c)); setDirty(true) }

  const pct = (t) => `${(t / total) * 100}%`
  const bgTransform = motionTransform(effects, time)
  const fadeOp = fadeBlack(effects, time)  // fade-to/from-black overlay opacity
  // Keep the playhead in view as it moves (long reels scroll horizontally).
  useEffect(() => {
    const sc = tlScrollRef.current
    if (!sc || sc.scrollWidth <= sc.clientWidth) return
    const px = (Math.min(time, total) / (total || 1)) * sc.scrollWidth
    const view = sc.clientWidth
    if (px < sc.scrollLeft + 50 || px > sc.scrollLeft + view - 50) sc.scrollLeft = Math.max(0, px - view / 2)
  }, [time, total])
  // Quick-jump the controls column to a section (so you don't hunt by scrolling).
  const jumpTo = (id) => document.getElementById(id)?.scrollIntoView({ behavior: 'smooth', block: 'start' })

  // ---- Undo / redo: snapshot every editable bit so an accidental drag is recoverable ----
  const histRef = useRef({ past: [], future: [], applying: false, prev: null })
  const snapKey = JSON.stringify({
    overlays, effects, cues, transitions, transDur, captionPreset, captionsOn, captionHidden, voiceMuted,
    clipOrder, clipDisabled, clipLooks, timedLooks, atmo, reframes, holds, clipStarts,
  })
  useEffect(() => {
    const h = histRef.current
    if (h.prev == null) { h.prev = snapKey; return }              // seed on first render
    if (h.applying) { h.applying = false; h.prev = snapKey; return }  // change came from undo/redo
    h.past.push(h.prev); if (h.past.length > 100) h.past.shift()
    h.future = []
    h.prev = snapKey
    setHistN((n) => n + 1)  // refresh button enabled-state
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [snapKey])
  const [, setHistN] = useState(0)
  const applySnap = (key) => {
    const s = JSON.parse(key)
    histRef.current.applying = true
    setOverlays(s.overlays); setEffects(s.effects); setCues(s.cues)
    setTransitions(s.transitions); setTransDur(s.transDur)
    setCaptionPreset(s.captionPreset); setCaptionsOn(s.captionsOn)
    setCaptionHidden(s.captionHidden); setVoiceMuted(s.voiceMuted)
    setClipOrder(s.clipOrder); setClipDisabled(s.clipDisabled); setClipLooks(s.clipLooks)
    setTimedLooks(s.timedLooks || [])
    setAtmo(s.atmo); setReframes(s.reframes); setHolds(s.holds); setClipStarts(s.clipStarts)
    setDirty(true); setHistN((n) => n + 1)
  }
  const undo = () => { const h = histRef.current; if (!h.past.length) return; h.future.push(snapKey); applySnap(h.past.pop()) }
  const redo = () => { const h = histRef.current; if (!h.future.length) return; h.past.push(snapKey); applySnap(h.future.pop()) }
  const undoRef = useRef(undo), redoRef = useRef(redo)
  undoRef.current = undo; redoRef.current = redo
  useEffect(() => {
    const onKey = (e) => {
      const tag = (e.target.tagName || '').toLowerCase()
      if (tag === 'input' || tag === 'textarea' || tag === 'select') return
      if (e.key === 'Escape') { setSelId(null); setSelClip(null); return }  // deselect everything
      if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'z') {
        e.preventDefault()
        if (e.shiftKey) redoRef.current(); else undoRef.current()
      }
    }
    window.addEventListener('keydown', onKey)
    return () => window.removeEventListener('keydown', onKey)
  }, [])

  // Live caption for the preview (mirrors the render).
  const capPreset = CAP[captionPreset] || CAP.minimal
  const capVisible = captionsOn && curScene && !captionHidden.includes(curScene.index)
  const capWords = (capVisible && curScene?.line) ? captionWords(curScene.line, time - curScene.start, capPreset) : null
  const toggleClipCaption = (idx) => {
    setCaptionHidden(captionHidden.includes(idx) ? captionHidden.filter((i) => i !== idx) : [...captionHidden, idx])
    setDirty(true)
  }
  const lookFor = (idx) => clipLooks.find((c) => c.line === idx)?.look || 'none'
  const setLook = (idx, look) => {
    const rest = clipLooks.filter((c) => c.line !== idx)
    setClipLooks(look === 'none' ? rest : [...rest, { line: idx, look }])
    setDirty(true)
  }
  const curLook = curScene ? lookFor(curScene.index) : 'none'
  // Timed looks active at the playhead (combine with the per-clip look).
  const addTimedLook = (look) => { setTimedLooks([...timedLooks, { id: newId(), look, start: round(time), end: round(Math.min(total, time + 2)) }]); setDirty(true) }
  const editTimedLook = (id, f, v) => { setTimedLooks(timedLooks.map((t) => t.id === id ? { ...t, [f]: v } : t)); setDirty(true) }
  const delTimedLook = (id) => { setTimedLooks(timedLooks.filter((t) => t.id !== id)); setDirty(true) }
  const activeTimed = timedLooks.filter((t) => t.start <= time && time < t.end)
  const timedCss = activeTimed.map((t) => LOOK_CSS[t.look]).filter(Boolean).join(' ')
  const showVignette = curLook === 'vignette' || activeTimed.some((t) => t.look === 'vignette')
  const showGrain = curLook === 'grain' || activeTimed.some((t) => t.look === 'grain')
  const curLookCss = [LOOK_CSS[curLook] || '', timedCss].filter(Boolean).join(' ')
  // SVG film-grain noise as a data-URI for the grain look preview.
  const GRAIN_BG = `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.5'/%3E%3C/svg%3E")`

  const uploadAtmo = async (file) => {
    if (!file) return
    setAtmoBusy(true)
    try {
      const fd = new FormData(); fd.append('file', file)
      const r = await upload(`${base}/atmosphere/upload`, fd)
      const a = r.atmosphere || {}
      setAtmo({ file: a.file || '', url: a.url || '', blend: a.blend || 'screen', opacity: a.opacity ?? 0.7 })
    } catch (e) { setError(e.message) } finally { setAtmoBusy(false) }
  }
  const clearAtmo = () => { setAtmo({ file: '', url: '', blend: 'screen', opacity: 0.7 }); setDirty(true) }

  // --- music on the preview page ---
  const media = reelData?.media || {}
  useEffect(() => { api.get('/api/music').then((d) => setMusicItems(d.items || [])).catch(() => {}) }, [])
  const auditionTrack = (url) => {
    if (auditionRef.current) { auditionRef.current.pause(); auditionRef.current = null }
    if (auditionUrl === url) { setAuditionUrl(''); return }
    const a = new Audio(url); a.volume = media.music_volume ?? 0.5; a.onended = () => setAuditionUrl('')
    a.play().catch(() => {}); auditionRef.current = a; setAuditionUrl(url)
  }
  const setReelMusic = async (body) => { try { await api.put(`${base}/music`, body); await refreshReel() } catch (e) { setError(e.message) } }

  return (
    <div>
      <div className="row" style={{ alignItems: 'center', marginBottom: 8 }}>
        <h2 style={{ margin: 0 }}>Effects &amp; overlays</h2>
        <span className="muted" style={{ fontSize: 12 }}>{autoBusy ? `✨ ${autoMsg}` : '▶ Play previews live · ⌘/Ctrl+Z to undo'}</span>
        <div className="grow" />
        <button className="btn small secondary" onClick={autoEdit} disabled={autoBusy}
          title="Let AI build a first-pass edit (looks, motion, transitions, captions, callouts) in your channel's style — then tweak it">
          {autoBusy ? '✨ Editing…' : '✨ Auto-edit (channel style)'}
        </button>
        <button className="btn small secondary" onClick={undo} disabled={!histRef.current.past.length} title="Undo (⌘/Ctrl+Z)">↶ Undo</button>
        <button className="btn small secondary" onClick={redo} disabled={!histRef.current.future.length} title="Redo (⌘/Ctrl+Shift+Z)">↷ Redo</button>
        {dirty && <button className="btn small secondary" onClick={save}>Save</button>}
        <button className="btn small" onClick={saveAndRender}>Save &amp; Render →</button>
      </div>
      <ErrorBox error={error || rerender.error} />
      <PromptPanel label="auto-edit" value={autoPrompt} setValue={setAutoPrompt} disabled={autoBusy}
        fetchPrompt={async () => (await api.post(`${base}/overlays/auto-edit/prompt`, {})).prompt} />

      <div className="fx-body">
        {/* STAGE — preview stays put while you edit */}
        <div className="fx-stage">
          <div ref={canvasRef} className="fx-canvas" style={{ width: PW, height: PH, overflow: 'hidden' }}
            onMouseDown={(e) => { if (!e.target.closest('.fx-el')) setSelId(null) }}
            onMouseMove={onMove} onMouseUp={onUp} onMouseLeave={onUp}>
            {(() => {
              const layer = { position: 'absolute', inset: 0, width: '100%', height: '100%', objectFit: 'cover', transformOrigin: 'center' }
              if (bgPlan.single) {
                return <video ref={bgARef} muted playsInline preload="auto" onLoadedMetadata={onVidMeta}
                  style={{ ...layer, transform: `${bgTransform} scale(${curFrame.zoom})`,
                    objectPosition: `${curFrame.x * 100}% ${curFrame.y * 100}%`, filter: curLookCss, zIndex: 1 }} />
              }
              const ts = transitionStyles(bgPlan.type, bgPlan.p)
              return (
                <>
                  <video ref={bgARef} muted playsInline preload="auto" onLoadedMetadata={onVidMeta} style={{ ...layer, zIndex: 1, ...ts.out }} />
                  <video ref={bgBRef} muted playsInline preload="auto" onLoadedMetadata={onVidMeta} style={{ ...layer, zIndex: 2, ...ts.inc }} />
                  {ts.overlay && <div style={{ ...layer, zIndex: 3, background: ts.overlay.background, opacity: ts.overlay.opacity }} />}
                </>
              )
            })()}
            {showVignette && (
              <div style={{ position: 'absolute', inset: 0, zIndex: 4, pointerEvents: 'none',
                background: 'radial-gradient(ellipse at center, transparent 45%, rgba(0,0,0,0.65) 100%)' }} />
            )}
            {showGrain && (
              <div style={{ position: 'absolute', inset: 0, zIndex: 4, pointerEvents: 'none',
                backgroundImage: GRAIN_BG, opacity: 0.22, mixBlendMode: 'overlay' }} />
            )}
            {reframeOn && curScene && (
              <div onMouseDown={(e) => { e.preventDefault(); if (playingRef.current) stopPlayback(); reDragRef.current = true }}
                title="Drag to choose what's in frame at the playhead"
                style={{ position: 'absolute', left: curFrame.x * PW, top: curFrame.y * PH, width: 26, height: 26,
                  marginLeft: -13, marginTop: -13, border: '2px solid #fff', borderRadius: '50%', cursor: 'move',
                  boxShadow: '0 0 0 1px rgba(0,0,0,0.6), 0 0 8px rgba(0,0,0,0.5)', background: 'rgba(255,255,255,0.18)', zIndex: 13 }}>
                <div style={{ position: 'absolute', inset: '11px', borderRadius: '50%', background: '#fff' }} />
              </div>
            )}
            {atmo.url && (
              <video ref={atmoRef} src={atmo.url} muted loop playsInline
                style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', objectFit: 'cover',
                         mixBlendMode: atmo.blend === 'addition' ? 'plus-lighter' : atmo.blend,
                         opacity: atmo.opacity, zIndex: 5, pointerEvents: 'none' }} />
            )}
            {fadeOp > 0 && (
              <div style={{ position: 'absolute', inset: 0, background: '#000', opacity: fadeOp, zIndex: 6, pointerEvents: 'none' }} />
            )}
            {capWords && (
              <div style={{
                position: 'absolute', left: '5%', width: '90%', top: `${capPreset.y * 100}%`,
                transform: 'translateY(-50%)', textAlign: 'center', pointerEvents: 'none',
                fontWeight: 800, lineHeight: 1.15, zIndex: 10,
                fontSize: capPreset.size * SCALE,
                textTransform: capPreset.upper ? 'uppercase' : 'none',
              }}>
                <span style={{
                  background: capPreset.box ? 'rgba(10,10,14,0.7)' : 'transparent',
                  padding: capPreset.box ? '2px 8px' : 0, borderRadius: 8,
                  WebkitTextStroke: '1px #000',
                }}>
                  {capWords.map((w, i) => {
                    const em = capPreset.emph && w.em
                    return (
                      <span key={i} style={{
                        color: em ? capPreset.emph : (w.hi ? capPreset.hi : capPreset.base),
                        fontSize: em ? `${(capPreset.emphScale || 1.2) * 100}%` : undefined,
                      }}>{w.text}{i < capWords.length - 1 ? ' ' : ''}</span>
                    )
                  })}
                </span>
              </div>
            )}
            {overlays.map((o) => {
              if (!(o.start <= time && time < o.end)) return null
              const k = interp(o.keyframes, time)
              if (o.type === 'flash') {
                return <div key={o.id} className={`fx-el ${o.id === selId ? 'sel' : ''}`}
                  style={{ left: 0, top: 0, width: PW, height: PH, transform: 'none', background: o.color, opacity: k.opacity, zIndex: 11 }}
                  onMouseDown={(e) => onDown(e, o)} />
              }
              // card: anchor the pointer TIP at the position (body sits above)
              const yShift = o.type === 'card' ? '-100%' : '-50%'
              const common = { left: k.x * PW, top: k.y * PH, opacity: k.opacity, zIndex: 12, transform: `translate(-50%,${yShift}) rotate(${k.rotation}deg) scale(${k.scale})` }
              return (
                <div key={o.id} className={`fx-el ${o.id === selId ? 'sel' : ''}`} style={common} onMouseDown={(e) => onDown(e, o)}>
                  {o.type === 'text' && (() => {
                    const full = o.text || 'text'
                    const shown = o.typewriter
                      ? full.slice(0, Math.round(full.length * Math.max(0, Math.min((time - o.start) / Math.max(0.1, o.end - o.start), 1))))
                      : full
                    // size to the full text (fixed width) so typing reveals left-to-right
                    return <span style={{ color: o.color, fontSize: o.size * SCALE, WebkitTextStroke: '1px #000', fontWeight: 800, whiteSpace: 'pre', position: 'relative' }}>
                      <span style={{ visibility: 'hidden' }}>{full}</span>
                      <span style={{ position: 'absolute', left: 0, top: 0 }}>{shown}</span>
                    </span>
                  })()}
                  {o.type === 'emoji' && <span style={{ fontSize: o.size * SCALE }}>{o.emoji || '⭐'}</span>}
                  {(o.type === 'circle' || o.type === 'box') && (() => {
                    const ww = o.size * SCALE, hh = ww / (o.aspect || 1)
                    const wp = o.warp || []
                    const warped = wp.length >= 4 && wp.some((c) => Math.abs(c[0]) > 1e-3 || Math.abs(c[1]) > 1e-3)
                    const def = [[0, 0], [ww, 0], [ww, hh], [0, hh]]
                    const corners = def.map((d, i) => [d[0] + (wp[i]?.[0] || 0) * ww, d[1] + (wp[i]?.[1] || 0) * hh])
                    return <div style={{ width: ww, height: hh,
                      border: `${Math.max(2, ww * 0.045)}px solid ${o.color}`,
                      background: o.fill ? hexToRgba(o.color, o.fillOpacity ?? 0.35) : 'transparent',
                      borderRadius: o.type === 'circle' ? '50%' : 4, boxSizing: 'border-box',
                      transformOrigin: '0 0', transform: warped ? cornerPin(ww, hh, corners) : 'none' }} />
                  })()}
                  {o.type === 'arrow' && (() => {
                    const w = o.size * SCALE, h = w * 0.6, b = (o.bend || 0)
                    const cyTop = 30 - b * 38  // control-point height for the curve
                    return (
                      <svg width={w} height={h} viewBox="0 0 100 60" style={{ overflow: 'visible' }}>
                        <path d={`M 4 30 Q 50 ${cyTop} 90 30`} stroke={o.color} strokeWidth="9" fill="none" />
                        <polygon points="98,30 76,18 76,42" fill={o.color}
                          transform={`rotate(${Math.atan2(30 - cyTop, 40) * 57.3} 90 30)`} />
                      </svg>
                    )
                  })()}
                  {o.type === 'card' && (
                    <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
                      <div style={{ background: 'rgba(15,17,24,0.92)', border: `2px solid ${o.color}`, color: o.color,
                        borderRadius: 6, padding: '2px 8px', fontSize: o.size * SCALE * 0.5, fontWeight: 700, whiteSpace: 'nowrap' }}>
                        {o.text || 'PLAYER'}</div>
                      <div style={{ width: 0, height: 0, borderLeft: `${o.size * SCALE * 0.18}px solid transparent`,
                        borderRight: `${o.size * SCALE * 0.18}px solid transparent`, borderTop: `${o.size * SCALE * 0.22}px solid ${o.color}` }} />
                    </div>
                  )}
                  {o.id === selId && (
                    <>
                      <div onMouseDown={(e) => onHandleDown(e, o, 'rotate')} title="Drag to rotate"
                        style={{ position: 'absolute', left: '50%', top: 0, width: 13, height: 13, marginLeft: -6,
                          transform: `translateY(-200%) scale(${1 / (k.scale || 1)})`, borderRadius: '50%',
                          background: '#4ea1ff', border: '2px solid #fff', cursor: 'grab', zIndex: 20 }} />
                      <div onMouseDown={(e) => onHandleDown(e, o, 'resize')} title="Drag to resize"
                        style={{ position: 'absolute', right: 0, bottom: 0, width: 13, height: 13,
                          transform: `translate(50%,50%) scale(${1 / (k.scale || 1)})`,
                          background: '#4ea1ff', border: '2px solid #fff', cursor: 'nwse-resize', zIndex: 20 }} />
                      {warpOn && (o.type === 'circle' || o.type === 'box') && [0, 1, 2, 3].map((i) => {
                        const ww = o.size * SCALE, hh = ww / (o.aspect || 1)
                        const def = [[0, 0], [ww, 0], [ww, hh], [0, hh]][i]
                        const wp = o.warp || []
                        return <div key={'wc' + i} onMouseDown={(e) => onWarpDown(e, o, i)} title="Drag to warp this corner"
                          style={{ position: 'absolute', left: def[0] + (wp[i]?.[0] || 0) * ww, top: def[1] + (wp[i]?.[1] || 0) * hh,
                            width: 14, height: 14, marginLeft: -7, marginTop: -7, transform: `scale(${1 / (k.scale || 1)})`,
                            borderRadius: '50%', background: '#ffd60a', border: '2px solid #000', cursor: 'crosshair', zIndex: 21 }} />
                      })}
                    </>
                  )}
                </div>
              )
            })}
            {guides.x && <div style={{ position: 'absolute', left: '50%', top: 0, width: 1, height: '100%', background: 'rgba(78,161,255,0.85)', zIndex: 14, pointerEvents: 'none' }} />}
            {guides.y && <div style={{ position: 'absolute', top: '50%', left: 0, height: 1, width: '100%', background: 'rgba(78,161,255,0.85)', zIndex: 14, pointerEvents: 'none' }} />}
          </div>
          <div className="row" style={{ marginTop: 8, width: PW }}>
            <button className="btn" onClick={() => (playingRef.current ? stopPlayback() : play())}>
              {playing ? '⏸ Pause' : '▶ Play'}
            </button>
            <button className="btn small secondary" onClick={() => { stopPlayback(); setTime(0) }}>⏮</button>
            <span className="muted grow" style={{ textAlign: 'right' }}>{fmt(time)} / {fmt(total)}s{curScene ? ` · Clip ${curScene.index + 1}` : ''}</span>
          </div>
          {curScene && (() => {
            const idx = curScene.index
            const lineDur = curScene.end - curScene.start
            // Prefer the length read from the actual loaded video over stored metadata.
            const clipDur = realClipDur(curScene.clipUrl) || curScene.clipDur || 0
            const headroom = Math.max(0, clipDur - lineDur)
            if (headroom <= 0.3) return null
            const cs = curScene.clipStart || 0
            // What the preview is showing: the raw scrub position if scrubbing this
            // clip, else the slice's first frame.
            const shown = rawScrub && rawScrub.idx === idx ? rawScrub.t : cs
            // Committed slice start = where the slice begins (clamped so it can't run
            // past the clip end). Scrub past `headroom` and the slice ends at clipDur.
            const sliceStart = Math.min(shown, headroom)
            const scrubTo = (t) => {
              if (playingRef.current) stopPlayback()
              setRawScrub({ idx, t })          // preview shows this exact frame of the whole clip
              setTime(curScene.start)          // keep the playhead on this scene
              setClipTrim(idx, Math.min(t, headroom))  // commit the (clamped) slice start
            }
            return (
              <div style={{ marginTop: 8, width: PW }}>
                <div className="row" style={{ alignItems: 'center', gap: 8 }}>
                  <span className="muted" style={{ whiteSpace: 'nowrap' }}
                    title="Scrub the WHOLE clip to find your moment (e.g. the goal). The scene uses a fixed-length slice; if you scrub past the point where a full slice fits, the slice ends at the clip's end so your moment still shows.">
                    ✂ Clip {idx + 1}: showing {shown.toFixed(1)}s
                  </span>
                  <input type="range" min="0" max={clipDur.toFixed(2)} step="0.1" className="grow"
                    value={Math.min(shown, clipDur)}
                    onMouseDown={(e) => scrubTo(Number(e.target.value))}
                    onChange={(e) => scrubTo(Number(e.target.value))} />
                  <span className="muted" style={{ whiteSpace: 'nowrap' }}>of {clipDur.toFixed(1)}s</span>
                </div>
                <p className="prompt-hint" style={{ margin: '4px 0 0' }}>
                  Scrub the full clip to find the moment. Scene will play <strong>{sliceStart.toFixed(1)}s–{(sliceStart + lineDur).toFixed(1)}s</strong> ({lineDur.toFixed(1)}s). Press ▶ to watch the slice.
                </p>
              </div>
            )
          })()}
          {curScene && (() => {
            const idx = curScene.index
            const kfs = reframes[idx] || []
            const relT = Math.max(0, time - curScene.start)
            return (
              <div style={{ marginTop: 8, width: PW }}>
                <div className="row" style={{ alignItems: 'center', gap: 8 }}>
                  <button className={`btn small ${reframeOn ? '' : 'secondary'}`} onClick={() => setReframeOn((v) => !v)}
                    title="Pan/zoom the crop to follow the action — e.g. keep a player who's off to the side in frame.">
                    🎯 Reframe clip {idx + 1}{reframeOn ? ' (on)' : ''}
                  </button>
                  {kfs.length > 0 && <span className="muted" style={{ fontSize: 12 }}>{kfs.length} keyframe{kfs.length > 1 ? 's' : ''}</span>}
                  {kfs.length > 0 && <button className="btn small secondary" onClick={() => clearReframe(idx)}>Clear</button>}
                </div>
                {reframeOn && (
                  <div style={{ marginTop: 6 }}>
                    <p className="prompt-hint" style={{ margin: '0 0 6px' }}>
                      Drag the white dot on the preview (or use the sliders) to choose what's in frame. Move the playhead and adjust again to <strong>follow the action</strong> — each change adds a keyframe at the playhead.
                    </p>
                    <div className="row" style={{ gap: 8, alignItems: 'center' }}>
                      <span className="muted" style={{ width: 78 }}>Zoom {curFrame.zoom.toFixed(2)}×</span>
                      <input type="range" min="1" max="3" step="0.05" className="grow" value={curFrame.zoom}
                        onChange={(e) => setFrameKey({ zoom: Number(e.target.value) })} />
                    </div>
                    <div className="row" style={{ gap: 8, alignItems: 'center' }}>
                      <span className="muted" style={{ width: 78 }}>Horizontal</span>
                      <input type="range" min="0" max="1" step="0.01" className="grow" value={curFrame.x}
                        onChange={(e) => setFrameKey({ x: Number(e.target.value) })} />
                    </div>
                    <div className="row" style={{ gap: 8, alignItems: 'center' }}>
                      <span className="muted" style={{ width: 78 }}>Vertical</span>
                      <input type="range" min="0" max="1" step="0.01" className="grow" value={curFrame.y}
                        onChange={(e) => setFrameKey({ y: Number(e.target.value) })} />
                    </div>
                    <div className="row" style={{ marginTop: 6, flexWrap: 'wrap', gap: 4 }}>
                      <button className="btn small secondary" onClick={() => setFrameKey({})}>+ Keyframe at {relT.toFixed(1)}s</button>
                      {kfs.length > 1 && (
                        <button className={`btn small ${kfs.every((k) => k.ease) ? '' : 'secondary'}`}
                          title="Ease the pan/zoom in and out instead of moving at constant speed"
                          onClick={() => { const on = !kfs.every((k) => k.ease); setReframes((p) => ({ ...p, [idx]: (p[idx] || []).map((k) => ({ ...k, ease: on })) })); setDirty(true) }}>
                          {kfs.every((k) => k.ease) ? '✓ Smooth' : 'Smooth'}
                        </button>
                      )}
                      {[...kfs].sort((a, b) => a.t - b.t).map((k) => (
                        <button key={k.t} className="btn small secondary" title="Jump to this keyframe (× to delete)"
                          onClick={() => { stopPlayback(); setRawScrub(null); setTime(curScene.start + k.t) }}>
                          {k.t.toFixed(1)}s
                          <span onClick={(e) => { e.stopPropagation(); delFrameKey(idx, k.t) }} style={{ marginLeft: 6, color: '#ff6b6b' }}>×</span>
                        </button>
                      ))}
                    </div>
                  </div>
                )}
              </div>
            )
          })()}
          {curScene && (() => {
            const idx = curScene.index
            const lineDur = curScene.lineDur
            const ext = holds[idx] ?? (curScene.extend || 0)
            const dur = Math.max(0.3, lineDur + ext)
            const minExt = round(0.3 - lineDur)
            const maxExt = round(lineDur * 2 + 6)
            return (
              <div className="row" style={{ marginTop: 8, width: PW, gap: 8, alignItems: 'center' }}>
                <span className="muted" style={{ width: 92, whiteSpace: 'nowrap' }} title="Scene length. Drag right to hold the clip longer, left to trim. A reliable alternative to dragging the clip's edge on the timeline.">↔ Length {dur.toFixed(1)}s</span>
                <input type="range" min={minExt} max={maxExt} step="0.1" className="grow" value={Math.max(minExt, Math.min(ext, maxExt))}
                  onChange={(e) => { setHolds((p) => ({ ...p, [idx]: Number(e.target.value) })); setDirty(true) }} />
                <button className="btn small secondary" title="Back to the narration length, full clip from its start"
                  onClick={() => { setHolds((p) => ({ ...p, [idx]: 0 })); setClipStarts((p) => ({ ...p, [idx]: 0 })); setDirty(true) }}>Reset</button>
              </div>
            )
          })()}
          {curScene && (() => {
            const idx = curScene.index
            const sp = { speed: curScene.speed || 1, freeze: curScene.freeze || 0 }
            return (
              <div style={{ marginTop: 8, width: PW }}>
                <div className="row" style={{ gap: 8, alignItems: 'center' }}>
                  <span className="muted" style={{ width: 92, whiteSpace: 'nowrap' }} title="Slow motion (<1×) or fast (>1×). The narration stays at normal speed.">🐢 Speed {sp.speed.toFixed(2)}×</span>
                  <input type="range" min="0.25" max="2" step="0.05" className="grow" value={sp.speed}
                    onChange={(e) => setClipSpeed(idx, { speed: Number(e.target.value) }, sp)} />
                </div>
                <div className="row" style={{ gap: 8, alignItems: 'center', marginTop: 4 }}>
                  <span className="muted" style={{ width: 92, whiteSpace: 'nowrap' }} title="Freeze the starting frame for a dramatic pause, then play. Use Hold (drag the clip's right edge) to make room.">⏸ Freeze {sp.freeze.toFixed(1)}s</span>
                  <input type="range" min="0" max="3" step="0.1" className="grow" value={sp.freeze}
                    onChange={(e) => setClipSpeed(idx, { freeze: Number(e.target.value) }, sp)} />
                </div>
              </div>
            )
          })()}
          <div className="row" style={{ marginTop: 8, flexWrap: 'wrap' }}>
            <button className="btn small secondary" onClick={() => addOverlay('text')}>+ Text</button>
            <button className="btn small secondary" onClick={() => addTypedText()} disabled={typeBusy} title="Typewriter text that types itself out, with a typing sound">+ Typed{typeBusy ? '…' : ''}</button>
            <button className="btn small secondary" onClick={() => addOverlay('emoji')}>+ Emoji</button>
            <button className="btn small secondary" onClick={() => addOverlay('arrow')}>+ Arrow</button>
            <button className="btn small secondary" onClick={() => addOverlay('card')}>+ Card</button>
            <button className="btn small secondary" onClick={() => addOverlay('circle')} title="Hollow ring — circle a ball or player (works with Auto-follow)">+ Circle</button>
            <button className="btn small secondary" onClick={() => addOverlay('box')}>+ Box</button>
            <button className="btn small secondary" onClick={() => addOverlay('flash')}>+ Flash</button>
          </div>
        </div>

        {/* RIGHT — controls, with a sticky section nav so you don't hunt by scrolling */}
        <div className="fx-rightcol">
          <div className="fx-secnav">
            <button onClick={() => jumpTo('sec-element')}>✦ Element</button>
            <button onClick={() => jumpTo('sec-timeline')}>Timeline</button>
            <button onClick={() => jumpTo('sec-clips')}>Clips</button>
            <button onClick={() => jumpTo('sec-captions')}>Captions</button>
            <button onClick={() => jumpTo('sec-audio')}>Audio</button>
            <button onClick={() => jumpTo('sec-motion')}>Motion/FX</button>
          </div>
          <div id="sec-element" className="card">
            <div className="row"><strong className="grow">Element</strong></div>
          {!sel && <p className="muted" style={{ margin: 0 }}>Select an overlay (click it on the canvas or its bar on the timeline) to edit it. Add elements with the buttons under the preview.</p>}
          {sel && (
            <div>
              <div className="row"><strong className="grow">Edit {sel.type}</strong>
                <button className="btn small danger" onClick={() => removeOverlay(sel.id)}>Delete</button></div>
              {(sel.type === 'text' || sel.type === 'card') && <label className="field"><span className="lbl">{sel.type === 'card' ? 'Label' : 'Text'}</span><input type="text" value={sel.text} onChange={(e) => editSel('text', e.target.value)} /></label>}
              {sel.type === 'emoji' && <label className="field"><span className="lbl">Emoji</span><input type="text" value={sel.emoji} onChange={(e) => editSel('emoji', e.target.value)} /></label>}
              <div className="row">
                {sel.type !== 'emoji' && <label className="field" style={{ width: 90 }}><span className="lbl">Color</span><input type="color" value={sel.color} onChange={(e) => editSel('color', e.target.value)} style={{ height: 38, padding: 2 }} /></label>}
                <label className="field grow"><span className="lbl">{sel.type === 'arrow' ? 'Length' : 'Size'}: {sel.size}</span><input type="range" min="20" max="400" value={sel.size} onChange={(e) => editSel('size', Number(e.target.value))} /></label>
              </div>
              {sel.type === 'arrow' && (
                <label className="field"><span className="lbl">Bend: {(sel.bend || 0).toFixed(2)} (curve the arrow)</span>
                  <input type="range" min="-1" max="1" step="0.05" value={sel.bend || 0} onChange={(e) => editSel('bend', Number(e.target.value))} /></label>
              )}
              {(sel.type === 'circle' || sel.type === 'box') && (
                <>
                  <label className="field"><span className="lbl">Shape: {(sel.aspect || 1).toFixed(2)} (1 = {sel.type === 'circle' ? 'round' : 'square'}, &gt;1 = wide)</span>
                    <input type="range" min="0.3" max="3" step="0.05" value={sel.aspect || 1} onChange={(e) => editSel('aspect', Number(e.target.value))} /></label>
                  <div className="row" style={{ alignItems: 'center', gap: 8 }}>
                    <label className="row" style={{ gap: 6 }}>
                      <input type="checkbox" checked={!!sel.fill} onChange={(e) => editSel('fill', e.target.checked)} />
                      <span className="muted">Fill</span>
                    </label>
                    {sel.fill && <label className="field grow"><span className="lbl">Transparency: {Math.round((1 - (sel.fillOpacity ?? 0.35)) * 100)}%</span>
                      <input type="range" min="0.05" max="1" step="0.05" value={sel.fillOpacity ?? 0.35} onChange={(e) => editSel('fillOpacity', Number(e.target.value))} /></label>}
                  </div>
                  <div className="row" style={{ marginTop: 4 }}>
                    <button className={`btn small ${warpOn ? '' : 'secondary'}`} onClick={() => setWarpOn((v) => !v)}
                      title="Drag the yellow corner dots on the preview to fit a perspective (e.g. a goal mouth)">
                      ⊞ Warp corners{warpOn ? ' (on)' : ''}
                    </button>
                    {sel.warp && sel.warp.length >= 4 && sel.warp.some((c) => c[0] || c[1]) &&
                      <button className="btn small secondary" onClick={() => editSel('warp', [])}>Reset warp</button>}
                  </div>
                </>
              )}
              {sel.type === 'text' && (
                <label className="row" style={{ gap: 6, marginTop: 2 }}>
                  <input type="checkbox" checked={!!sel.typewriter} onChange={(e) => editSel('typewriter', e.target.checked)} />
                  <span className="muted">Typewriter (types itself out over its duration)</span>
                </label>
              )}
              <div className="row" style={{ marginTop: 4, gap: 4 }}>
                <button className="btn small grow" onClick={trackOverlay} disabled={trackBusy}
                  title="Drop this on the player, then auto-follow him across the clip">
                  {trackBusy ? '⏳ Tracking…' : '🎯 Auto-follow from here'}
                </button>
                <button className="btn small secondary" onClick={refineTrack} disabled={trackBusy}
                  title="For crowded scenes: place the card on the player at a few points (drag it at different playhead times), then Refine — it tracks between your points and can't drift off.">
                  Refine
                </button>
              </div>
              <p className="prompt-hint"><strong>Auto-follow</strong>: put it on the player, it tracks forward. <strong>Crowded scene?</strong> Drop it on him at a few moments (move the playhead, drag it onto him, repeat), then <strong>Refine</strong> — it tracks between your points and won't wander onto another player.</p>
              <div className="row" style={{ marginTop: 2 }}>
                <strong className="grow">Keyframes (motion)</strong>
                <button className="btn small secondary" onClick={addKeyframe}>+ Keyframe at {fmt(time)}s</button>
              </div>
              {sel.keyframes.map((k, i) => (
                <div key={i} className="row" style={{ gap: 6, fontSize: 12, marginBottom: 4 }}>
                  <span style={{ width: 48 }}>t={fmt(k.t)}</span>
                  <label>scale<input type="number" step="0.1" value={k.scale} style={{ width: 52 }} onChange={(e) => editKf(i, 'scale', Number(e.target.value))} /></label>
                  <label>rot<input type="number" step="5" value={k.rotation} style={{ width: 52 }} onChange={(e) => editKf(i, 'rotation', Number(e.target.value))} /></label>
                  <label>opac<input type="number" step="0.1" min="0" max="1" value={k.opacity} style={{ width: 52 }} onChange={(e) => editKf(i, 'opacity', Number(e.target.value))} /></label>
                  {sel.keyframes.length > 1 && <button className="btn small danger" onClick={() => delKf(i)}>✕</button>}
                </div>
              ))}
            </div>
          )}
          </div>

      {/* TIMELINE */}
      <div id="sec-timeline" className="card" style={{ marginTop: 0 }}>
        <div className="row"><strong className="grow">Timeline</strong>
          <span className="muted" style={{ fontSize: 12 }}>Drag a clip's right edge to hold it longer · drag bar edges to trim · drag bars to move</span></div>
        {(() => {
          const LANES = [['Clips', '#3a6ea5'], ['Overlays', '#b06'], ['Motion', '#a70'], ['Looks', '#2bb3a3'], ['Audio', '#4ecf8a']]
          return (
        <div style={{ display: 'flex', gap: 6 }}>
          <div style={{ width: 58, flexShrink: 0, paddingTop: 22 }}>
            {LANES.map(([name, c]) => (
              <div key={name} style={{ height: 26, marginBottom: 6, display: 'flex', alignItems: 'center', fontSize: 11, color: 'var(--muted)' }}>
                <span style={{ width: 8, height: 8, borderRadius: 2, background: c, marginRight: 5 }} />{name}
              </div>
            ))}
          </div>
          <div ref={tlScrollRef} className="grow" style={{ overflowX: 'auto', overflowY: 'hidden' }}>
          <div ref={tlRef} className="timeline" style={{ width: Math.max(1, total) * 48, minWidth: '100%' }}
            onMouseDown={tlDown} onMouseMove={tlMove} onMouseUp={tlUp} onMouseLeave={tlUp}>
          {/* time ruler — a tick every 1s up to ~30s, else every 5s */}
          <div style={{ position: 'absolute', top: 0, left: 0, right: 0, height: 16, pointerEvents: 'none' }}>
            {Array.from({ length: Math.floor(total / (total > 40 ? 5 : 1)) + 1 }).map((_, i) => { const s = i * (total > 40 ? 5 : 1); return (
              <span key={i} style={{ position: 'absolute', left: pct(s), fontSize: 9, color: 'var(--muted)', transform: 'translateX(-50%)' }}>{s}s</span>) })}
            ))}
          </div>
          {/* scene/clip lane (drag right edge to extend) */}
          <div className="tl-lane">
            {scenes.map((s) => (
              <div key={s.index} className="tl-clip" title={`${s.text}\n${(s.end - s.start).toFixed(1)}s${s.extend > 0 ? ` (voice ${s.lineDur.toFixed(1)}s + hold ${s.extend.toFixed(1)}s)` : ''}\nClick to select, then drag an edge to trim.`}
                onMouseDown={(e) => { e.stopPropagation(); stopPlayback(); setRawScrub(null); setSelClip(s.index === selClip ? null : s.index); setTime(s.start) }}
                style={{ left: pct(s.start), width: pct(s.end - s.start), minWidth: 20, position: 'absolute',
                  outline: selClip === s.index ? '2px solid var(--accent)' : 'none', outlineOffset: -2 }}>
                <span style={{ pointerEvents: 'none' }}>Clip {s.index + 1} · {(s.end - s.start).toFixed(1)}s{s.extend > 0 ? ' ⏱' : s.extend < 0 ? ' ✂' : ''}</span>
                {selClip === s.index && <>
                <div onMouseDown={(e) => clipTrimDown(e, s, 'l')} title="Trim the front (skip into the clip)"
                  style={{ position: 'absolute', left: 0, top: 0, bottom: 0, width: 8, cursor: 'ew-resize', background: 'var(--accent)' }} />
                <div onMouseDown={(e) => clipTrimDown(e, s, 'r')} title="Trim the back / hold longer"
                  style={{ position: 'absolute', right: 0, top: 0, bottom: 0, width: 8, cursor: 'ew-resize', background: 'var(--accent)' }} />
                </>}
              </div>
            ))}
          </div>
          {/* overlays lane (drag to move, edges to trim) */}
          <div className="tl-lane">
            {overlays.map((o) => (
              <div key={o.id} className={`tl-bar ${o.id === selId ? 'sel' : ''}`}
                style={{ left: pct(o.start), width: pct(Math.max(0.2, o.end - o.start)), background: o.color }}
                onMouseDown={(e) => barDown(e, 'overlay', o.id)}
                title={`${o.type} ${fmt(o.start)}–${fmt(o.end)}s`}>
                <div onMouseDown={(e) => edgeDown(e, 'overlay', o.id, 'l')} style={TL_EDGE_L} />
                {o.type === 'text' ? o.text : o.type === 'emoji' ? o.emoji : o.type}
                <div onMouseDown={(e) => edgeDown(e, 'overlay', o.id, 'r')} style={TL_EDGE_R} />
              </div>
            ))}
          </div>
          {/* motion lane */}
          <div className="tl-lane">
            {effects.map((e) => (
              <div key={e.id} className="tl-bar"
                style={{ left: pct(e.start), width: pct(Math.max(0.2, e.end - e.start)), background: EFFECT_COLORS[e.type] || '#888' }}
                onMouseDown={(ev) => barDown(ev, 'effect', e.id)} title={`${e.type} ${fmt(e.start)}–${fmt(e.end)}s`}>
                <div onMouseDown={(ev) => edgeDown(ev, 'effect', e.id, 'l')} style={TL_EDGE_L} />
                {e.type}
                <div onMouseDown={(ev) => edgeDown(ev, 'effect', e.id, 'r')} style={TL_EDGE_R} />
              </div>
            ))}
          </div>
          {/* timed looks lane */}
          <div className="tl-lane">
            {timedLooks.map((t) => (
              <div key={t.id} className="tl-bar"
                style={{ left: pct(t.start), width: pct(Math.max(0.2, t.end - t.start)), background: '#2bb3a3' }}
                onMouseDown={(ev) => barDown(ev, 'timedlook', t.id)} title={`look: ${t.look} ${fmt(t.start)}–${fmt(t.end)}s`}>
                <div onMouseDown={(ev) => edgeDown(ev, 'timedlook', t.id, 'l')} style={TL_EDGE_L} />
                {t.look}
                <div onMouseDown={(ev) => edgeDown(ev, 'timedlook', t.id, 'r')} style={TL_EDGE_R} />
              </div>
            ))}
          </div>
          {/* sfx lane */}
          <div className="tl-lane">
            {cues.map((c) => (
              <div key={c.id} className="tl-bar sfx" style={{ left: pct(c.start), width: 14, background: '#4ecf8a' }}
                onMouseDown={(ev) => barDown(ev, 'sfx', c.id)} title={`🔊 ${c.label} @ ${fmt(c.start)}s`}>🔊</div>
            ))}
          </div>
          {/* playhead */}
          <div className="tl-playhead" style={{ left: pct(Math.min(time, total)) }} />
          </div>
          </div>
        </div>
          )
        })()}
        <div className="tl-legend muted">Lanes: Clips · Overlays · Motion · Looks · Sounds — long reels scroll sideways; drag bars to move, drag anywhere to scrub.</div>
      </div>

      {/* Captions */}
      <div id="sec-captions" className="card">
        <div className="row">
          <strong className="grow">💬 Captions</strong>
          <label className="row" style={{ gap: 6 }}>
            <input type="checkbox" checked={captionsOn} onChange={(e) => { setCaptionsOn(e.target.checked); setDirty(true) }} />
            <span className="muted">Show captions</span>
          </label>
          <select value={captionPreset} disabled={!captionsOn}
            onChange={(e) => { setCaptionPreset(e.target.value); setDirty(true) }}>
            {Object.keys(CAP).map((p) => <option key={p} value={p}>{p}</option>)}
          </select>
        </div>
        <p className="prompt-hint" style={{ marginTop: 6 }}>
          Captions come from the narration — to change the words, edit the script. This style + on/off is what renders.
        </p>
        {captionsOn && scenes.length > 0 && (
          <div className="row" style={{ flexWrap: 'wrap', gap: 6, marginTop: 4 }}>
            <span className="muted" style={{ width: '100%' }}>Per-clip captions:</span>
            {scenes.map((s) => {
              const on = !captionHidden.includes(s.index)
              return (
                <button key={s.index} className={`btn small ${on ? '' : 'secondary'}`}
                  title={s.text} onClick={() => toggleClipCaption(s.index)}>
                  Clip {s.index + 1} {on ? '💬' : '🚫'}
                </button>
              )
            })}
          </div>
        )}
      </div>

      {/* Clip arrangement: reorder + enable/disable (non-destructive) */}
      {clipOrder.length > 1 && (
        <div id="sec-clips" className="card">
          <strong>🎬 Clips (reorder / hide)</strong>
          <p className="prompt-hint" style={{ marginTop: 4 }}>
            Drag the order with ↑↓ or hide a clip — the preview and render update instantly. No re-voicing needed.
          </p>
          {clipOrder.map((idx, pos) => {
            const ln = lineByIndex[idx]
            if (!ln) return null
            const off = clipDisabled.includes(idx)
            return (
              <div key={idx} className="row" style={{ gap: 8, marginTop: 4, fontSize: 13, opacity: off ? 0.5 : 1 }}>
                <span style={{ width: 56 }}>{off ? '— ' : `${pos + 1}. `}clip</span>
                <span className="grow" style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
                  {ln.text ? `“${ln.text}”` : '(silent)'}
                </span>
                <button className="btn small secondary" disabled={pos === 0} onClick={() => moveClip(pos, -1)}>↑</button>
                <button className="btn small secondary" disabled={pos === clipOrder.length - 1} onClick={() => moveClip(pos, 1)}>↓</button>
                <button className={`btn small ${off ? 'secondary' : ''}`} title="Show/hide this clip"
                  onClick={() => toggleClipEnabled(idx)}>{off ? '🚫 Hidden' : '👁 Shown'}</button>
                <button className={`btn small ${voiceMuted.includes(idx) ? 'secondary' : ''}`} disabled={off}
                  title="Speak / mute this clip's narration"
                  onClick={() => toggleClipVoice(idx)}>{voiceMuted.includes(idx) ? '🔇 Muted' : '🔊 Speaks'}</button>
              </div>
            )
          })}
        </div>
      )}

      {/* Per-clip looks */}
      {scenes.length > 0 && (
        <div className="card">
          <strong>🎨 Clip looks (filters)</strong>
          <p className="prompt-hint" style={{ marginTop: 4 }}>
            A colour/lens filter per clip. Most preview live; fisheye, vignette and grain are render-only (shown as a tag).
          </p>
          <div className="row" style={{ flexWrap: 'wrap', gap: 10 }}>
            {scenes.map((s) => (
              <label key={s.index} className="row" style={{ gap: 6, fontSize: 13 }}>
                <span className="muted">Clip {s.index + 1}</span>
                <select value={lookFor(s.index)} onChange={(e) => setLook(s.index, e.target.value)}>
                  {LOOK_TYPES.map((l) => <option key={l} value={l}>{l}{!LOOK_CSS[l] && l !== 'none' ? ' (render)' : ''}</option>)}
                </select>
              </label>
            ))}
          </div>
          {/* Time-ranged looks — apply a look to ANY window (not a whole clip) */}
          <div style={{ marginTop: 12, borderTop: '1px solid var(--border)', paddingTop: 10 }}>
            <div className="row">
              <strong className="grow">⏱ Timed looks (any range)</strong>
              <select id="tl-look-pick" defaultValue="vignette" style={{ fontSize: 13 }}>
                {LOOK_TYPES.filter((l) => l !== 'none').map((l) => <option key={l} value={l}>{l}</option>)}
              </select>
              <button className="btn small secondary" onClick={() => addTimedLook(document.getElementById('tl-look-pick')?.value || 'vignette')}>
                + Add at {fmt(time)}s
              </button>
            </div>
            <p className="prompt-hint" style={{ marginTop: 4 }}>
              Applies on top of clip looks, over a time window you drag on the <strong>Looks</strong> lane in the timeline (can span across cuts).
            </p>
            {timedLooks.map((t) => (
              <div key={t.id} className="row" style={{ gap: 8, marginTop: 4, fontSize: 13 }}>
                <select value={t.look} onChange={(e) => editTimedLook(t.id, 'look', e.target.value)}>
                  {LOOK_TYPES.filter((l) => l !== 'none').map((l) => <option key={l} value={l}>{l}</option>)}
                </select>
                <label>from <input type="number" step="0.1" min="0" max={total} value={t.start} style={{ width: 58 }} onChange={(e) => editTimedLook(t.id, 'start', Number(e.target.value))} /></label>
                <label>to <input type="number" step="0.1" min="0" max={total} value={t.end} style={{ width: 58 }} onChange={(e) => editTimedLook(t.id, 'end', Number(e.target.value))} /></label>
                <div className="grow" />
                <button className="btn small danger" onClick={() => delTimedLook(t.id)}>✕</button>
              </div>
            ))}
          </div>
        </div>
      )}

      {/* Atmosphere overlay video (rain/snow/light leaks) */}
      <div className="card">
        <div className="row">
          <strong className="grow">🌧️ Atmosphere overlay</strong>
          <input ref={atmoInput} type="file" accept="video/*" style={{ display: 'none' }}
            onChange={(e) => uploadAtmo(e.target.files?.[0])} />
          <button className="btn small secondary" disabled={atmoBusy} onClick={() => atmoInput.current?.click()}>
            {atmoBusy ? 'Uploading…' : atmo.url ? '↻ Replace video' : '⬆ Upload overlay video'}
          </button>
          {atmo.url && <button className="btn small danger" onClick={clearAtmo}>Remove</button>}
        </div>
        <p className="prompt-hint" style={{ marginTop: 4 }}>
          Upload a rain/snow/light-leak loop (grab a free one from Pixabay/Pexels). It's blended over the whole reel.
        </p>
        {atmo.url && (
          <div className="row" style={{ gap: 12 }}>
            <label className="row" style={{ gap: 6 }}>
              <span className="muted">Blend</span>
              <select value={atmo.blend} onChange={(e) => { setAtmo({ ...atmo, blend: e.target.value }); setDirty(true) }}>
                {BLEND_MODES.map((b) => <option key={b} value={b}>{b}</option>)}
              </select>
            </label>
            <label className="row grow" style={{ gap: 6 }}>
              <span className="muted">Opacity {Math.round(atmo.opacity * 100)}%</span>
              <input type="range" min="0" max="1" step="0.05" value={atmo.opacity}
                onChange={(e) => { setAtmo({ ...atmo, opacity: Number(e.target.value) }); setDirty(true) }} />
            </label>
          </div>
        )}
      </div>

      {/* Background music — audition + sync, right on the preview page */}
      <div id="sec-audio" className="card">
        <strong>🎵 Music</strong>
        <p className="prompt-hint" style={{ marginTop: 4 }}>
          Audition tracks against the visuals (▶), pick one, then hit the big ▶ Play above to hear it in the mix.
        </p>
        <div className="row" style={{ marginBottom: 4 }}>
          <button className={`btn small ${!media.music_path ? '' : 'secondary'}`} onClick={() => setReelMusic({ filename: '' })}>None</button>
          <span className="muted grow">{musicItems.length ? '' : 'No tracks — add them on the Media step (search/upload).'}</span>
        </div>
        {musicItems.map((it) => {
          const chosen = (media.music_path || '').endsWith(it.name)
          return (
            <div key={it.name} className={`topic-row ${chosen ? 'selected' : ''}`}>
              <button className="btn small secondary" onClick={() => auditionTrack(it.url)}>{auditionUrl === it.url ? '⏹' : '▶'}</button>
              <span className="grow">{it.name}</span>
              <button className={`btn small ${chosen ? '' : 'secondary'}`} onClick={() => setReelMusic({ filename: it.name })}>{chosen ? '✓ Using' : 'Use'}</button>
            </div>
          )
        })}
        {media.music_path && (
          <>
            <label className="field" style={{ marginTop: 8 }}>
              <span className="lbl">Volume: {Math.round((media.music_volume ?? 0.15) * 100)}%</span>
              <input type="range" min="0" max="1" step="0.01" defaultValue={media.music_volume ?? 0.15}
                onMouseUp={(e) => setReelMusic({ volume: Number(e.target.value) })}
                onTouchEnd={(e) => setReelMusic({ volume: Number(e.target.value) })} />
            </label>
            <div className="row">
              <label className="field grow">
                <span className="lbl">Start INTO track: {(media.music_start ?? 0).toFixed(1)}s — move a beat drop earlier</span>
                <input type="range" min="0" max="60" step="0.5" defaultValue={media.music_start ?? 0}
                  onMouseUp={(e) => setReelMusic({ start: Number(e.target.value) })}
                  onTouchEnd={(e) => setReelMusic({ start: Number(e.target.value) })} />
              </label>
              <label className="field grow">
                <span className="lbl">Begin at reel time: {(media.music_begin ?? 0).toFixed(1)}s</span>
                <input type="range" min="0" max={total} step="0.1" defaultValue={media.music_begin ?? 0}
                  onMouseUp={(e) => setReelMusic({ begin: Number(e.target.value) })}
                  onTouchEnd={(e) => setReelMusic({ begin: Number(e.target.value) })} />
              </label>
            </div>
            <div className="row" style={{ alignItems: 'center', gap: 12 }}>
              <label className="row" style={{ gap: 6 }}>
                <input type="checkbox" checked={!!media.beat_sync}
                  onChange={(e) => setReelMusic({ beat_sync: e.target.checked })} />
                <span>🥁 Beat sync (pulse zoom on the beat)</span>
              </label>
              {media.beat_sync && (
                <label className="field grow">
                  <span className="lbl">Punch: {Math.round((media.beat_sync_intensity ?? 0.5) * 100)}%</span>
                  <input type="range" min="0" max="1" step="0.05" defaultValue={media.beat_sync_intensity ?? 0.5}
                    onMouseUp={(e) => setReelMusic({ beat_sync_intensity: Number(e.target.value) })}
                    onTouchEnd={(e) => setReelMusic({ beat_sync_intensity: Number(e.target.value) })} />
                </label>
              )}
            </div>
            <p className="muted" style={{ margin: 0, fontSize: '0.85em' }}>
              Beat sync needs <code>pip install librosa</code>; without it the render runs normally (no pulse).
            </p>
            {media.music_credit && <p className="muted" style={{ margin: 0 }}>Credit: {media.music_credit}</p>}
          </>
        )}
      </div>

      {/* Transitions between clips */}
      {scenes.length > 1 && (
        <div className="card">
          <div className="row">
            <strong className="grow">🔀 Transitions between clips</strong>
            <label className="muted">duration {transDur.toFixed(2)}s
              <input type="range" min="0.15" max="1.5" step="0.05" value={transDur}
                onChange={(e) => { setTransDur(Number(e.target.value)); setDirty(true) }} /></label>
          </div>
          <div className="row" style={{ flexWrap: 'wrap', gap: 10, marginTop: 6 }}>
            {scenes.slice(0, -1).map((s, i) => (
              <label key={i} className="row" style={{ gap: 6, fontSize: 13 }}>
                <span className="muted">Clip {i + 1}→{i + 2}</span>
                <select value={transTypeFor(i)} onChange={(e) => setTransition(i, e.target.value)}>
                  {TRANSITION_TYPES.map((t) => <option key={t} value={t}>{t}</option>)}
                </select>
              </label>
            ))}
          </div>
          <p className="prompt-hint" style={{ marginTop: 6 }}>
            Previews live at each cut (scrub or play over a boundary). Transitions overlap clips, so the
            final reel is shorter by the transition time at each cut.
          </p>
        </div>
      )}

      {/* Motion effects */}
      <div id="sec-motion" className="card">
        <div className="row">
          <strong className="grow">🎥 Motion effects</strong>
          {['zoom', 'zoomout', 'slowzoom', 'pulse', 'shake', 'glitch', 'pan', 'fadein', 'fadeout'].map((t) => (
            <button key={t} className="btn small secondary" onClick={() => addEffect(t)}>+ {t}</button>
          ))}
        </div>
        {effects.length === 0 && <p className="muted" style={{ margin: '6px 0 0' }}>Zoom punch-in, pulse, camera shake, or pan — added at the playhead.</p>}
        {effects.map((e, i) => (
          <div key={e.id} className="row" style={{ gap: 8, marginTop: 6, fontSize: 13 }}>
            <span style={{ width: 56, color: EFFECT_COLORS[e.type] }}>{e.type}</span>
            <label>from <input type="number" step="0.1" min="0" max={total} value={e.start} style={{ width: 58 }} onChange={(ev) => editEffect(i, 'start', Number(ev.target.value))} /></label>
            <label>to <input type="number" step="0.1" min="0" max={total} value={e.end} style={{ width: 58 }} onChange={(ev) => editEffect(i, 'end', Number(ev.target.value))} /></label>
            <label className="grow">strength <input type="range" min="0" max="1" step="0.05" value={e.intensity} onChange={(ev) => editEffect(i, 'intensity', Number(ev.target.value))} /></label>
            <button className="btn small danger" onClick={() => delEffect(i)}>✕</button>
          </div>
        ))}
      </div>

      {/* Sound effects */}
      <div className="card">
        <strong>🔊 Sound effects</strong>
        <p className="prompt-hint">Search or upload a sound; it drops at the playhead ({fmt(time)}s). Hit Play to hear it in place.</p>
        <div className="row">
          <input type="text" className="grow" placeholder="whoosh, crowd cheer, ding…" value={sfxQuery}
            onChange={(e) => setSfxQuery(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && searchSfx()} />
          <button className="btn small secondary" onClick={searchSfx} disabled={sfxBusy}>Search</button>
          <input ref={sfxInput} type="file" accept="audio/*" style={{ display: 'none' }} onChange={(e) => uploadSfx(e.target.files?.[0])} />
          <button className="btn small secondary" onClick={() => sfxInput.current?.click()} disabled={sfxBusy}>⬆ Upload</button>
        </div>
        <div className="row" style={{ marginTop: 6 }}>
          <input type="text" className="grow" placeholder="✨ describe a sound to generate — e.g. 'crowd roar after a goal'" value={sfxGenText}
            onChange={(e) => setSfxGenText(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && generateSfx()} />
          <button className="btn small" onClick={generateSfx} disabled={sfxBusy || !sfxGenText.trim()} title="Generate a custom sound with ElevenLabs (uses credits)">
            {sfxBusy ? '✨ Generating…' : '✨ Generate (ElevenLabs)'}
          </button>
        </div>
        {sfxResults.map((r) => (
          <div key={r.id} className="topic-row">
            <span className="grow">{r.name} <span className="muted">· {Math.round(r.duration_sec)}s</span></span>
            {r.preview_url && <audio src={r.preview_url} controls preload="none" style={{ height: 30 }} />}
            <button className="btn small" disabled={sfxBusy} onClick={() => placeSfx(r)}>Place at {fmt(time)}s</button>
          </div>
        ))}
        {cues.map((c) => (
          <div key={c.id} className="row" style={{ gap: 8, marginTop: 4, fontSize: 13 }}>
            <span className="grow">🔊 {c.label}</span>
            {c.url && <button className="btn small secondary" onClick={() => { const a = new Audio(c.url); a.volume = Math.min(c.volume, 1); a.play(); audiosRef.current.push(a) }}>▶</button>}
            <label>at <input type="number" step="0.1" min="0" max={total} value={c.start} style={{ width: 58 }} onChange={(e) => editCue(c.id, 'start', Number(e.target.value))} />s</label>
            <label>vol <input type="range" min="0" max="1.5" step="0.05" value={c.volume} onChange={(e) => editCue(c.id, 'volume', Number(e.target.value))} /></label>
            <button className="btn small danger" onClick={() => delCue(c.id)}>✕</button>
          </div>
        ))}
      </div>

        </div>{/* /fx-rightcol */}
      </div>{/* /fx-body */}

      {rerender.running && <ProgressBox progress={rerender.progress.length ? rerender.progress : ['Re-rendering…']} />}
      <div className="row" style={{ marginTop: 14 }}>
        {dirty && <button className="btn secondary" onClick={save}>Save</button>}
        <div className="grow" />
        <button className="btn" onClick={saveAndRender}>Save &amp; go to Render →</button>
      </div>
    </div>
  )
}
