import React, { useMemo, useState, useCallback, useRef, useEffect } from 'react'; import { Play, Pause, RotateCcw, Film } from 'lucide-react'; import { BradlyPlayer, BradlyPlayerRef } from '../../engine/player'; import { ExpressTemplate, CompanyProfile, DesignMD } from '../../types'; import { BrandComposition } from '../BrandComposition'; import { compileExpressToTimeline, getAspectDimensions, getTemplateDuration } from '../../utils/expressCompiler'; /** * LivePreviewCanvas — Shared Remotion preview component. * * Compiles TemplateField[] + fieldData → TimelineElement[] → Remotion Player. * * Used in: * - ProductionForm (production preview) * - TemplateBuilder test-data mode (design-time preview) */ export interface LivePreviewCanvasProps { template: ExpressTemplate; fieldData: Record; brand: CompanyProfile; designMD: DesignMD; /** Override objectFit per field ID */ mediaFits?: Record; /** Override containBgColor per field ID */ containBgColors?: Record; /** Show playback controls (play/pause/reset) — default true for video */ showControls?: boolean; /** Active scene ID for scene navigation */ activeSceneId?: string | null; /** Callback when user navigates to a scene */ onSceneChange?: (sceneId: string) => void; /** External player ref */ playerRef?: React.RefObject; /** Optional variation ID to apply */ variationId?: string; /** Status label (e.g. "Listo" / "Faltan campos") */ statusLabel?: string; /** Whether all required fields are complete */ isComplete?: boolean; /** Video duration overrides per scene (from useVideoDurations) */ videoDurations?: Record; } /** Format frame number to mm:ss */ function formatTime(frames: number, fps: number): string { const secs = Math.floor(frames / fps); const mins = Math.floor(secs / 60); const remainSecs = secs % 60; return `${mins}:${String(remainSecs).padStart(2, '0')}`; } /** Scene type colors for timeline segments */ const SCENE_COLORS: Record = { intro: '#10b981', content: '#8b5cf6', outro: '#f43f5e', }; export const LivePreviewCanvas: React.FC = ({ template, fieldData, brand, designMD, mediaFits = {}, containBgColors = {}, showControls, activeSceneId, onSceneChange, playerRef: externalRef, statusLabel, isComplete = false, videoDurations, variationId, }) => { const internalRef = useRef(null); const playerRef = externalRef || internalRef; const [isPlaying, setIsPlaying] = useState(false); const [currentFrame, setCurrentFrame] = useState(0); const scrubRef = useRef(null); const isScrubbing = useRef(false); const fps = 30; const totalDuration = getTemplateDuration(template, videoDurations, designMD); const totalFrames = Math.max(30, totalDuration * fps); const dimensions = getAspectDimensions(template.aspectRatio); // Compile template to timeline (reactive to fieldData + mediaFits) const compiled = useMemo(() => { const result = compileExpressToTimeline(template, fieldData, designMD, brand, videoDurations, variationId); // Strip transitions and apply mediaFit overrides result.elements = result.elements.map(el => { const fieldId = el.sourceFieldId; const fitOverride = fieldId ? mediaFits[fieldId] : undefined; const bgOverride = fieldId ? containBgColors[fieldId] : undefined; return { ...el, transitionIn: undefined, transitionOut: undefined, ...(fitOverride ? { objectFit: fitOverride } : {}), ...(bgOverride !== undefined ? { containBgColor: bgOverride } : {}), }; }); return result; }, [template, fieldData, designMD, brand, mediaFits, containBgColors, videoDurations, variationId]); const playerInputProps = useMemo(() => ({ designMD, timelineElements: compiled.elements, layers: compiled.layers, selectedElementId: null, textOverlay: '', brandVisibility: { logo: false, frame: false, background: true, }, outputFormat: template.format, }), [designMD, compiled, template.format]); // Force Player remount when media sources change (blob URLs from uploads). // Remotion's Player doesn't always re-render paused compositions on inputProps change. const playerKey = useMemo(() => { return compiled.elements .filter(el => el.type === 'video' || el.type === 'image') .map(el => el.content || '') .join('|'); }, [compiled]); const shouldShowControls = showControls ?? (template.format === 'video'); const isMultiScene = template.scenes.length > 1; // ── Frame tracking via polling ── useEffect(() => { if (!shouldShowControls) return; const interval = setInterval(() => { if (playerRef.current && !isScrubbing.current) { const frame = playerRef.current.getCurrentFrame(); setCurrentFrame(frame); } }, 1000 / 15); // 15Hz polling is enough for UI update return () => clearInterval(interval); }, [playerRef, shouldShowControls]); // ── Scene segments for timeline ── const sceneSegments = useMemo(() => { let offset = 0; return template.scenes.map(scene => { let actualDuration = scene.durationSeconds; if (videoDurations && videoDurations[scene.id]) { // Use actual video duration if user uploaded one actualDuration = videoDurations[scene.id]; } const durFrames = actualDuration * fps; const seg = { id: scene.id, name: scene.type === 'intro' ? 'INTRO' : scene.type === 'outro' ? 'OUTRO' : scene.name, type: scene.type || 'content', startFrame: offset, endFrame: offset + durFrames, widthPct: totalFrames > 0 ? (durFrames / totalFrames) * 100 : 0, }; offset += durFrames; return seg; }); }, [template, fps, totalFrames, videoDurations, designMD]); const handlePlayToggle = useCallback(() => { if (playerRef.current) { if (isPlaying) { playerRef.current.pause(); } else { playerRef.current.play(); } setIsPlaying(!isPlaying); } }, [isPlaying, playerRef]); const handleSelectScene = useCallback((sceneId: string) => { if (!playerRef.current) return; let frameOffset = 0; for (const scene of template.scenes) { if (scene.id === sceneId) break; frameOffset += scene.durationSeconds * fps; } playerRef.current.seekTo(frameOffset); playerRef.current.pause(); setIsPlaying(false); onSceneChange?.(sceneId); }, [template, fps, playerRef, onSceneChange]); // ── Scrub bar interactions ── const scrubToPosition = useCallback((clientX: number) => { if (!scrubRef.current || !playerRef.current) return; const rect = scrubRef.current.getBoundingClientRect(); const pct = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)); const frame = Math.round(pct * (totalFrames - 1)); playerRef.current.seekTo(frame); setCurrentFrame(frame); }, [playerRef, totalFrames]); const handleScrubDown = useCallback((e: React.PointerEvent) => { e.preventDefault(); (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); isScrubbing.current = true; playerRef.current?.pause(); setIsPlaying(false); scrubToPosition(e.clientX); }, [playerRef, scrubToPosition]); const handleScrubMove = useCallback((e: React.PointerEvent) => { if (!isScrubbing.current) return; scrubToPosition(e.clientX); }, [scrubToPosition]); const handleScrubUp = useCallback(() => { isScrubbing.current = false; }, []); const playheadPct = totalFrames > 0 ? (currentFrame / totalFrames) * 100 : 0; // Determine which scene the playhead is currently in const activeSceneFromPlayhead = useMemo(() => { for (const seg of sceneSegments) { if (currentFrame >= seg.startFrame && currentFrame < seg.endFrame) return seg.id; } return sceneSegments[sceneSegments.length - 1]?.id; }, [currentFrame, sceneSegments]); return (
{/* Status header */} {statusLabel !== undefined && (
Preview en vivo {statusLabel}
)} {/* Player container */}
{/* ═══ Timeline Controls ═══ */} {shouldShowControls && (
{/* ── Scrub Bar with scene segments ── */}
{/* Scene segment blocks */}
{sceneSegments.map(seg => { const isActive = activeSceneFromPlayhead === seg.id; const color = SCENE_COLORS[seg.type] || SCENE_COLORS.content; return (
{seg.name}
); })}
{/* Progress fill */}
{/* Playhead line */}
{/* Playhead thumb (appears on hover / scrub) */}
{/* ── Controls row ── */}
{/* Time display */} {formatTime(currentFrame, fps)} / {formatTime(totalFrames, fps)}
{/* Scene navigation buttons */} {isMultiScene && (
{template.scenes.map(scene => { const color = SCENE_COLORS[scene.type || 'content'] || SCENE_COLORS.content; const isActive = activeSceneFromPlayhead === scene.id; const label = scene.type === 'intro' ? 'IN' : scene.type === 'outro' ? 'OUT' : scene.name.slice(0, 4); return ( ); })}
)}
)} {/* Hint */}

Se actualiza al llenar los campos

); };