Files
brandly/src/components/shared/LivePreviewCanvas.tsx
T

407 lines
15 KiB
TypeScript

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<string, string>;
brand: CompanyProfile;
designMD: DesignMD;
/** Override objectFit per field ID */
mediaFits?: Record<string, 'cover' | 'contain' | 'fill'>;
/** Override containBgColor per field ID */
containBgColors?: Record<string, string | null>;
/** 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<BradlyPlayerRef>;
/** 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<string, number>;
}
/** 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<string, string> = {
intro: '#10b981',
content: '#8b5cf6',
outro: '#f43f5e',
};
export const LivePreviewCanvas: React.FC<LivePreviewCanvasProps> = ({
template,
fieldData,
brand,
designMD,
mediaFits = {},
containBgColors = {},
showControls,
activeSceneId,
onSceneChange,
playerRef: externalRef,
statusLabel,
isComplete = false,
videoDurations,
variationId,
}) => {
const internalRef = useRef<BradlyPlayerRef>(null);
const playerRef = externalRef || internalRef;
const [isPlaying, setIsPlaying] = useState(false);
const [currentFrame, setCurrentFrame] = useState(0);
const scrubRef = useRef<HTMLDivElement>(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 (
<div className="flex-1 flex flex-col items-center justify-center relative z-10 overflow-hidden">
{/* Status header */}
{statusLabel !== undefined && (
<div className="absolute top-4 left-5 flex items-center gap-2 z-20">
<div className={`w-2 h-2 rounded-full ${isComplete ? 'bg-emerald-400' : 'bg-amber-400 animate-pulse'}`} />
<span className="text-xs font-semibold text-neutral-300">Preview en vivo</span>
<span className={`text-[9px] px-2 py-0.5 rounded-full font-medium ${
isComplete
? 'bg-emerald-500/10 text-emerald-400'
: 'bg-amber-500/10 text-amber-400'
}`}>
{statusLabel}
</span>
</div>
)}
{/* Player container */}
<div
className="relative rounded-xl overflow-hidden shadow-2xl shadow-black/60 border border-neutral-800/40"
style={{
width: template.aspectRatio === '9:16' ? 240
: template.aspectRatio === '1:1' ? 320
: template.aspectRatio === '4:5' ? 280
: 420,
aspectRatio: `${dimensions.w} / ${dimensions.h}`,
maxHeight: 'calc(100% - 160px)',
}}
>
<BradlyPlayer
key={playerKey}
ref={playerRef}
component={BrandComposition}
inputProps={playerInputProps}
durationInFrames={totalFrames}
compositionWidth={dimensions.w}
compositionHeight={dimensions.h}
fps={fps}
style={{ width: '100%', height: '100%' }}
controls={false}
autoPlay={false}
loop
/>
</div>
{/* ═══ Timeline Controls ═══ */}
{shouldShowControls && (
<div className="mt-4 w-full max-w-md px-4 z-10 space-y-2">
{/* ── Scrub Bar with scene segments ── */}
<div
ref={scrubRef}
className="relative h-7 cursor-col-resize group rounded-lg overflow-hidden"
onPointerDown={handleScrubDown}
onPointerMove={handleScrubMove}
onPointerUp={handleScrubUp}
onPointerCancel={handleScrubUp}
title="Arrastra para navegar"
>
{/* Scene segment blocks */}
<div className="absolute inset-0 flex gap-px rounded-lg overflow-hidden">
{sceneSegments.map(seg => {
const isActive = activeSceneFromPlayhead === seg.id;
const color = SCENE_COLORS[seg.type] || SCENE_COLORS.content;
return (
<div
key={seg.id}
className="relative h-full flex items-center justify-center transition-all"
style={{
width: `${seg.widthPct}%`,
backgroundColor: isActive ? `${color}25` : `${color}10`,
borderBottom: `2px solid ${isActive ? color : `${color}40`}`,
}}
>
<span
className="text-[7px] font-bold tracking-wider truncate px-1 pointer-events-none select-none"
style={{ color: isActive ? color : `${color}80` }}
>
{seg.name}
</span>
</div>
);
})}
</div>
{/* Progress fill */}
<div
className="absolute top-0 left-0 h-full pointer-events-none transition-[width] duration-75"
style={{
width: `${playheadPct}%`,
background: 'linear-gradient(90deg, rgba(139,92,246,0.1), rgba(139,92,246,0.05))',
}}
/>
{/* Playhead line */}
<div
className="absolute top-0 h-full w-0.5 pointer-events-none transition-[left] duration-75 z-10"
style={{
left: `${playheadPct}%`,
background: 'rgba(255,255,255,0.9)',
boxShadow: '0 0 4px rgba(139,92,246,0.6)',
}}
/>
{/* Playhead thumb (appears on hover / scrub) */}
<div
className="absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-2.5 h-2.5 rounded-full bg-white shadow-lg shadow-violet-500/30 border-2 border-violet-500 pointer-events-none z-20 opacity-0 group-hover:opacity-100 transition-opacity duration-150"
style={{ left: `${playheadPct}%` }}
/>
</div>
{/* ── Controls row ── */}
<div className="flex items-center gap-2">
<button
onClick={handlePlayToggle}
title={isPlaying ? 'Pausar' : 'Reproducir'}
className="w-8 h-8 rounded-full bg-violet-600 hover:bg-violet-500 text-white flex items-center justify-center transition-colors shadow-md"
>
{isPlaying ? <Pause size={12} fill="currentColor" /> : <Play size={12} fill="currentColor" />}
</button>
<button
onClick={() => { playerRef.current?.seekTo(0); setCurrentFrame(0); setIsPlaying(false); playerRef.current?.pause(); }}
title="Reiniciar"
className="w-7 h-7 rounded-full bg-neutral-800 hover:bg-neutral-700 text-neutral-400 flex items-center justify-center transition-colors"
>
<RotateCcw size={10} />
</button>
{/* Time display */}
<span className="text-[10px] font-mono text-neutral-400 ml-1">
{formatTime(currentFrame, fps)}
<span className="text-neutral-600 mx-0.5">/</span>
{formatTime(totalFrames, fps)}
</span>
<div className="flex-1" />
{/* Scene navigation buttons */}
{isMultiScene && (
<div className="flex items-center gap-0.5">
{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 (
<button
key={scene.id}
onClick={() => handleSelectScene(scene.id)}
title={`${scene.type === 'intro' ? 'Intro' : scene.type === 'outro' ? 'Outro' : scene.name}${scene.durationSeconds}s`}
className="px-2 py-0.5 rounded text-[7px] font-bold border transition-all uppercase tracking-wider"
style={{
borderColor: isActive ? `${color}80` : 'rgba(64,64,64,0.5)',
backgroundColor: isActive ? `${color}15` : 'transparent',
color: isActive ? color : 'rgb(115,115,115)',
}}
>
{label}
</button>
);
})}
</div>
)}
</div>
</div>
)}
{/* Hint */}
<p className="absolute bottom-4 text-[10px] text-neutral-600 z-10">
Se actualiza al llenar los campos
</p>
</div>
);
};