Files
brandly/src/components/express/ExpressEditor.tsx
T
kevinguevara a21675e5fc fix: 3 critical bugs — audio corruption, blob URLs, static duration
1. Audio/Video sync: During playback, let the browser handle media naturally
   and only force-seek when drift > 250ms. Prevents audio glitching caused
   by setting currentTime 30x/sec. When paused/scrubbing, sync precisely.

2. Blob URLs → Server uploads: All media uploads (BrandTabMedia, SceneFieldEditor,
   TemplateFieldInput) now POST to /api/upload and use persistent server URLs
   instead of blob: URLs that vanish on refresh.

3. Dynamic duration: Added useVideoDurations hook that probes actual video
   durations from uploaded form videos. getTemplateDuration and
   compileExpressToTimeline now accept videoDurations to override static
   durationSeconds for form-sourced scenes.
2026-06-02 09:48:28 -05:00

333 lines
13 KiB
TypeScript

import React, { useState, useMemo, useCallback, useRef } from 'react';
import { ArrowLeft, Zap, Wrench, Download, ChevronRight, Play, Pause, RotateCcw } from 'lucide-react';
import { BradlyPlayer, BradlyPlayerRef } from '../../engine/player';
import { ExpressTemplate, DesignMD, TimelineElement, TimelineLayer, CompanyProfile } from '../../types';
import { BrandComposition } from '../BrandComposition';
import { ExpressTemplateGallery } from './ExpressTemplateGallery';
import { StoryboardView } from './StoryboardView';
import { SceneFieldEditor } from './SceneFieldEditor';
import { ExpressStylePanel } from './ExpressStylePanel';
import { compileExpressToTimeline, getAspectDimensions, getTemplateDuration } from '../../utils/expressCompiler';
import { useVideoDurations } from '../../hooks/useVideoDurations';
interface ExpressEditorProps {
designMD: DesignMD;
company?: CompanyProfile;
onBack: () => void;
onUpgradeToPro: (elements: TimelineElement[], layers: TimelineLayer[]) => void;
onExport: (elements: TimelineElement[], layers: TimelineLayer[], format: 'video' | 'image') => void;
}
type EditorPhase = 'gallery' | 'editing';
/**
* ExpressEditor — Scene-based storyboard editor.
* No video editor, no timeline, no toolbar.
* User picks a template → fills in scenes → exports.
*/
export const ExpressEditor: React.FC<ExpressEditorProps> = ({
designMD,
company,
onBack,
onUpgradeToPro,
onExport,
}) => {
const [phase, setPhase] = useState<EditorPhase>('gallery');
const [selectedTemplate, setSelectedTemplate] = useState<ExpressTemplate | null>(null);
const [fieldData, setFieldData] = useState<Record<string, string>>({});
const [activeSceneId, setActiveSceneId] = useState<string | null>(null);
const [isPlaying, setIsPlaying] = useState(false);
// Style options
const [bgStyle, setBgStyle] = useState<'solid' | 'gradient' | 'dark'>('gradient');
const [showLogo, setShowLogo] = useState(true);
const [overlayOpacity, setOverlayOpacity] = useState(0);
const playerRef = useRef<BradlyPlayerRef>(null);
const handleSelectTemplate = useCallback((template: ExpressTemplate) => {
setSelectedTemplate(template);
// Pre-fill field data with empty strings
const initial: Record<string, string> = {};
template.scenes.forEach(scene => {
scene.editableFields.forEach(field => {
initial[field.id] = '';
});
});
setFieldData(initial);
setActiveSceneId(template.scenes[0]?.id || null);
setPhase('editing');
}, []);
const handleFieldChange = useCallback((fieldId: string, value: string) => {
setFieldData(prev => ({ ...prev, [fieldId]: value }));
}, []);
// Probe actual video durations for form-sourced scenes
const videoDurations = useVideoDurations(selectedTemplate, fieldData);
// Compile template to timeline
const compiled = useMemo(() => {
if (!selectedTemplate) return null;
return compileExpressToTimeline(selectedTemplate, fieldData, designMD, company, videoDurations);
}, [selectedTemplate, fieldData, designMD, company, videoDurations]);
const totalDuration = selectedTemplate ? getTemplateDuration(selectedTemplate, videoDurations) : 0;
const fps = 30;
const totalFrames = Math.max(30, totalDuration * fps);
const dimensions = selectedTemplate
? getAspectDimensions(selectedTemplate.aspectRatio)
: { w: 1080, h: 1920 };
const activeScene = selectedTemplate?.scenes.find(s => s.id === activeSceneId) || null;
const handlePlayToggle = useCallback(() => {
if (playerRef.current) {
if (isPlaying) {
playerRef.current.pause();
} else {
playerRef.current.play();
}
setIsPlaying(!isPlaying);
}
}, [isPlaying]);
const handleUpgrade = () => {
if (compiled) onUpgradeToPro(compiled.elements, compiled.layers);
};
const handleExport = () => {
if (compiled && selectedTemplate) {
onExport(compiled.elements, compiled.layers, selectedTemplate.format);
}
};
// Navigate to scene in player
const handleSelectScene = useCallback((sceneId: string) => {
setActiveSceneId(sceneId);
if (!selectedTemplate || !playerRef.current) return;
// Seek player to scene start
let frameOffset = 0;
for (const scene of selectedTemplate.scenes) {
if (scene.id === sceneId) break;
frameOffset += scene.durationSeconds * fps;
}
playerRef.current.seekTo(frameOffset);
playerRef.current.pause();
setIsPlaying(false);
}, [selectedTemplate, fps]);
const bgColor = bgStyle === 'dark'
? '#111111'
: bgStyle === 'gradient'
? undefined
: designMD.secondaryColor;
return (
<div className="flex-1 flex flex-col overflow-hidden bg-neutral-950">
{/* ═══ Top Bar ═══ */}
<div className="h-11 bg-neutral-900/80 border-b border-neutral-800/60 flex items-center px-4 gap-3 shrink-0 backdrop-blur-sm">
<button
onClick={phase === 'editing' ? () => setPhase('gallery') : onBack}
title="Volver"
className="flex items-center gap-1.5 text-neutral-400 hover:text-white transition-colors text-xs"
>
<ArrowLeft size={14} />
{phase === 'editing' ? 'Plantillas' : 'Dashboard'}
</button>
<div className="flex-1" />
<div className="flex items-center gap-1.5 px-2 py-1 rounded-lg bg-gradient-to-r from-violet-600/15 to-fuchsia-600/15 border border-violet-500/20">
<Zap size={12} className="text-violet-400" />
<span className="text-[10px] font-bold text-violet-300 tracking-wider">EXPRESS</span>
</div>
{phase === 'editing' && (
<>
<button
onClick={handleUpgrade}
title="Abrir en Editor Pro con timeline completo"
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg bg-neutral-800 border border-neutral-700 text-[10px] text-neutral-400 hover:text-white hover:border-neutral-600 transition-all"
>
<Wrench size={10} />
Editor Pro
<ChevronRight size={10} />
</button>
<button
onClick={handleExport}
title="Exportar"
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-gradient-to-r from-violet-600 to-fuchsia-600 text-white text-[10px] font-semibold hover:from-violet-500 hover:to-fuchsia-500 transition-all shadow-lg shadow-violet-900/30"
>
<Download size={12} />
Exportar
</button>
</>
)}
</div>
{/* ═══ Content ═══ */}
{phase === 'gallery' ? (
<ExpressTemplateGallery
designMD={designMD}
onSelectTemplate={handleSelectTemplate}
brandTemplates={company?.brandTemplates}
brandName={company?.name}
/>
) : selectedTemplate && compiled ? (
<div className="flex-1 flex flex-col overflow-hidden">
{/* Main area: Preview + Right Panel */}
<div className="flex-1 flex overflow-hidden min-h-0">
{/* Canvas Area */}
<div className="flex-1 flex flex-col items-center justify-center bg-neutral-950 p-4 overflow-hidden relative min-h-0">
{/* Subtle pattern */}
<div
className="absolute inset-0 opacity-[0.02]"
style={{ backgroundImage: 'radial-gradient(circle at 1px 1px, white 1px, transparent 0)', backgroundSize: '30px 30px' }}
/>
{/* Template name */}
<div className="mb-2 flex items-center gap-2 relative z-10 shrink-0">
<span className="text-lg">{selectedTemplate.icon}</span>
<span className="text-xs font-semibold text-neutral-400">{selectedTemplate.name}</span>
<span className="text-[9px] text-neutral-600 font-mono px-1.5 py-0.5 bg-neutral-900 rounded">
{selectedTemplate.aspectRatio}
</span>
<span className="text-[9px] text-neutral-600 font-mono px-1.5 py-0.5 bg-neutral-900 rounded">
{totalDuration}s
</span>
</div>
{/* Player */}
<div
className="relative rounded-xl overflow-hidden shadow-2xl shadow-black/50 border border-neutral-800/40 shrink-0"
style={{
width: selectedTemplate.aspectRatio === '9:16' ? 240
: selectedTemplate.aspectRatio === '1:1' ? 320
: selectedTemplate.aspectRatio === '4:5' ? 280
: 420,
aspectRatio: `${dimensions.w} / ${dimensions.h}`,
maxHeight: 'calc(100% - 80px)',
}}
>
<BradlyPlayer
ref={playerRef}
component={BrandComposition}
inputProps={{
designMD: {
...designMD,
secondaryColor: bgColor || designMD.secondaryColor,
},
timelineElements: compiled.elements,
layers: compiled.layers,
selectedElementId: null,
aspectRatio: selectedTemplate.aspectRatio,
textOverlay: '',
showLogo,
showFrame: false,
showBackground: true,
brandVisibility: {
logo: showLogo,
frame: false,
background: true,
},
}}
durationInFrames={totalFrames}
compositionWidth={dimensions.w}
compositionHeight={dimensions.h}
fps={fps}
style={{ width: '100%', height: '100%' }}
controls={false}
autoPlay={false}
loop
/>
{overlayOpacity > 0 && (
<div
className="absolute inset-0 pointer-events-none"
style={{ backgroundColor: `rgba(0,0,0,${overlayOpacity / 100})` }}
/>
)}
</div>
{/* Mini play controls */}
{selectedTemplate.format === 'video' && (
<div className="mt-3 flex items-center gap-2 relative z-10 shrink-0">
<button
onClick={handlePlayToggle}
title={isPlaying ? 'Pausar' : 'Reproducir'}
className="w-7 h-7 rounded-full bg-violet-600 hover:bg-violet-500 text-white flex items-center justify-center transition-colors shadow-sm"
>
{isPlaying ? <Pause size={11} fill="currentColor" /> : <Play size={11} fill="currentColor" />}
</button>
<button
onClick={() => { playerRef.current?.seekTo(0); setIsPlaying(false); }}
title="Reiniciar"
className="w-6 h-6 rounded-full bg-neutral-800 hover:bg-neutral-700 text-neutral-400 flex items-center justify-center transition-colors"
>
<RotateCcw size={10} />
</button>
<span className="text-[9px] text-neutral-500 font-mono">{totalDuration}s</span>
</div>
)}
</div>
{/* Right Panel — Scene Fields */}
<aside className="w-72 bg-neutral-900 border-l border-neutral-800/60 overflow-y-auto p-4 space-y-5 shrink-0">
{activeScene ? (
<SceneFieldEditor
scene={activeScene}
fieldData={fieldData}
onFieldChange={handleFieldChange}
designMD={designMD}
/>
) : (
<div className="text-center text-neutral-500 text-xs py-8">
Selecciona una escena del storyboard
</div>
)}
<hr className="border-neutral-800/50" />
{/* Style */}
<ExpressStylePanel
designMD={designMD}
bgStyle={bgStyle}
setBgStyle={setBgStyle}
showLogo={showLogo}
setShowLogo={setShowLogo}
overlayOpacity={overlayOpacity}
setOverlayOpacity={setOverlayOpacity}
/>
<hr className="border-neutral-800/50" />
<button
onClick={() => setPhase('gallery')}
title="Elegir otra plantilla"
className="w-full py-2 rounded-lg bg-neutral-800/50 border border-neutral-800 text-[10px] text-neutral-400 hover:text-white hover:border-neutral-700 transition-all flex items-center justify-center gap-1.5"
>
<RotateCcw size={10} />
Cambiar plantilla
</button>
</aside>
</div>
{/* Storyboard (bottom strip — video only) */}
{selectedTemplate.format === 'video' && (
<StoryboardView
scenes={selectedTemplate.scenes}
activeSceneId={activeSceneId}
onSelectScene={handleSelectScene}
fieldData={fieldData}
totalDuration={totalDuration}
/>
)}
</div>
) : null}
</div>
);
};