Spaces:
Paused
Paused
| 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 & 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 & 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'}, >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 & go to Render →</button> | |
| </div> | |
| </div> | |
| ) | |
| } | |