Initial commit — Bradly branding editor platform

This commit is contained in:
2026-06-02 03:27:03 -05:00
commit b135a70cc7
180 changed files with 43160 additions and 0 deletions
+394
View File
@@ -0,0 +1,394 @@
import React, { useMemo, useState, useCallback, useRef, useEffect } from 'react';
import { Play, Pause, RotateCcw, Film } from 'lucide-react';
import { Player, PlayerRef } from '@remotion/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<PlayerRef>;
/** Status label (e.g. "Listo" / "Faltan campos") */
statusLabel?: string;
/** Whether all required fields are complete */
isComplete?: boolean;
}
/** 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,
}) => {
const internalRef = useRef<PlayerRef>(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);
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);
// 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]);
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 => {
const durFrames = scene.durationSeconds * 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: (durFrames / totalFrames) * 100,
};
offset += durFrames;
return seg;
});
}, [template, fps, totalFrames]);
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)',
}}
>
<Player
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>
);
};
@@ -0,0 +1,276 @@
import React, { useRef } from 'react';
import {
Type, Image as ImageIcon, Video, Upload, AlertCircle,
Maximize2, Minimize2, Move, Pipette, X,
} from 'lucide-react';
import { TemplateField, DesignMD } from '../../types';
/**
* TemplateFieldInput — Shared form field component for TemplateField.
*
* Used in:
* - ProductionForm (live mode — user fills real data)
* - FormPreviewPanel (disabled mode — shows form mockup in builder)
* - TemplateBuilder test-data mode (live — designer fills test data)
*/
export interface TemplateFieldInputProps {
field: TemplateField;
value: string;
onChange: (value: string) => void;
error?: string;
designMD: DesignMD;
/** Media fit mode for image/video fields */
mediaFit?: 'cover' | 'contain' | 'fill';
/** Callback when user changes the media fit mode */
onMediaFitChange?: (fit: 'cover' | 'contain' | 'fill') => void;
/** Background color for contain mode empty space (null = transparent) */
containBgColor?: string | null;
/** Callback when user changes the contain background color */
onContainBgColorChange?: (color: string | null) => void;
/** When true, all inputs are disabled (form preview mode) */
disabled?: boolean;
}
export const TemplateFieldInput: React.FC<TemplateFieldInputProps> = ({
field,
value,
onChange,
error,
designMD,
mediaFit,
onMediaFitChange,
containBgColor,
onContainBgColorChange,
disabled = false,
}) => {
const colorInputRef = useRef<HTMLInputElement>(null);
const isText = field.type === 'text';
const isMedia = field.type === 'image' || field.type === 'video';
const isVideoField = field.type === 'video';
const isMultiline = field.rules?.multiline;
const maxChars = field.rules?.maxChars;
const resolvedFont = (() => {
if (field.style?.textRole === 'title') return designMD.titleFont || designMD.baseFont;
if (field.style?.textRole === 'subtitle') return designMD.subtitleFont || designMD.baseFont;
return designMD.paragraphFont || designMD.baseFont;
})();
const currentFit = mediaFit || 'cover';
return (
<div className="space-y-1.5">
{/* Label */}
<label className="flex items-center gap-1.5 text-[11px] text-neutral-300 font-medium">
{isText && <Type size={11} className="text-sky-400" />}
{isMedia && (isVideoField
? <Video size={11} className="text-sky-400" />
: <ImageIcon size={11} className="text-sky-400" />
)}
{field.label}
{field.required && <span className="text-red-400 text-[10px]">*</span>}
</label>
{/* Text input */}
{isText && (
isMultiline ? (
<textarea
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={field.content || `Escribe ${field.label.toLowerCase()}...`}
rows={3}
maxLength={maxChars || undefined}
disabled={disabled}
className={`w-full bg-neutral-800/50 border rounded-lg px-3 py-2 text-xs text-white placeholder-neutral-600 resize-none focus:outline-none focus:border-violet-500/50 transition-colors ${
disabled ? 'text-neutral-500 cursor-not-allowed' : ''
} ${
error ? 'border-red-500/50' : 'border-neutral-700'
}`}
style={{ fontFamily: resolvedFont }}
/>
) : (
<input
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={field.content || `Escribe ${field.label.toLowerCase()}...`}
maxLength={maxChars || undefined}
disabled={disabled}
className={`w-full bg-neutral-800/50 border rounded-lg px-3 py-2 text-xs text-white placeholder-neutral-600 focus:outline-none focus:border-violet-500/50 transition-colors ${
disabled ? 'text-neutral-500 cursor-not-allowed' : ''
} ${
error ? 'border-red-500/50' : 'border-neutral-700'
}`}
style={{
fontFamily: resolvedFont,
fontWeight: field.style.fontWeight || 400,
}}
/>
)
)}
{/* Media upload */}
{isMedia && (
<label
className={`flex flex-col items-center justify-center h-24 border-2 border-dashed rounded-lg transition-colors ${
disabled ? 'cursor-default' : 'cursor-pointer'
} ${
value
? 'border-violet-500/30 bg-violet-950/10'
: error
? 'border-red-500/30 bg-red-950/5'
: 'border-neutral-700 bg-neutral-800/30 hover:border-neutral-600'
}`}
>
{value ? (
<div className="relative w-full h-full">
{isVideoField ? (
<video
src={value}
muted
autoPlay
loop
playsInline
className="w-full h-full object-cover rounded-md"
/>
) : (
<img
src={value}
alt={field.label}
className="w-full h-full object-cover rounded-md"
/>
)}
{!disabled && (
<button
onClick={(e) => { e.preventDefault(); e.stopPropagation(); onChange(''); }}
title="Quitar media"
className="absolute top-1 right-1 bg-black/70 text-white text-[8px] px-1.5 py-0.5 rounded hover:bg-red-600 transition-colors"
>
</button>
)}
</div>
) : (
<>
<Upload size={18} className="text-neutral-600 mb-1.5" />
<span className="text-[9px] text-neutral-600">
{isVideoField ? 'Subir video' : 'Subir imagen'}
</span>
{field.rules?.aspectRatio && (
<span className="text-[8px] text-neutral-700 mt-0.5">
Ratio: {field.rules.aspectRatio}
</span>
)}
</>
)}
{!disabled && (
<input
type="file"
accept={isVideoField ? 'video/*' : 'image/*'}
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
const url = URL.createObjectURL(file);
onChange(url);
}
}}
/>
)}
</label>
)}
{/* Media Fit Selector — shown when media is uploaded and not disabled */}
{isMedia && value && !disabled && onMediaFitChange && (
<div className="flex items-center gap-1">
<span className="text-[8px] text-neutral-500 mr-1">Ajuste:</span>
{([
{ key: 'cover' as const, label: 'Cover', icon: <Maximize2 size={9} />, tip: 'Llena el área, recorta bordes' },
{ key: 'contain' as const, label: 'Contain', icon: <Minimize2 size={9} />, tip: 'Muestra completo, puede tener vacíos' },
{ key: 'fill' as const, label: 'Fill', icon: <Move size={9} />, tip: 'Estira para llenar (puede distorsionar)' },
]).map(opt => (
<button
key={opt.key}
type="button"
title={opt.tip}
onClick={(e) => { e.preventDefault(); e.stopPropagation(); onMediaFitChange(opt.key); }}
className={`flex items-center gap-1 px-1.5 py-0.5 rounded text-[8px] font-medium border transition-all ${
currentFit === opt.key
? 'border-violet-500/50 bg-violet-500/15 text-violet-300'
: 'border-neutral-800 bg-neutral-900 text-neutral-500 hover:text-neutral-300 hover:border-neutral-700'
}`}
>
{opt.icon}
{opt.label}
</button>
))}
</div>
)}
{/* Contain Background Color Picker — shown when fit=contain, media uploaded, not disabled */}
{isMedia && value && !disabled && currentFit === 'contain' && onContainBgColorChange && (
<div className="flex items-center gap-1.5">
<span className="text-[8px] text-neutral-500">Fondo:</span>
{/* Color swatch — click opens native picker */}
<button
type="button"
title={containBgColor ? `Color: ${containBgColor}` : 'Seleccionar color de fondo'}
onClick={(e) => { e.preventDefault(); colorInputRef.current?.click(); }}
className="w-5 h-5 rounded border border-neutral-700 hover:border-neutral-500 transition-colors overflow-hidden flex items-center justify-center shrink-0"
style={{
backgroundColor: containBgColor || undefined,
// Checkerboard pattern for transparent
...(!containBgColor ? {
backgroundImage: 'linear-gradient(45deg, #444 25%, transparent 25%), linear-gradient(-45deg, #444 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #444 75%), linear-gradient(-45deg, transparent 75%, #444 75%)',
backgroundSize: '6px 6px',
backgroundPosition: '0 0, 0 3px, 3px -3px, -3px 0px',
} : {}),
}}
>
{!containBgColor && <Pipette size={8} className="text-neutral-400" />}
</button>
<input
ref={colorInputRef}
type="color"
value={containBgColor || '#000000'}
onChange={(e) => onContainBgColorChange(e.target.value)}
className="sr-only"
tabIndex={-1}
/>
{/* Transparent toggle */}
<button
type="button"
title="Fondo transparente"
onClick={(e) => { e.preventDefault(); onContainBgColorChange(null); }}
className={`flex items-center gap-0.5 px-1.5 py-0.5 rounded text-[8px] font-medium border transition-all ${
!containBgColor
? 'border-violet-500/50 bg-violet-500/15 text-violet-300'
: 'border-neutral-800 bg-neutral-900 text-neutral-500 hover:text-neutral-300 hover:border-neutral-700'
}`}
>
<X size={7} />
Transparente
</button>
{/* Quick color — show clear button when color is set */}
{containBgColor && (
<span className="text-[7px] text-neutral-600 font-mono">{containBgColor}</span>
)}
</div>
)}
{/* Error / validation hints */}
{error && (
<p className="text-[9px] text-red-400 flex items-center gap-1">
<AlertCircle size={9} /> {error}
</p>
)}
{!error && maxChars && isText && (
<p className="text-[8px] text-neutral-600 flex items-center gap-1">
<AlertCircle size={8} /> Máximo {maxChars} caracteres
{value && <span className="ml-auto">{value.length}/{maxChars}</span>}
</p>
)}
</div>
);
};