// Custom video player with chapter markers, aspect toggle, and integrated playback function ChispaVideoPlayer() { const TOTAL_DURATION = 54; const [time, setTime] = React.useState(0); const [playing, setPlaying] = React.useState(true); const [aspect, setAspect] = React.useState('16:9'); // 16:9, 9:16, 1:1 const [isFullscreen, setIsFullscreen] = React.useState(false); const playerRef = React.useRef(null); const toggleFullscreen = React.useCallback(() => { const el = playerRef.current; if (!el) return; if (!document.fullscreenElement) { (el.requestFullscreen?.() || el.webkitRequestFullscreen?.() || el.msRequestFullscreen?.()); } else { (document.exitFullscreen?.() || document.webkitExitFullscreen?.() || document.msExitFullscreen?.()); } }, []); React.useEffect(() => { const onFsChange = () => setIsFullscreen(!!document.fullscreenElement); document.addEventListener('fullscreenchange', onFsChange); document.addEventListener('webkitfullscreenchange', onFsChange); return () => { document.removeEventListener('fullscreenchange', onFsChange); document.removeEventListener('webkitfullscreenchange', onFsChange); }; }, []); const [showSubs, setShowSubs] = React.useState(true); const [speed, setSpeed] = React.useState(1); const [pacing, setPacing] = React.useState('full'); // full or fast const [primaryHue, setPrimaryHue] = React.useState(28); // base orange hue const [recording, setRecording] = React.useState(false); const [recordProgress, setRecordProgress] = React.useState(0); const rafRef = React.useRef(null); const lastTsRef = React.useRef(null); const stageRef = React.useRef(null); const stageInnerRef = React.useRef(null); const [scale, setScale] = React.useState(1); // Record video using html2canvas + MediaRecorder const recordVideo = React.useCallback(async () => { if (recording) return; if (!window.html2canvas) { alert('Cargando html2canvas... espera un momento e intenta de nuevo.'); return; } setRecording(true); setPlaying(false); setTime(0); const SCENE_W = STAGE_W; const SCENE_H = STAGE_H; const FPS = 24; const frameInterval = 1000 / FPS; // Output canvas at native resolution const outCanvas = document.createElement('canvas'); outCanvas.width = SCENE_W; outCanvas.height = SCENE_H; const outCtx = outCanvas.getContext('2d'); const stream = outCanvas.captureStream(FPS); // Try MP4 (H.264) first — Chrome 117+, Edge, Safari. Fall back to WebM. const mp4Candidates = [ 'video/mp4;codecs=avc1.42E01E', // H.264 baseline 'video/mp4;codecs=avc1.4D401F', // H.264 main 'video/mp4', ]; const webmCandidates = [ 'video/webm;codecs=vp9', 'video/webm;codecs=vp8', 'video/webm', ]; let mimeType = null; for (const m of [...mp4Candidates, ...webmCandidates]) { if (MediaRecorder.isTypeSupported(m)) { mimeType = m; break; } } const isMp4 = mimeType && mimeType.startsWith('video/mp4'); const recorder = new MediaRecorder(stream, { mimeType, videoBitsPerSecond: 8000000 }); const chunks = []; recorder.ondataavailable = (e) => { if (e.data.size > 0) chunks.push(e.data); }; const finished = new Promise(resolve => { recorder.onstop = resolve; }); recorder.start(); // Wait for next paint then capture frame-by-frame const totalFrames = Math.ceil(TOTAL_DURATION * FPS); for (let i = 0; i <= totalFrames; i++) { const t = (i / FPS); setTime(t); setRecordProgress(t / TOTAL_DURATION); // Wait two RAFs for React to render await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r))); try { const inner = stageInnerRef.current; if (!inner) break; const snap = await window.html2canvas(inner, { backgroundColor: '#000', width: SCENE_W, height: SCENE_H, scale: 1, useCORS: true, logging: false, }); outCtx.fillStyle = '#000'; outCtx.fillRect(0, 0, SCENE_W, SCENE_H); outCtx.drawImage(snap, 0, 0, SCENE_W, SCENE_H); } catch (err) { console.error('Frame capture failed', err); } // Pace to FPS await new Promise(r => setTimeout(r, Math.max(0, frameInterval - 16))); } recorder.stop(); await finished; const ext = isMp4 ? 'mp4' : 'webm'; const blob = new Blob(chunks, { type: isMp4 ? 'video/mp4' : 'video/webm' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; const aspectLabel = aspect.replace(':', 'x'); a.download = `chispa-promo-${aspectLabel}.${ext}`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); setRecording(false); setRecordProgress(0); setTime(0); }, [recording, aspect, STAGE_W, STAGE_H]); // Speed multiplier from pacing const effectiveSpeed = pacing === 'fast' ? speed * 2 : speed; React.useEffect(() => { window.__chispaSubsOn = showSubs; }, [showSubs]); // Expose recording trigger globally so devs can run window.recordChispa() // from the console without needing a visible button in the player chrome. React.useEffect(() => { window.recordChispa = recordVideo; window.setChispaAspect = (a) => { if (['16:9','9:16','1:1'].includes(a)) setAspect(a); }; return () => { if (window.recordChispa === recordVideo) delete window.recordChispa; }; }, [recordVideo]); // Animation loop React.useEffect(() => { if (!playing) { lastTsRef.current = null; return; } const step = (ts) => { if (lastTsRef.current == null) lastTsRef.current = ts; const dt = ((ts - lastTsRef.current) / 1000) * effectiveSpeed; lastTsRef.current = ts; setTime(t => { let next = t + dt; if (next >= TOTAL_DURATION) next = next % TOTAL_DURATION; return next; }); rafRef.current = requestAnimationFrame(step); }; rafRef.current = requestAnimationFrame(step); return () => { if (rafRef.current) cancelAnimationFrame(rafRef.current); lastTsRef.current = null; }; }, [playing, effectiveSpeed]); // Each aspect has its own canonical authoring size + dedicated scenes. // 16:9 → 1920x1080 wide-format scenes (Act1Problem, Act2Solution, ...) // 9:16 → 1080x1920 portrait scenes (Act1ProblemV, Act2SolutionV, ...) // 1:1 → uses 9:16 portrait composition cropped/contained (still fills cleanly) const isVertical = aspect === '9:16'; const isSquare = aspect === '1:1'; const STAGE_W = isVertical ? 1080 : isSquare ? 1080 : 1920; const STAGE_H = isVertical ? 1920 : isSquare ? 1080 : 1080; React.useEffect(() => { if (!stageRef.current) return; const el = stageRef.current; const measure = () => { const s = Math.min( el.clientWidth / STAGE_W, el.clientHeight / STAGE_H ); setScale(Math.max(0.05, s)); }; measure(); const ro = new ResizeObserver(measure); ro.observe(el); return () => ro.disconnect(); }, [aspect, STAGE_W, STAGE_H]); // Keyboard React.useEffect(() => { const onKey = (e) => { if (e.target && (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA')) return; if (e.code === 'Space') { e.preventDefault(); setPlaying(p => !p); } else if (e.code === 'ArrowLeft') { setTime(t => Math.max(0, t - 1)); } else if (e.code === 'ArrowRight') { setTime(t => Math.min(TOTAL_DURATION, t + 1)); } }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, []); const acts = [ { id: 1, name: 'Problema', start: 0, end: 5.0 }, { id: 2, name: 'Solución', start: 5.0, end: 20.0 }, { id: 3, name: 'IA aprende', start: 20.0, end: 32.0 }, { id: 4, name: 'Resultados', start: 32.0, end: 42.0 }, { id: 5, name: 'CTA', start: 42.0, end: 54.0 }, ]; const currentAct = acts.find(a => time >= a.start && time < a.end) || acts[0]; const ctxValue = { time, duration: TOTAL_DURATION, playing, setTime, setPlaying }; const primaryColor = `oklch(63% 0.18 ${primaryHue})`; const accentColor = `oklch(72% 0.16 ${primaryHue + 20})`; return (