reel-studio-2 / web /src /steps /EffectsStep.jsx
xvepkj's picture
Add beat sync and power punch captions
4021f36
Raw
History Blame
107 kB
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>
)
}