a21675e5fc
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.
333 lines
13 KiB
TypeScript
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>
|
|
);
|
|
};
|