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