fix(rendering): synchronize FPS, implement render locks, respect brand segment duration, and fix local audio path resolving for ffmpeg

This commit is contained in:
2026-06-02 20:40:30 -05:00
parent b7656cf8eb
commit 9503dbfabc
39 changed files with 3556 additions and 3506 deletions
+8 -6
View File
@@ -10,13 +10,14 @@ import { StudioTopBar } from './components/studio/StudioTopBar';
import { EditorProvider } from './context/EditorContext'; import { EditorProvider } from './context/EditorContext';
import { DEFAULT_DESIGN_MD, PREDEFINED_COMPANIES, DEFAULT_PILLARS } from './data/defaults'; import { DEFAULT_DESIGN_MD, PREDEFINED_COMPANIES, DEFAULT_PILLARS } from './data/defaults';
import { useCustomTooltips } from './hooks/useCustomTooltips'; import { useCustomTooltips } from './hooks/useCustomTooltips';
import { ToastProvider } from './components/ui/ToastProvider'; import { useToast } from './components/ui/ToastProvider';
import { usePersistence, loadCompanies, useTemplatePersistence, loadTemplates } from './hooks/usePersistence'; import { usePersistence, loadCompanies, useTemplatePersistence, loadTemplates } from './hooks/usePersistence';
import { ContentGridView } from './components/content-grid/ContentGridView'; import { ContentGridView } from './components/content-grid/ContentGridView';
import { TemplateBuilder } from './components/express/builder/TemplateBuilder'; import { TemplateBuilder } from './components/express/builder/TemplateBuilder';
import { EXPRESS_TEMPLATES } from './config/expressTemplates'; import { EXPRESS_TEMPLATES } from './config/expressTemplates';
import { compileExpressToTimeline } from './utils/expressCompiler'; import { compileExpressToTimeline } from './utils/expressCompiler';
import { FullscreenToggle } from './components/ui/FullscreenToggle'; import { FullscreenToggle } from './components/ui/FullscreenToggle';
import { GlobalExportWidget } from './components/export/GlobalExportWidget';
import { detectMediaDimensionsAndAspect } from './utils/mediaDimensions'; import { detectMediaDimensionsAndAspect } from './utils/mediaDimensions';
type Step = 'dashboard' | 'brand' | 'studio' | 'express' | 'content-grid' | 'template-builder' | 'production-form'; type Step = 'dashboard' | 'brand' | 'studio' | 'express' | 'content-grid' | 'template-builder' | 'production-form';
@@ -34,6 +35,7 @@ function saveContentData(data: Record<string, { pieces: ContentPiece[]; pillars:
} }
export default function App() { export default function App() {
const { showToast } = useToast();
const [companies, setCompanies] = useState<CompanyProfile[]>(() => { const [companies, setCompanies] = useState<CompanyProfile[]>(() => {
return loadCompanies() ?? PREDEFINED_COMPANIES; return loadCompanies() ?? PREDEFINED_COMPANIES;
}); });
@@ -150,7 +152,7 @@ export default function App() {
x: 50, y: 50, x: 50, y: 50,
width: 100, height: 100, width: 100, height: 100,
objectFit: 'cover', objectFit: 'cover',
opacity: 1, opacity: 100,
isBrandElement: false, isBrandElement: false,
isLocked: true, isLocked: true,
} : { } : {
@@ -163,7 +165,7 @@ export default function App() {
x: 50, y: 50, x: 50, y: 50,
width: 100, height: 100, width: 100, height: 100,
objectFit: 'cover', objectFit: 'cover',
opacity: 1, opacity: 100,
isBrandElement: false, isBrandElement: false,
isLocked: true, isLocked: true,
}; };
@@ -182,8 +184,9 @@ export default function App() {
handleDesignChange(editingBrandAsset.type, url); handleDesignChange(editingBrandAsset.type, url);
setEditingBrandAsset(null); setEditingBrandAsset(null);
setCurrentStep('brand'); setCurrentStep('brand');
showToast('Activo de marca guardado y aplicado correctamente', 'success');
} }
}, [editingBrandAsset]); }, [editingBrandAsset, showToast]);
const handleStartExpressBlank = useCallback(() => { const handleStartExpressBlank = useCallback(() => {
setCurrentCompanyId(null); setCurrentCompanyId(null);
@@ -244,7 +247,6 @@ export default function App() {
}, [productionTemplate, productionBrand]); }, [productionTemplate, productionBrand]);
return ( return (
<ToastProvider>
<div className="flex flex-col h-screen bg-neutral-950 text-neutral-100 font-sans overflow-hidden"> <div className="flex flex-col h-screen bg-neutral-950 text-neutral-100 font-sans overflow-hidden">
{currentStep !== 'studio' && ( {currentStep !== 'studio' && (
<TopHeader <TopHeader
@@ -405,8 +407,8 @@ export default function App() {
/> />
)} )}
</div> </div>
<GlobalExportWidget />
<FullscreenToggle /> <FullscreenToggle />
</div> </div>
</ToastProvider>
); );
} }
+15 -28
View File
@@ -2,7 +2,6 @@ import React from 'react';
import { AbsoluteFill } from '../engine/components'; import { AbsoluteFill } from '../engine/components';
import { useCurrentFrame } from '../engine/player'; import { useCurrentFrame } from '../engine/player';
import { RenderProps } from '../types'; import { RenderProps } from '../types';
import { useCanvasDrag } from './composition/useCanvasDrag';
import { BackgroundLayer } from './composition/BackgroundLayer'; import { BackgroundLayer } from './composition/BackgroundLayer';
import { BrandOverlay } from './composition/BrandOverlay'; import { BrandOverlay } from './composition/BrandOverlay';
import { CompositionElement } from './composition/CompositionElement'; import { CompositionElement } from './composition/CompositionElement';
@@ -25,27 +24,23 @@ export const BrandComposition: React.FC<RenderProps> = ({
activeLayerId, activeLayerId,
activeAction, activeAction,
brandVisibility, brandVisibility,
outputFormat outputFormat,
onDragStart,
startResize,
startRotate,
snapGuides
}) => { }) => {
const frame = useCurrentFrame(); const frame = useCurrentFrame();
const {
containerRef,
dragState,
setDragState,
transformDragState,
setTransformDragState,
tempPositions,
guides
} = useCanvasDrag(timelineElements, onElementPositionChange, onElementTransformChange);
// Separate brand fullscreen videos from other elements for correct z-order // Separate brand fullscreen videos from other elements for correct z-order
const brandFullscreenEls = timelineElements.filter(el => const brandFullscreenEls = timelineElements.filter(el =>
el.isBrandElement && (el.brandDisplayMode ?? 'fullscreen') === 'fullscreen' && el.type === 'video' el.isBrandElement && (el.brandDisplayMode ?? 'fullscreen') === 'fullscreen' && el.type === 'video'
); );
const otherElements = timelineElements.filter(el => const otherElements = timelineElements.filter(el => {
!(el.isBrandElement && (el.brandDisplayMode ?? 'fullscreen') === 'fullscreen' && el.type === 'video') const isBrandFullscreen = el.isBrandElement && (el.brandDisplayMode ?? 'fullscreen') === 'fullscreen' && el.type === 'video';
); const isBackground = layers?.find(l => l.id === el.layerId)?.type === 'background';
return !isBrandFullscreen && !isBackground;
});
const renderElement = (el: typeof timelineElements[0]) => { const renderElement = (el: typeof timelineElements[0]) => {
let layer = layers.find(l => l.id === el.layerId); let layer = layers.find(l => l.id === el.layerId);
@@ -69,23 +64,15 @@ export const BrandComposition: React.FC<RenderProps> = ({
activeLayerId={activeLayerId ?? null} activeLayerId={activeLayerId ?? null}
activeAction={activeAction ?? 'move'} activeAction={activeAction ?? 'move'}
isImageMode={outputFormat === 'image'} isImageMode={outputFormat === 'image'}
tempPositions={tempPositions}
dragStateId={dragState?.id ?? null}
containerRef={containerRef}
onElementClick={onElementClick} onElementClick={onElementClick}
onElementDoubleClick={onElementDoubleClick} onElementDoubleClick={onElementDoubleClick}
onElementContextMenu={onElementContextMenu} onElementContextMenu={onElementContextMenu}
onElementDuplicate={onElementDuplicate} onElementDuplicate={onElementDuplicate}
onElementDelete={onElementDelete} onElementDelete={onElementDelete}
onElementLock={onElementLock} onElementLock={onElementLock}
onDragStart={(id, startX, startY, initialElX, initialElY) => { onDragStart={onDragStart}
if (onElementPositionChange) { startResize={startResize}
setDragState({ id, startX, startY, initialElX, initialElY }); startRotate={startRotate}
}
}}
onTransformStart={(id, type, startX, startY, initialScale, initialRot, centerX, centerY) => {
setTransformDragState({ id, type, startX, startY, initialScale, initialRot, centerX, centerY });
}}
/> />
); );
}; };
@@ -93,7 +80,7 @@ export const BrandComposition: React.FC<RenderProps> = ({
const showBackground = brandVisibility?.background ?? true; const showBackground = brandVisibility?.background ?? true;
return ( return (
<AbsoluteFill style={{ backgroundColor: showBackground ? designMD.secondaryColor : 'transparent' }} ref={containerRef}> <AbsoluteFill style={{ backgroundColor: showBackground ? designMD.secondaryColor : 'transparent' }}>
{/* Layer 1: Background media (user-uploaded backgrounds) */} {/* Layer 1: Background media (user-uploaded backgrounds) */}
<BackgroundLayer timelineElements={timelineElements} layers={layers} /> <BackgroundLayer timelineElements={timelineElements} layers={layers} />
@@ -107,7 +94,7 @@ export const BrandComposition: React.FC<RenderProps> = ({
{otherElements.map(renderElement)} {otherElements.map(renderElement)}
{/* Smart Guides Overlay */} {/* Smart Guides Overlay */}
<SmartGuides guides={guides} /> <SmartGuides guides={snapGuides || { x: null, y: null }} />
</AbsoluteFill> </AbsoluteFill>
); );
}; };
+1 -3
View File
@@ -3,7 +3,7 @@ import type { BradlyPlayerRef } from '../engine/player';
import { Type, Image as ImageIcon, Trash2, Film, Upload, Wand2, Play, ImagePlus, Square, Plus } from 'lucide-react'; import { Type, Image as ImageIcon, Trash2, Film, Upload, Wand2, Play, ImagePlus, Square, Plus } from 'lucide-react';
import { TimelineElement, MediaFilter, TimelineLayer, DesignMD } from '../types'; import { TimelineElement, MediaFilter, TimelineLayer, DesignMD } from '../types';
import { AudioLayerPanel } from './properties/AudioLayerPanel'; import { AudioLayerPanel } from './properties/AudioLayerPanel';
import { GraphicLayerPanel } from './properties/GraphicLayerPanel';
import { TransitionsPanel } from './properties/TransitionsPanel'; import { TransitionsPanel } from './properties/TransitionsPanel';
import { GlobalSettingsPanel } from './properties/GlobalSettingsPanel'; import { GlobalSettingsPanel } from './properties/GlobalSettingsPanel';
import { ElementPropertiesPanel } from './properties/ElementPropertiesPanel'; import { ElementPropertiesPanel } from './properties/ElementPropertiesPanel';
@@ -116,8 +116,6 @@ export const StudioProperties: React.FC<StudioPropertiesProps> = ({
playerRef={playerRef} playerRef={playerRef}
endFrameLimit={timelineElements.find(el => layers.find(l => l.id === el.layerId)?.type === 'background')?.endFrame || 150} endFrameLimit={timelineElements.find(el => layers.find(l => l.id === el.layerId)?.type === 'background')?.endFrame || 150}
/> />
) : activeLayerId && layers.find(l => l.id === activeLayerId)?.type === 'visual' ? (
<GraphicLayerPanel />
) : ( ) : (
<GlobalSettingsPanel <GlobalSettingsPanel
textOverlay={textOverlay} textOverlay={textOverlay}
+64 -14
View File
@@ -1,10 +1,11 @@
import React, { RefObject, useState, useCallback, useEffect } from 'react'; import React, { RefObject, useState, useCallback, useEffect, useRef } from 'react';
import { BradlyPlayer, BradlyPlayerRef } from '../engine/player'; import { BradlyPlayer, BradlyPlayerRef } from '../engine/player';
import { BrandComposition } from './BrandComposition'; import { BrandComposition } from './BrandComposition';
import { RenderProps, TimelineElement } from '../types'; import { RenderProps, TimelineElement } from '../types';
import { PlaySquare } from 'lucide-react'; import { PlaySquare } from 'lucide-react';
import { CanvasWorkspace } from './ui/CanvasWorkspace'; import { CanvasWorkspace } from './ui/CanvasWorkspace';
import { useEditor } from '../context/EditorContext'; import { useEditor } from '../context/EditorContext';
import { useDragResize } from '../hooks/useDragResize';
import { ElementActionToolbar } from './composition/ElementActionToolbar'; import { ElementActionToolbar } from './composition/ElementActionToolbar';
import { SAFE_AREAS } from '../config/constants'; import { SAFE_AREAS } from '../config/constants';
import { getAudioDuration, durationToFrames } from '../utils/audioMetadata'; import { getAudioDuration, durationToFrames } from '../utils/audioMetadata';
@@ -55,6 +56,31 @@ export const StudioWorkspace: React.FC<StudioWorkspaceProps> = ({
const { activeAction, setActiveAction } = useEditor(); const { activeAction, setActiveAction } = useEditor();
const compositionContainerRef = React.useRef<HTMLDivElement>(null);
const {
startDrag,
startResize,
startRotate,
handlePointerMove: handleDragMove,
handlePointerUp: handleDragUp,
isDragging,
activeId: dragFieldId,
snapGuides,
} = useDragResize({
containerRef: compositionContainerRef,
onMove: useCallback((id: string, x: number, y: number) => {
setTimelineElements?.(prev => prev.map(el => el.id === id ? { ...el, x, y } : el));
}, [setTimelineElements]),
onResize: useCallback((id: string, w: number, h: number) => {
setTimelineElements?.(prev => prev.map(el => el.id === id ? { ...el, width: w, height: h, scale: 1 } : el));
}, [setTimelineElements]),
onRotate: useCallback((id: string, rotation: number) => {
setTimelineElements?.(prev => prev.map(el => el.id === id ? { ...el, rotation } : el));
}, [setTimelineElements]),
snapLines: [50],
});
// Keyboard shortcuts for action modes (M/S/R) and element actions (D/Delete) // Keyboard shortcuts for action modes (M/S/R) and element actions (D/Delete)
useEffect(() => { useEffect(() => {
if (!selectedElementId) return; if (!selectedElementId) return;
@@ -123,11 +149,14 @@ export const StudioWorkspace: React.FC<StudioWorkspaceProps> = ({
const dy = e.clientY - lastPanPos.current.y; const dy = e.clientY - lastPanPos.current.y;
setPan(prev => ({ x: prev.x + dx, y: prev.y + dy })); setPan(prev => ({ x: prev.x + dx, y: prev.y + dy }));
lastPanPos.current = { x: e.clientX, y: e.clientY }; lastPanPos.current = { x: e.clientX, y: e.clientY };
} else {
handleDragMove(e);
} }
}; };
const handlePointerUp = () => { const handlePointerUp = () => {
setIsPanning(false); setIsPanning(false);
handleDragUp();
}; };
// Ref for native wheel handler (React onWheel is passive, can't preventDefault) // Ref for native wheel handler (React onWheel is passive, can't preventDefault)
@@ -169,11 +198,18 @@ export const StudioWorkspace: React.FC<StudioWorkspaceProps> = ({
}; };
const { layers, setLayers, setActiveLayerId } = useEditor(); const { layers, setLayers, setActiveLayerId } = useEditor();
const lastDropTime = useRef<number>(0);
// ═══ Drop handler for canvas ═══ // ═══ Drop handler for canvas ═══
const handleDrop = useCallback((e: React.DragEvent) => { const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault(); e.preventDefault();
setIsDragOver(false); setIsDragOver(false);
// Prevent duplicate drops (event bubbling/double firing) within 300ms
const now = Date.now();
if (now - lastDropTime.current < 300) return;
lastDropTime.current = now;
const data = e.dataTransfer.getData('application/json'); const data = e.dataTransfer.getData('application/json');
if (!data || !setTimelineElements) return; if (!data || !setTimelineElements) return;
@@ -230,18 +266,26 @@ export const StudioWorkspace: React.FC<StudioWorkspaceProps> = ({
} }
} }
setTimelineElements(prev => [...prev, { const newId = 'el-' + Date.now();
id: 'el-' + Date.now(), console.log('[handleDrop] Adding new element:', newId, parsed.src);
layerId: targetLayerId,
type: elementType, setTimelineElements(prev => {
content: parsed.src, // Prevent duplicate drops in the same millisecond or duplicate event bubbling
startFrame: currentFrame, if (prev.some(el => el.id === newId)) return prev;
endFrame: Math.min(durationInFrames, currentFrame + 100),
x: Math.max(5, Math.min(95, x)), return [...prev, {
y: Math.max(5, Math.min(95, y)), id: newId,
scale: 1, layerId: targetLayerId,
originalFileName: parsed.fileName, type: elementType,
}]); content: parsed.src,
startFrame: currentFrame,
endFrame: Math.min(durationInFrames, currentFrame + 100),
x: Math.max(5, Math.min(95, x)),
y: Math.max(5, Math.min(95, y)),
scale: 1,
originalFileName: parsed.fileName,
}];
});
// Auto-detect audio duration and update endFrame // Auto-detect audio duration and update endFrame
if (elementType === 'audio') { if (elementType === 'audio') {
@@ -438,6 +482,7 @@ export const StudioWorkspace: React.FC<StudioWorkspaceProps> = ({
isEditing={!!selectedElementId} isEditing={!!selectedElementId}
className="" className=""
canvasClassName="rounded" canvasClassName="rounded"
canvasRef={compositionContainerRef}
overlay={selectedElementId ? ( overlay={selectedElementId ? (
<> <>
{/* Text editor overlay */} {/* Text editor overlay */}
@@ -595,7 +640,12 @@ export const StudioWorkspace: React.FC<StudioWorkspaceProps> = ({
setEditingTextId(id); setEditingTextId(id);
setShowContextMenu(false); setShowContextMenu(false);
} }
} },
onDragStart: startDrag,
onTransformStart: undefined,
startResize,
startRotate,
snapGuides,
}} }}
durationInFrames={durationInFrames} durationInFrames={durationInFrames}
compositionWidth={dimensions.width} compositionWidth={dimensions.width}
@@ -28,10 +28,15 @@ export const BackgroundLayer: React.FC<BackgroundLayerProps> = ({ timelineElemen
return ( return (
<> <>
{backgroundElements.map((el) => { {backgroundElements.map((el) => {
if (el.isHidden) return null;
const filterStyle = getFilterStyle(el.filter || 'none'); const filterStyle = getFilterStyle(el.filter || 'none');
// Calculate base opacity
const layerOpacity = layers.find(l => l.id === el.layerId)?.opacity ?? 1;
const opacity = ((el.opacity ?? 100) / 100) * layerOpacity;
return ( return (
<Sequence key={el.id} from={el.startFrame} durationInFrames={el.endFrame - el.startFrame}> <Sequence key={el.id} from={el.startFrame} durationInFrames={el.endFrame - el.startFrame}>
<AbsoluteFill style={filterStyle}> <AbsoluteFill style={{ ...filterStyle, opacity }}>
{el.type === 'color' && ( {el.type === 'color' && (
<div style={{ <div style={{
width: '100%', width: '100%',
+79 -320
View File
@@ -8,6 +8,8 @@ import { ChromaKeyImage } from './ChromaKeyImage';
import { ChromaKeyVideo } from './ChromaKeyVideo'; import { ChromaKeyVideo } from './ChromaKeyVideo';
import type { CanvasActionMode } from './ElementActionToolbar'; import type { CanvasActionMode } from './ElementActionToolbar';
import { loadGoogleFont } from '../../utils/googleFontsApi'; import { loadGoogleFont } from '../../utils/googleFontsApi';
import { ElementRenderer } from './ElementRenderer';
import { ElementHandles } from './ElementHandles';
interface CompositionElementProps { interface CompositionElementProps {
element: TimelineElement; element: TimelineElement;
@@ -18,14 +20,12 @@ interface CompositionElementProps {
activeLayerId: string | null; activeLayerId: string | null;
activeAction: CanvasActionMode; activeAction: CanvasActionMode;
isImageMode?: boolean; isImageMode?: boolean;
tempPositions: Record<string, { x: number; y: number; scale?: number; rotation?: number }>;
dragStateId: string | null;
containerRef: RefObject<HTMLDivElement>;
onElementClick?: (id: string) => void; onElementClick?: (id: string) => void;
onElementDoubleClick?: (id: string) => void; onElementDoubleClick?: (id: string) => void;
onElementContextMenu?: (id: string, e: React.MouseEvent) => void; onElementContextMenu?: (id: string, e: React.MouseEvent) => void;
onDragStart: (id: string, startX: number, startY: number, initialElX: number, initialElY: number) => void; onDragStart?: (e: React.PointerEvent, id: string, origPos: { x: number; y: number; w: number; h: number; rotation?: number }) => void;
onTransformStart: (id: string, type: 'scale' | 'rotate', startX: number, startY: number, initialScale: number, initialRot: number, centerX: number, centerY: number) => void; startResize?: (e: React.PointerEvent, id: string, origPos: { x: number; y: number; w: number; h: number; rotation?: number }) => void;
startRotate?: (e: React.PointerEvent, id: string, origPos: { x: number; y: number; w: number; h: number; rotation?: number }) => void;
onElementDuplicate?: (id: string) => void; onElementDuplicate?: (id: string) => void;
onElementDelete?: (id: string) => void; onElementDelete?: (id: string) => void;
onElementLock?: (id: string) => void; onElementLock?: (id: string) => void;
@@ -40,14 +40,12 @@ export const CompositionElement: React.FC<CompositionElementProps> = ({
activeLayerId, activeLayerId,
activeAction, activeAction,
isImageMode = false, isImageMode = false,
tempPositions,
dragStateId,
containerRef,
onElementClick, onElementClick,
onElementDoubleClick, onElementDoubleClick,
onElementContextMenu, onElementContextMenu,
onDragStart, onDragStart,
onTransformStart, startResize,
startRotate,
onElementDuplicate, onElementDuplicate,
onElementDelete, onElementDelete,
onElementLock, onElementLock,
@@ -66,24 +64,37 @@ export const CompositionElement: React.FC<CompositionElementProps> = ({
// Skip hidden elements (after all hooks to satisfy Rules of Hooks) // Skip hidden elements (after all hooks to satisfy Rules of Hooks)
if (el.isHidden) return null; if (el.isHidden) return null;
if (el.type === 'text') {
console.log('[DEBUG TEXT]', {
id: el.id,
content: el.content,
x: el.x, y: el.y,
opacity: el.opacity,
layerOpacity: layer?.opacity,
color: el.color,
fontSize: el.fontSize,
fontFamily: fontFamily,
startFrame: el.startFrame,
endFrame: el.endFrame,
currentFrame: frame
});
}
const isSelected = selectedElementId === el.id; const isSelected = selectedElementId === el.id;
const layerOpacity = layer?.opacity ?? 1; const layerOpacity = layer?.opacity ?? 1;
const baseOpacity = ((el.opacity ?? 100) / 100) * layerOpacity; const baseOpacity = ((el.opacity ?? 100) / 100) * layerOpacity;
const currentScale = tempPositions[el.id]?.scale ?? el.scale ?? 1; const currentScale = el.scale ?? 1;
const currentRot = tempPositions[el.id]?.rotation ?? el.rotation ?? 0; const currentRot = el.rotation ?? 0;
const tempX = tempPositions[el.id]?.x;
const tempY = tempPositions[el.id]?.y;
const { opacity, transformStr, displayContent } = calculateElementTransitions( const { opacity, transformStr, displayContent } = calculateElementTransitions(
el, frame, baseOpacity, currentScale, currentRot, tempX, tempY el, frame, baseOpacity, currentScale, currentRot, undefined, undefined
); );
// Resolve position — multi-keyframes take priority over legacy animEnd* // Resolve position — multi-keyframes take priority over legacy animEnd*
let currentX = tempX ?? el.x; let currentX = el.x;
let currentY = tempY ?? el.y; let currentY = el.y;
if (el.keyframes && el.keyframes.length >= 2 && !tempPositions[el.id]) { if (el.keyframes && el.keyframes.length >= 2) {
// Multi-keyframe: resolve x/y from keyframe engine // Multi-keyframe: resolve x/y from keyframe engine
const resolved = resolveKeyframes(el.keyframes, frame, { const resolved = resolveKeyframes(el.keyframes, frame, {
x: el.x, y: el.y, x: el.x, y: el.y,
@@ -94,10 +105,10 @@ export const CompositionElement: React.FC<CompositionElementProps> = ({
} else if (!el.keyframes) { } else if (!el.keyframes) {
// Legacy 2-point keyframes // Legacy 2-point keyframes
if (el.animEndX !== undefined) { if (el.animEndX !== undefined) {
currentX = interpolate(frame, [el.startFrame, el.endFrame], [tempX ?? el.x, el.animEndX], { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' }); currentX = interpolate(frame, [el.startFrame, el.endFrame], [el.x, el.animEndX], { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' });
} }
if (el.animEndY !== undefined) { if (el.animEndY !== undefined) {
currentY = interpolate(frame, [el.startFrame, el.endFrame], [tempY ?? el.y, el.animEndY], { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' }); currentY = interpolate(frame, [el.startFrame, el.endFrame], [el.y, el.animEndY], { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' });
} }
} }
@@ -131,48 +142,43 @@ export const CompositionElement: React.FC<CompositionElementProps> = ({
overflow: 'hidden', overflow: 'hidden',
} : undefined; } : undefined;
// ── Transform helpers ──
const startScaleDrag = (e: React.PointerEvent) => { // Adapters for the drag hooks
e.stopPropagation(); const getOrigPos = () => ({
if (!containerRef.current) return; x: currentX,
const rect = containerRef.current.getBoundingClientRect(); y: currentY,
onTransformStart( w: el.width ?? (el.type === 'text' ? undefined : 25) ?? 25,
el.id, 'scale', e.clientX, e.clientY, h: el.height ?? 25,
currentScale, currentRot, rotation: currentRot,
rect.left + (currentX / 100) * rect.width, });
rect.top + (currentY / 100) * rect.height
);
};
const startRotateDrag = (e: React.PointerEvent) => { const handleStartDrag = (e: React.PointerEvent) => {
e.stopPropagation();
if (!containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
onTransformStart(
el.id, 'rotate', e.clientX, e.clientY,
currentScale, currentRot,
rect.left + (currentX / 100) * rect.width,
rect.top + (currentY / 100) * rect.height
);
};
const startDrag = (e: React.PointerEvent) => {
if (!isInteractive) return; if (!isInteractive) return;
e.stopPropagation(); e.stopPropagation();
if (e.button === 2) return; if (e.button === 2) return;
if (onElementClick) onElementClick(el.id); if (onElementClick) onElementClick(el.id);
// In move mode: drag moves. In scale/rotate: start respective transform. if (activeAction === 'move' && onDragStart) {
if (activeAction === 'move') { onDragStart(e, el.id, getOrigPos());
onDragStart(el.id, e.clientX, e.clientY, currentX, currentY); } else if (activeAction === 'scale' && startResize) {
} else if (activeAction === 'scale') { startResize(e, el.id, getOrigPos());
startScaleDrag(e); } else if (activeAction === 'rotate' && startRotate) {
} else if (activeAction === 'rotate') { startRotate(e, el.id, getOrigPos());
startRotateDrag(e);
} }
}; };
const handleScaleDrag = (e: React.PointerEvent) => {
if (!isInteractive || !startResize) return;
e.stopPropagation();
startResize(e, el.id, getOrigPos());
};
const handleRotateDrag = (e: React.PointerEvent) => {
if (!isInteractive || !startRotate) return;
e.stopPropagation();
startRotate(e, el.id, getOrigPos());
};
// ── Selection outline color ── // ── Selection outline color ──
const outlineColor = el.isLocked ? '#d97706' : '#8b5cf6'; const outlineColor = el.isLocked ? '#d97706' : '#8b5cf6';
@@ -325,7 +331,7 @@ export const CompositionElement: React.FC<CompositionElementProps> = ({
opacity: opacity, opacity: opacity,
cursor: isInteractive cursor: isInteractive
? (activeAction === 'move' ? (activeAction === 'move'
? (dragStateId === el.id ? 'grabbing' : 'grab') ? 'grab'
: activeAction === 'scale' ? 'nwse-resize' : activeAction === 'scale' ? 'nwse-resize'
: activeAction === 'rotate' ? 'alias' : activeAction === 'rotate' ? 'alias'
: 'grab') : 'grab')
@@ -351,276 +357,29 @@ export const CompositionElement: React.FC<CompositionElementProps> = ({
e.stopPropagation(); e.stopPropagation();
if (isInteractive && onElementContextMenu) onElementContextMenu(el.id, e as unknown as React.MouseEvent); if (isInteractive && onElementContextMenu) onElementContextMenu(el.id, e as unknown as React.MouseEvent);
}} }}
onPointerDown={startDrag} onPointerDown={handleStartDrag}
> >
{/* ── Content ── */} {/* ── Visual Content ── */}
{el.type === 'text' ? ( <ElementRenderer
<div element={el}
style={{ designMD={designMD}
fontFamily: el.fontFamily ?? designMD.baseFont, displayContent={displayContent}
color: el.color ?? designMD.textColor, filterStr={filterStr}
fontSize: el.fontSize ? `${el.fontSize}px` : '56px', hasContainBg={hasContainBg}
fontWeight: el.fontWeight ?? 'bold', containBgStyle={containBgStyle}
fontStyle: el.fontStyle ?? 'normal', ckColor={ckColor}
textDecoration: el.textDecoration && el.textDecoration !== 'none' ? el.textDecoration : undefined, ckTolerance={ckTolerance}
textShadow: `${el.shadowOffset ?? 3}px ${el.shadowOffset ?? 3}px ${el.shadowBlur ?? 6}px ${el.shadowColor ?? 'rgba(0,0,0,0.8)'}`, ckSoftness={ckSoftness}
textAlign: el.textAlign ?? 'center', />
lineHeight: el.lineHeight ?? 1.2,
letterSpacing: el.letterSpacing ? `${el.letterSpacing}px` : undefined,
textTransform: el.textTransform ?? 'none',
WebkitTextStroke: el.textStrokeWidth
? `${el.textStrokeWidth}px ${el.textStrokeColor ?? '#000000'}`
: undefined,
// Gradient text (overrides solid color)
...(el.textGradient ? {
background: el.textGradient,
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
backgroundClip: 'text',
} : el.textBackground ? {
// Text background (pill/highlight)
background: el.textBackground,
padding: `${el.textBackgroundPadding ?? 8}px ${(el.textBackgroundPadding ?? 8) * 2}px`,
borderRadius: `${el.textBackgroundRadius ?? 4}px`,
display: 'inline-block',
} : {}),
whiteSpace: 'pre-wrap',
userSelect: 'none',
pointerEvents: 'none',
}}
>
{displayContent}
</div>
) : el.type === 'video' ? (
(() => {
const videoContent = el.chromaKeyEnabled ? (
<ChromaKeyVideo
src={el.content}
chromaKeyColor={ckColor}
chromaKeyTolerance={ckTolerance}
chromaKeySoftness={ckSoftness}
playbackRate={el.playbackRate}
style={{
width: '100%',
height: el.height ? '100%' : 'auto',
objectFit: el.objectFit ?? 'contain',
objectPosition: el.objectPosition ?? 'center center',
pointerEvents: 'none',
userSelect: 'none',
filter: filterStr,
}}
/>
) : (
<Video
src={el.content}
volume={el.volume ?? 1}
playbackRate={el.playbackRate ?? 1}
startFrom={el.trimStartSec ? Math.round(el.trimStartSec * 30) : undefined}
endAt={el.trimEndSec ? Math.round(el.trimEndSec * 30) : undefined}
style={{
width: '100%',
height: el.height ? '100%' : 'auto',
objectFit: el.objectFit ?? 'contain',
objectPosition: el.objectPosition ?? 'center center',
pointerEvents: 'none',
userSelect: 'none',
filter: filterStr,
}}
/>
);
return hasContainBg ? <div style={containBgStyle}>{videoContent}</div> : videoContent;
})()
) : el.type === 'shape' ? (
/* ── Shape Element (SVG) ── */
(() => {
const sw = el.width ?? 25;
const fill = el.shapeFill ?? '#ffffff';
const stroke = el.shapeStroke ?? 'none';
const strokeW = el.shapeStrokeWidth ?? 0;
const cr = el.shapeCornerRadius ?? 0;
const svgStyle: React.CSSProperties = {
width: '100%',
pointerEvents: 'none',
userSelect: 'none',
filter: filterStr,
};
switch (el.shapeType) {
case 'circle':
return (
<svg viewBox="0 0 100 100" style={svgStyle}>
<circle cx="50" cy="50" r={48 - strokeW / 2} fill={fill} stroke={stroke} strokeWidth={strokeW} />
</svg>
);
case 'triangle':
return (
<svg viewBox="0 0 100 100" style={svgStyle}>
<polygon points="50,2 98,98 2,98" fill={fill} stroke={stroke} strokeWidth={strokeW} strokeLinejoin="round" />
</svg>
);
case 'line':
return (
<svg viewBox="0 0 100 10" style={svgStyle} preserveAspectRatio="none">
<line x1="0" y1="5" x2="100" y2="5" stroke={stroke || fill} strokeWidth={strokeW || 3} strokeLinecap="round" />
</svg>
);
case 'arrow':
return (
<svg viewBox="0 0 100 40" style={svgStyle} preserveAspectRatio="none">
<line x1="0" y1="20" x2="80" y2="20" stroke={stroke || fill} strokeWidth={strokeW || 3} strokeLinecap="round" />
<polygon points="75,5 100,20 75,35" fill={stroke || fill} />
</svg>
);
case 'star':
return (
<svg viewBox="0 0 100 100" style={svgStyle}>
<polygon points="50,0 61,35 98,35 68,57 79,91 50,70 21,91 32,57 2,35 39,35" fill={fill} stroke={stroke} strokeWidth={strokeW} strokeLinejoin="round" />
</svg>
);
case 'rectangle':
default:
return (
<svg viewBox="0 0 100 100" style={svgStyle}>
<rect x={strokeW / 2} y={strokeW / 2} width={100 - strokeW} height={100 - strokeW} rx={cr} ry={cr} fill={fill} stroke={stroke} strokeWidth={strokeW} />
</svg>
);
}
})()
) : el.isPlaceholder ? (
/* ── Placeholder for empty media fields ── */
<div
style={{
width: '100%',
height: el.height ? `${el.height}%` : '100%',
aspectRatio: el.height ? undefined : '16/9',
border: '2px dashed rgba(255,255,255,0.2)',
borderRadius: 8,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 6,
background: 'rgba(255,255,255,0.03)',
pointerEvents: 'none',
userSelect: 'none',
}}
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="rgba(255,255,255,0.25)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
<circle cx="8.5" cy="8.5" r="1.5"/>
<polyline points="21 15 16 10 5 21"/>
</svg>
{el.placeholderLabel && (
<span style={{ fontSize: 11, color: 'rgba(255,255,255,0.25)', fontFamily: 'system-ui, sans-serif', textAlign: 'center' }}>
{el.placeholderLabel}
</span>
)}
</div>
) : (
(() => {
const imgContent = el.chromaKeyEnabled ? (
<ChromaKeyImage
src={el.content}
chromaKeyColor={ckColor}
chromaKeyTolerance={ckTolerance}
chromaKeySoftness={ckSoftness}
style={{
width: '100%',
height: el.height ? '100%' : 'auto',
objectFit: el.objectFit ?? 'contain',
objectPosition: el.objectPosition ?? 'center center',
pointerEvents: 'none',
userSelect: 'none',
filter: filterStr,
}}
draggable={false}
/>
) : (
<Img
src={el.content}
style={{
width: '100%',
height: el.height ? '100%' : 'auto',
objectFit: el.objectFit ?? 'contain',
objectPosition: el.objectPosition ?? 'center center',
pointerEvents: 'none',
userSelect: 'none',
filter: filterStr,
}}
draggable={false}
/>
);
return hasContainBg ? <div style={containBgStyle}>{imgContent}</div> : imgContent;
})()
)}
{/* ═══ Scale Handles — only in Scale mode ═══ */} {/* ── Editor Handles ── */}
{isSelected && activeAction === 'scale' && ( <ElementHandles
<> isSelected={isSelected}
{(['top-left', 'top-right', 'bottom-left', 'bottom-right'] as const).map((corner) => { activeAction={activeAction}
const isTop = corner.includes('top'); currentScale={currentScale}
const isLeft = corner.includes('left'); onScaleDrag={handleScaleDrag}
const cursorH = (isTop === isLeft) ? 'nwse-resize' : 'nesw-resize'; onRotateDrag={handleRotateDrag}
return ( />
<div
key={corner}
style={{
position: 'absolute',
[isTop ? 'top' : 'bottom']: -7 / currentScale,
[isLeft ? 'left' : 'right']: -7 / currentScale,
width: 14 / currentScale,
height: 14 / currentScale,
background: '#fff',
border: `${Math.max(1, 2 / currentScale)}px solid #8b5cf6`,
borderRadius: 3 / currentScale,
cursor: cursorH,
pointerEvents: 'auto',
zIndex: 10,
boxShadow: '0 2px 4px rgba(0,0,0,0.3)',
}}
onPointerDown={startScaleDrag}
title="Redimensionar"
/>
);
})}
</>
)}
{/* ═══ Rotate Handle — only in Rotate mode ═══ */}
{isSelected && activeAction === 'rotate' && (
<>
{/* Connector line from element bottom to rotate handle */}
<div
style={{
position: 'absolute', bottom: -24 / currentScale, left: '50%',
transform: `translateX(-50%) scaleY(${1 / currentScale})`,
transformOrigin: 'top center',
width: 1, height: 20,
background: '#8b5cf6',
pointerEvents: 'none',
zIndex: 9,
}}
/>
{/* Rotate handle circle */}
<div
style={{
position: 'absolute', bottom: -38 / currentScale, left: '50%',
transform: `translateX(-50%) scale(${1 / currentScale})`,
transformOrigin: 'top center',
width: 22, height: 22,
background: '#fff', border: '2px solid #8b5cf6',
borderRadius: '50%', cursor: 'grab',
pointerEvents: 'auto',
display: 'flex', alignItems: 'center', justifyContent: 'center',
zIndex: 51,
boxShadow: '0 2px 8px rgba(0,0,0,0.4)',
}}
onPointerDown={startRotateDrag}
title="Arrastra para rotar"
>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="#8b5cf6" strokeWidth="3"><path d="M21.5 2v6h-6M21.34 15.57a10 10 0 1 1-.59-9.21l5.67-4.24"/></svg>
</div>
</>
)}
</div> </div>
</AbsoluteFill> </AbsoluteFill>
)} )}
@@ -0,0 +1,93 @@
import React from 'react';
import type { CanvasActionMode } from './ElementActionToolbar';
interface ElementHandlesProps {
isSelected: boolean;
activeAction: CanvasActionMode;
currentScale: number;
onScaleDrag: (e: React.PointerEvent) => void;
onRotateDrag: (e: React.PointerEvent) => void;
}
export const ElementHandles: React.FC<ElementHandlesProps> = ({
isSelected,
activeAction,
currentScale,
onScaleDrag,
onRotateDrag,
}) => {
if (!isSelected) return null;
return (
<>
{/* ═══ Scale Handles — only in Scale mode ═══ */}
{activeAction === 'scale' && (
<>
{(['top-left', 'top-right', 'bottom-left', 'bottom-right'] as const).map((corner) => {
const isTop = corner.includes('top');
const isLeft = corner.includes('left');
const cursorH = (isTop === isLeft) ? 'nwse-resize' : 'nesw-resize';
return (
<div
key={corner}
style={{
position: 'absolute',
[isTop ? 'top' : 'bottom']: -7 / currentScale,
[isLeft ? 'left' : 'right']: -7 / currentScale,
width: 14 / currentScale,
height: 14 / currentScale,
background: '#fff',
border: `${Math.max(1, 2 / currentScale)}px solid #8b5cf6`,
borderRadius: 3 / currentScale,
cursor: cursorH,
pointerEvents: 'auto',
zIndex: 10,
boxShadow: '0 2px 4px rgba(0,0,0,0.3)',
}}
onPointerDown={onScaleDrag}
title="Redimensionar"
/>
);
})}
</>
)}
{/* ═══ Rotate Handle — only in Rotate mode ═══ */}
{activeAction === 'rotate' && (
<>
{/* Connector line from element bottom to rotate handle */}
<div
style={{
position: 'absolute', bottom: -24 / currentScale, left: '50%',
transform: `translateX(-50%) scaleY(${1 / currentScale})`,
transformOrigin: 'top center',
width: 1, height: 20,
background: '#8b5cf6',
pointerEvents: 'none',
zIndex: 9,
}}
/>
{/* Rotate handle circle */}
<div
style={{
position: 'absolute', bottom: -38 / currentScale, left: '50%',
transform: `translateX(-50%) scale(${1 / currentScale})`,
transformOrigin: 'top center',
width: 22, height: 22,
background: '#fff', border: '2px solid #8b5cf6',
borderRadius: '50%', cursor: 'grab',
pointerEvents: 'auto',
display: 'flex', alignItems: 'center', justifyContent: 'center',
zIndex: 51,
boxShadow: '0 2px 8px rgba(0,0,0,0.4)',
}}
onPointerDown={onRotateDrag}
title="Arrastra para rotar"
>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="#8b5cf6" strokeWidth="3"><path d="M21.5 2v6h-6M21.34 15.57a10 10 0 1 1-.59-9.21l5.67-4.24"/></svg>
</div>
</>
)}
</>
);
};
@@ -0,0 +1,230 @@
import React from 'react';
import { Img, Video } from '../../engine/components';
import { TimelineElement, DesignMD } from '../../types';
import { ChromaKeyImage } from './ChromaKeyImage';
import { ChromaKeyVideo } from './ChromaKeyVideo';
interface ElementRendererProps {
element: TimelineElement;
designMD: DesignMD;
displayContent: string;
filterStr: string;
opacity?: number;
hasContainBg: boolean;
containBgStyle?: React.CSSProperties;
ckColor: string;
ckTolerance: number;
ckSoftness: number;
}
export const ElementRenderer: React.FC<ElementRendererProps> = ({
element: el,
designMD,
displayContent,
filterStr,
hasContainBg,
containBgStyle,
ckColor,
ckTolerance,
ckSoftness,
}) => {
if (el.type === 'text') {
return (
<div
style={{
fontFamily: el.fontFamily ?? designMD.baseFont,
color: el.color ?? designMD.textColor,
fontSize: el.fontSize ? `${el.fontSize}px` : '56px',
fontWeight: el.fontWeight ?? 'bold',
fontStyle: el.fontStyle ?? 'normal',
textDecoration: el.textDecoration && el.textDecoration !== 'none' ? el.textDecoration : undefined,
textShadow: `${el.shadowOffset ?? 3}px ${el.shadowOffset ?? 3}px ${el.shadowBlur ?? 6}px ${el.shadowColor ?? 'rgba(0,0,0,0.8)'}`,
textAlign: el.textAlign ?? 'center',
lineHeight: el.lineHeight ?? 1.2,
letterSpacing: el.letterSpacing ? `${el.letterSpacing}px` : undefined,
textTransform: el.textTransform ?? 'none',
WebkitTextStroke: el.textStrokeWidth
? `${el.textStrokeWidth}px ${el.textStrokeColor ?? '#000000'}`
: undefined,
...(el.textGradient ? {
background: el.textGradient,
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
backgroundClip: 'text',
} : el.textBackground ? {
background: el.textBackground,
padding: `${el.textBackgroundPadding ?? 8}px ${(el.textBackgroundPadding ?? 8) * 2}px`,
borderRadius: `${el.textBackgroundRadius ?? 4}px`,
display: 'inline-block',
} : {}),
whiteSpace: 'pre-wrap',
userSelect: 'none',
pointerEvents: 'none',
}}
>
{displayContent}
</div>
);
}
if (el.type === 'video') {
const videoContent = el.chromaKeyEnabled ? (
<ChromaKeyVideo
src={el.content}
chromaKeyColor={ckColor}
chromaKeyTolerance={ckTolerance}
chromaKeySoftness={ckSoftness}
playbackRate={el.playbackRate}
style={{
width: '100%',
height: el.height ? '100%' : 'auto',
objectFit: el.objectFit ?? 'contain',
objectPosition: el.objectPosition ?? 'center center',
pointerEvents: 'none',
userSelect: 'none',
filter: filterStr,
}}
/>
) : (
<Video
src={el.content}
volume={el.volume ?? 1}
playbackRate={el.playbackRate ?? 1}
startFrom={el.trimStartSec ? Math.round(el.trimStartSec * 30) : undefined}
endAt={el.trimEndSec ? Math.round(el.trimEndSec * 30) : undefined}
style={{
width: '100%',
height: el.height ? '100%' : 'auto',
objectFit: el.objectFit ?? 'contain',
objectPosition: el.objectPosition ?? 'center center',
pointerEvents: 'none',
userSelect: 'none',
filter: filterStr,
}}
/>
);
return hasContainBg ? <div style={containBgStyle}>{videoContent}</div> : videoContent;
}
if (el.type === 'shape') {
const sw = el.width ?? 25;
const fill = el.shapeFill ?? '#ffffff';
const stroke = el.shapeStroke ?? 'none';
const strokeW = el.shapeStrokeWidth ?? 0;
const cr = el.shapeCornerRadius ?? 0;
const svgStyle: React.CSSProperties = {
width: '100%',
pointerEvents: 'none',
userSelect: 'none',
filter: filterStr,
};
switch (el.shapeType) {
case 'circle':
return (
<svg viewBox="0 0 100 100" style={svgStyle}>
<circle cx="50" cy="50" r={48 - strokeW / 2} fill={fill} stroke={stroke} strokeWidth={strokeW} />
</svg>
);
case 'triangle':
return (
<svg viewBox="0 0 100 100" style={svgStyle}>
<polygon points="50,2 98,98 2,98" fill={fill} stroke={stroke} strokeWidth={strokeW} strokeLinejoin="round" />
</svg>
);
case 'line':
return (
<svg viewBox="0 0 100 10" style={svgStyle} preserveAspectRatio="none">
<line x1="0" y1="5" x2="100" y2="5" stroke={stroke || fill} strokeWidth={strokeW || 3} strokeLinecap="round" />
</svg>
);
case 'arrow':
return (
<svg viewBox="0 0 100 40" style={svgStyle} preserveAspectRatio="none">
<line x1="0" y1="20" x2="80" y2="20" stroke={stroke || fill} strokeWidth={strokeW || 3} strokeLinecap="round" />
<polygon points="75,5 100,20 75,35" fill={stroke || fill} />
</svg>
);
case 'star':
return (
<svg viewBox="0 0 100 100" style={svgStyle}>
<polygon points="50,0 61,35 98,35 68,57 79,91 50,70 21,91 32,57 2,35 39,35" fill={fill} stroke={stroke} strokeWidth={strokeW} strokeLinejoin="round" />
</svg>
);
case 'rectangle':
default:
return (
<svg viewBox="0 0 100 100" style={svgStyle}>
<rect x={strokeW / 2} y={strokeW / 2} width={100 - strokeW} height={100 - strokeW} rx={cr} ry={cr} fill={fill} stroke={stroke} strokeWidth={strokeW} />
</svg>
);
}
}
if (el.isPlaceholder) {
return (
<div
style={{
width: '100%',
height: el.height ? `${el.height}%` : '100%',
aspectRatio: el.height ? undefined : '16/9',
border: '2px dashed rgba(255,255,255,0.2)',
borderRadius: 8,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 6,
background: 'rgba(255,255,255,0.03)',
pointerEvents: 'none',
userSelect: 'none',
}}
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="rgba(255,255,255,0.25)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
<circle cx="8.5" cy="8.5" r="1.5"/>
<polyline points="21 15 16 10 5 21"/>
</svg>
{el.placeholderLabel && (
<span style={{ fontSize: 11, color: 'rgba(255,255,255,0.25)', fontFamily: 'system-ui, sans-serif', textAlign: 'center' }}>
{el.placeholderLabel}
</span>
)}
</div>
);
}
// Fallback to Image
const imgContent = el.chromaKeyEnabled ? (
<ChromaKeyImage
src={el.content}
chromaKeyColor={ckColor}
chromaKeyTolerance={ckTolerance}
chromaKeySoftness={ckSoftness}
style={{
width: '100%',
height: el.height ? '100%' : 'auto',
objectFit: el.objectFit ?? 'contain',
objectPosition: el.objectPosition ?? 'center center',
pointerEvents: 'none',
userSelect: 'none',
filter: filterStr,
}}
draggable={false}
/>
) : (
<Img
src={el.content}
style={{
width: '100%',
height: el.height ? '100%' : 'auto',
objectFit: el.objectFit ?? 'contain',
objectPosition: el.objectPosition ?? 'center center',
pointerEvents: 'none',
userSelect: 'none',
filter: filterStr,
}}
draggable={false}
/>
);
return hasContainBg ? <div style={containBgStyle}>{imgContent}</div> : imgContent;
};
-235
View File
@@ -1,235 +0,0 @@
import React, { useState, useEffect, useRef, RefObject } from 'react';
import { TimelineElement } from '../../types';
interface CanvasDragState {
id: string;
startX: number;
startY: number;
initialElX: number;
initialElY: number;
}
interface TransformDragState {
id: string;
type: 'scale' | 'rotate';
startX: number;
startY: number;
initialScale: number;
initialRot: number;
centerX: number;
centerY: number;
}
interface TempPosition {
x: number;
y: number;
scale?: number;
rotation?: number;
}
interface Guides {
x: number | null;
y: number | null;
}
interface UseCanvasDragReturn {
containerRef: RefObject<HTMLDivElement>;
dragState: CanvasDragState | null;
setDragState: React.Dispatch<React.SetStateAction<CanvasDragState | null>>;
transformDragState: TransformDragState | null;
setTransformDragState: React.Dispatch<React.SetStateAction<TransformDragState | null>>;
tempPositions: Record<string, TempPosition>;
guides: Guides;
}
export function useCanvasDrag(
timelineElements: TimelineElement[],
onElementPositionChange?: (id: string, x: number, y: number) => void,
onElementTransformChange?: (id: string, updates: Partial<TimelineElement>) => void
): UseCanvasDragReturn {
const containerRef = useRef<HTMLDivElement>(null);
const [dragState, setDragState] = useState<CanvasDragState | null>(null);
const [transformDragState, setTransformDragState] = useState<TransformDragState | null>(null);
const [guides, setGuides] = useState<Guides>({ x: null, y: null });
const [tempPositions, setTempPositions] = useState<Record<string, TempPosition>>({});
// Stable refs to avoid effect re-runs
const tempPositionsRef = useRef(tempPositions);
tempPositionsRef.current = tempPositions;
const elementsRef = useRef(timelineElements);
elementsRef.current = timelineElements;
const onPosChangeRef = useRef(onElementPositionChange);
onPosChangeRef.current = onElementPositionChange;
const onTransformChangeRef = useRef(onElementTransformChange);
onTransformChangeRef.current = onElementTransformChange;
useEffect(() => {
if (!dragState && !transformDragState) return;
let rafId: number | null = null;
const handlePointerMove = (e: PointerEvent) => {
if (rafId) return; // Throttle to 60fps via rAF
rafId = requestAnimationFrame(() => {
rafId = null;
handleDragUpdate(e);
});
};
const handleDragUpdate = (e: PointerEvent) => {
if (!containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
if (transformDragState) {
if (transformDragState.type === 'scale') {
// Distance-from-center approach: works correctly from ANY corner.
// Compare distance from element center at start vs now.
const startDist = Math.sqrt(
Math.pow(transformDragState.startX - transformDragState.centerX, 2) +
Math.pow(transformDragState.startY - transformDragState.centerY, 2)
);
const currentDist = Math.sqrt(
Math.pow(e.clientX - transformDragState.centerX, 2) +
Math.pow(e.clientY - transformDragState.centerY, 2)
);
// Ratio: if pointer moves farther from center → bigger, closer → smaller
const distRatio = startDist > 0 ? currentDist / startDist : 1;
const ratio = Math.max(0.05, transformDragState.initialScale * distRatio);
const el = elementsRef.current.find(e => e.id === transformDragState.id);
setTempPositions(prev => ({
...prev,
[transformDragState.id]: {
x: prev[transformDragState.id]?.x ?? el?.x ?? 50,
y: prev[transformDragState.id]?.y ?? el?.y ?? 50,
scale: ratio,
rotation: prev[transformDragState.id]?.rotation,
}
}));
} else if (transformDragState.type === 'rotate') {
const currentAngle = Math.atan2(e.clientY - transformDragState.centerY, e.clientX - transformDragState.centerX);
const initialAngle = Math.atan2(transformDragState.startY - transformDragState.centerY, transformDragState.startX - transformDragState.centerX);
const diff = (currentAngle - initialAngle) * (180 / Math.PI);
const newRot = transformDragState.initialRot + diff;
const el = elementsRef.current.find(e => e.id === transformDragState.id);
setTempPositions(prev => ({
...prev,
[transformDragState.id]: {
x: prev[transformDragState.id]?.x ?? el?.x ?? 50,
y: prev[transformDragState.id]?.y ?? el?.y ?? 50,
scale: prev[transformDragState.id]?.scale,
rotation: newRot,
}
}));
}
return;
}
if (dragState) {
// Convert pixel delta to percentage of container
const dxPct = (rect.width > 0) ? ((e.clientX - dragState.startX) / rect.width) * 100 : 0;
const dyPct = (rect.height > 0) ? ((e.clientY - dragState.startY) / rect.height) * 100 : 0;
let newX = dragState.initialElX + dxPct;
let newY = dragState.initialElY + dyPct;
// Allow elements to go slightly out of bounds for edge positioning
newX = Math.max(-20, Math.min(120, newX));
newY = Math.max(-20, Math.min(120, newY));
// Snapping logic (Smart Guides)
let snapX: number | null = null;
let snapY: number | null = null;
const snapThreshold = 1.5;
// Snap to center
if (Math.abs(newX - 50) < snapThreshold) { newX = 50; snapX = 50; }
if (Math.abs(newY - 50) < snapThreshold) { newY = 50; snapY = 50; }
// Snap to edges
if (Math.abs(newX) < snapThreshold) { newX = 0; snapX = 0; }
if (Math.abs(newX - 100) < snapThreshold) { newX = 100; snapX = 100; }
if (Math.abs(newY) < snapThreshold) { newY = 0; snapY = 0; }
if (Math.abs(newY - 100) < snapThreshold) { newY = 100; snapY = 100; }
// Snap to quarter grid (25%, 75%)
for (const q of [25, 75]) {
if (Math.abs(newX - q) < snapThreshold) { newX = q; snapX = q; }
if (Math.abs(newY - q) < snapThreshold) { newY = q; snapY = q; }
}
// Snap to other elements (center and edges)
elementsRef.current.forEach(el => {
if (el.id !== dragState.id) {
// Center snap
if (Math.abs(newX - el.x) < snapThreshold) { newX = el.x; snapX = el.x; }
if (Math.abs(newY - el.y) < snapThreshold) { newY = el.y; snapY = el.y; }
}
});
setGuides({ x: snapX, y: snapY });
setTempPositions(prev => ({
...prev,
[dragState.id]: {
...prev[dragState.id],
x: newX,
y: newY
}
}));
}
};
const handlePointerUp = () => {
const temps = tempPositionsRef.current;
if (transformDragState && onTransformChangeRef.current) {
const temp = temps[transformDragState.id];
if (temp) {
const updates: Partial<TimelineElement> = {};
if (temp.scale !== undefined) updates.scale = temp.scale;
if (temp.rotation !== undefined) updates.rotation = temp.rotation;
onTransformChangeRef.current(transformDragState.id, updates);
}
} else if (dragState && onPosChangeRef.current && temps[dragState.id]) {
onPosChangeRef.current(
dragState.id,
temps[dragState.id].x,
temps[dragState.id].y
);
}
const currentDragId = dragState?.id;
const currentTransformId = transformDragState?.id;
setDragState(null);
setTransformDragState(null);
setGuides({ x: null, y: null });
// Clean up temp positions after a short delay to avoid flicker
setTimeout(() => setTempPositions(prev => {
const next = { ...prev };
if (currentDragId) delete next[currentDragId];
if (currentTransformId) delete next[currentTransformId];
return next;
}), 30);
};
window.addEventListener('pointermove', handlePointerMove);
window.addEventListener('pointerup', handlePointerUp);
return () => {
if (rafId) cancelAnimationFrame(rafId);
window.removeEventListener('pointermove', handlePointerMove);
window.removeEventListener('pointerup', handlePointerUp);
};
}, [dragState, transformDragState]);
return {
containerRef,
dragState,
setDragState,
transformDragState,
setTransformDragState,
tempPositions,
guides,
};
}
@@ -220,7 +220,7 @@ export const BatchPreviewGrid: React.FC<BatchPreviewGridProps> = ({
const carouselBradlyPlayerRef = useRef<BradlyPlayerRef>(null); const carouselBradlyPlayerRef = useRef<BradlyPlayerRef>(null);
const dimensions = useMemo(() => getAspectDimensions(template.aspectRatio), [template.aspectRatio]); const dimensions = useMemo(() => getAspectDimensions(template.aspectRatio), [template.aspectRatio]);
const totalDuration = useMemo(() => getTemplateDuration(template), [template]); const totalDuration = useMemo(() => getTemplateDuration(template, undefined, brand.design), [template, brand]);
const totalFrames = Math.max(30, totalDuration * 30); const totalFrames = Math.max(30, totalDuration * 30);
const backgroundFieldId = useMemo(() => findBackgroundFieldId(template), [template]); const backgroundFieldId = useMemo(() => findBackgroundFieldId(template), [template]);
const isVideo = template.format === 'video'; const isVideo = template.format === 'video';
+1 -1
View File
@@ -103,7 +103,7 @@ export const ProductionForm: React.FC<ProductionFormProps> = ({
const designMD = brand.design; const designMD = brand.design;
const fps = 30; const fps = 30;
const totalDuration = getTemplateDuration(template, videoDurations); const totalDuration = getTemplateDuration(template, videoDurations, designMD);
const totalFrames = Math.max(30, totalDuration * fps); const totalFrames = Math.max(30, totalDuration * fps);
// ─── Compile for ExportModal (only when modal is open — LivePreviewCanvas handles its own compile) ─── // ─── Compile for ExportModal (only when modal is open — LivePreviewCanvas handles its own compile) ───
+46 -17
View File
@@ -1,6 +1,7 @@
import React, { useState, useMemo } from 'react'; import React, { useState, useMemo } from 'react';
import { X, Download, Film, Image as ImageIcon, Wifi, WifiOff, Zap, ChevronDown } from 'lucide-react'; import { X, Download, Film, Image as ImageIcon, Wifi, WifiOff, Zap, ChevronDown } from 'lucide-react';
import { useExportQueue, RenderFormat, ExportConfig } from '../../hooks/useExportQueue'; import { useExportQueue, RenderFormat, ExportConfig } from '../../context/ExportQueueContext';
import { useToast } from '../ui/ToastProvider';
import { ExportJobItem } from './ExportJobItem'; import { ExportJobItem } from './ExportJobItem';
import type { DesignMD, TimelineElement, TimelineLayer } from '../../types'; import type { DesignMD, TimelineElement, TimelineLayer } from '../../types';
@@ -57,6 +58,7 @@ export const ExportModal: React.FC<ExportModalProps> = ({
}) => { }) => {
const { jobs, activeJobs, hasActiveJobs, isConnected, startExport, cancelJob, downloadJob } = useExportQueue(); const { jobs, activeJobs, hasActiveJobs, isConnected, startExport, cancelJob, downloadJob } = useExportQueue();
const { showToast } = useToast();
const [format, setFormat] = useState<RenderFormat>('mp4'); const [format, setFormat] = useState<RenderFormat>('mp4');
const [fps, setFps] = useState(30); const [fps, setFps] = useState(30);
const [isExporting, setIsExporting] = useState(false); const [isExporting, setIsExporting] = useState(false);
@@ -92,17 +94,43 @@ export const ExportModal: React.FC<ExportModalProps> = ({
const [resIdx, setResIdx] = useState(0); const [resIdx, setResIdx] = useState(0);
const selectedRes = filteredPresets[resIdx] || filteredPresets[0]; const selectedRes = filteredPresets[resIdx] || filteredPresets[0];
// The app's internal timeline is always calculated at a base of 30 fps
const BASE_FPS = 30;
const durationSeconds = durationInFrames / BASE_FPS;
const actualFrames = isStill ? 1 : Math.round(durationSeconds * fps);
// Scale timeline elements to match selected FPS
const scaledTimelineElements = useMemo(() => {
const fpsScale = fps / BASE_FPS;
if (fpsScale === 1) return timelineElements;
return timelineElements.map(el => {
const scaledEl = {
...el,
startFrame: Math.round(el.startFrame * fpsScale),
endFrame: Math.round(el.endFrame * fpsScale),
};
if (scaledEl.transitionIn) {
scaledEl.transitionIn = {
...scaledEl.transitionIn,
duration: Math.round(scaledEl.transitionIn.duration * fpsScale)
};
}
return scaledEl;
});
}, [timelineElements, fps]);
// Estimated file size // Estimated file size
const estimatedSize = useMemo(() => { const estimatedSize = useMemo(() => {
if (isStill) return '~0.5 MB'; if (isStill) return '~0.5 MB';
const seconds = durationInFrames / fps;
const pixels = selectedRes.w * selectedRes.h; const pixels = selectedRes.w * selectedRes.h;
const bitrateMap: Record<string, number> = { mp4: 5, webm: 3, gif: 15 }; const bitrateMap: Record<string, number> = { mp4: 5, webm: 3, gif: 15 };
const bitrate = bitrateMap[format] ?? 5; // MB per minute per megapixel const bitrate = bitrateMap[format] ?? 5; // MB per minute per megapixel
const mp = pixels / 1_000_000; const mp = pixels / 1_000_000;
const sizeMB = (seconds / 60) * bitrate * mp; const sizeMB = (durationSeconds / 60) * bitrate * mp;
return sizeMB < 1 ? `~${Math.round(sizeMB * 1024)} KB` : `~${sizeMB.toFixed(1)} MB`; return sizeMB < 1 ? `~${Math.round(sizeMB * 1024)} KB` : `~${sizeMB.toFixed(1)} MB`;
}, [format, selectedRes, durationInFrames, fps, isStill]); }, [format, selectedRes, durationSeconds, fps, isStill]);
// Auto-select image format in image mode // Auto-select image format in image mode
const filteredFormats = useMemo(() => { const filteredFormats = useMemo(() => {
@@ -119,15 +147,6 @@ export const ExportModal: React.FC<ExportModalProps> = ({
} }
}, [filteredFormats, format]); }, [filteredFormats, format]);
// Track finished jobs to call onAssetSaved automatically
React.useEffect(() => {
if (!onAssetSaved) return;
const completedJob = jobs.find(j => j.status === 'completed' && j.resultUrl);
if (completedJob && completedJob.resultUrl) {
onAssetSaved(completedJob.resultUrl);
}
}, [jobs, onAssetSaved]);
const handleExport = async () => { const handleExport = async () => {
setIsExporting(true); setIsExporting(true);
try { try {
@@ -136,15 +155,25 @@ export const ExportModal: React.FC<ExportModalProps> = ({
width: selectedRes.w, width: selectedRes.w,
height: selectedRes.h, height: selectedRes.h,
fps, fps,
durationInFrames: isStill ? 1 : durationInFrames, durationInFrames: actualFrames,
designMD, designMD,
textOverlay, textOverlay,
timelineElements, timelineElements: scaledTimelineElements,
layers, layers,
brandVisibility, brandVisibility,
outputFormat, outputFormat,
}; };
await startExport(config);
const job = await startExport(config, {
onSuccess: onAssetSaved ? (url) => {
onAssetSaved(url);
} : undefined
});
if (job) {
showToast('Renderizado en segundo plano iniciado', 'info');
onClose(); // Close modal immediately so user can keep interacting
}
} finally { } finally {
setIsExporting(false); setIsExporting(false);
} }
@@ -266,7 +295,7 @@ export const ExportModal: React.FC<ExportModalProps> = ({
</div> </div>
<div className="flex items-center justify-between text-[10px]"> <div className="flex items-center justify-between text-[10px]">
<span className="text-neutral-500">Duración</span> <span className="text-neutral-500">Duración</span>
<span className="text-neutral-400 font-mono">{(durationInFrames / fps).toFixed(1)}s ({durationInFrames} frames)</span> <span className="text-neutral-400 font-mono">{durationSeconds.toFixed(1)}s ({actualFrames} frames)</span>
</div> </div>
{/* Quality Tier */} {/* Quality Tier */}
<div> <div>
@@ -0,0 +1,127 @@
import React, { useState } from 'react';
import { useExportQueue } from '../../context/ExportQueueContext';
import { Loader2, Download, Film, Image as ImageIcon, X, Zap } from 'lucide-react';
export const GlobalExportWidget: React.FC = () => {
const { jobs, activeJobs, hasActiveJobs, downloadJob, cancelJob } = useExportQueue();
const [isOpen, setIsOpen] = useState(false);
if (jobs.length === 0) return null;
return (
<div className="fixed bottom-4 right-4 z-50 flex flex-col items-end gap-2" style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}>
{/* Popover list of jobs */}
{isOpen && (
<div className="w-80 bg-neutral-900 border border-neutral-700/50 rounded-xl shadow-2xl shadow-black/80 overflow-hidden flex flex-col animate-in slide-in-from-bottom-2 fade-in">
<div className="flex items-center justify-between p-3 border-b border-neutral-800 bg-neutral-900">
<h3 className="text-xs font-semibold text-white flex items-center gap-2">
<Zap size={14} className="text-violet-400" />
Cola de Renderizado
</h3>
<button
onClick={() => setIsOpen(false)}
className="text-neutral-500 hover:text-white p-1 rounded-md hover:bg-neutral-800 transition-colors"
>
<X size={14} />
</button>
</div>
<div className="max-h-[300px] overflow-y-auto custom-scrollbar bg-neutral-950 p-2 space-y-2">
{jobs.map(job => {
const isDone = job.status === 'done';
const isError = job.status === 'error';
const isWorking = job.status === 'rendering' || job.status === 'queued';
const Icon = job.format === 'png' || job.format === 'jpeg' ? ImageIcon : Film;
return (
<div key={job.id} className="bg-neutral-900 border border-neutral-800 rounded-lg p-3 text-xs flex flex-col gap-2">
<div className="flex items-start justify-between">
<div className="flex items-center gap-2">
<Icon size={14} className={isDone ? 'text-green-400' : isError ? 'text-red-400' : 'text-violet-400'} />
<span className="font-medium text-neutral-200">
{job.format.toUpperCase()} ({job.width}x{job.height})
</span>
</div>
{isWorking && (
<button
onClick={() => cancelJob(job.id)}
className="text-neutral-500 hover:text-red-400 p-1"
title="Cancelar render"
>
<X size={12} />
</button>
)}
</div>
{isWorking && (
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between text-[10px]">
<span className="text-neutral-400">
{job.status === 'queued' ? 'En cola...' : 'Renderizando...'}
</span>
<span className="font-mono text-violet-400">{job.progress}%</span>
</div>
<div className="h-1.5 w-full bg-neutral-800 rounded-full overflow-hidden">
<div
className="h-full bg-violet-500 transition-all duration-300"
style={{ width: `${job.progress}%` }}
/>
</div>
</div>
)}
{isDone && (
<div className="flex items-center justify-between mt-1">
<span className="text-[10px] text-green-400 bg-green-500/10 px-2 py-0.5 rounded font-medium">Completado</span>
<button
onClick={() => downloadJob(job)}
className="flex items-center gap-1 text-[10px] bg-violet-600 hover:bg-violet-500 text-white px-2 py-1 rounded transition-colors"
>
<Download size={12} /> Descargar
</button>
</div>
)}
{isError && (
<div className="mt-1">
<span className="text-[10px] text-red-400 bg-red-500/10 px-2 py-0.5 rounded font-medium">
{job.error || 'Error al renderizar'}
</span>
</div>
)}
</div>
);
})}
</div>
</div>
)}
{/* Floating Toggle Button */}
<button
onClick={() => setIsOpen(!isOpen)}
className={`flex items-center gap-2 px-4 py-2.5 rounded-full shadow-lg shadow-black/50 transition-all border ${
hasActiveJobs
? 'bg-neutral-900 border-violet-500/50 text-white'
: 'bg-neutral-900 border-neutral-700/50 text-neutral-400 hover:text-white hover:border-neutral-600'
}`}
>
{hasActiveJobs ? (
<>
<Loader2 size={16} className="animate-spin text-violet-400" />
<span className="text-xs font-semibold">
{activeJobs.length} renderizando...
</span>
<span className="text-[10px] font-mono text-violet-300 bg-violet-500/20 px-1.5 py-0.5 rounded ml-1">
{activeJobs[0]?.progress || 0}%
</span>
</>
) : (
<>
<Zap size={16} />
<span className="text-xs font-medium">Cola de Render ({jobs.length})</span>
</>
)}
</button>
</div>
);
};
+1 -1
View File
@@ -72,7 +72,7 @@ export const ExpressEditor: React.FC<ExpressEditorProps> = ({
return compileExpressToTimeline(selectedTemplate, fieldData, designMD, company, videoDurations); return compileExpressToTimeline(selectedTemplate, fieldData, designMD, company, videoDurations);
}, [selectedTemplate, fieldData, designMD, company, videoDurations]); }, [selectedTemplate, fieldData, designMD, company, videoDurations]);
const totalDuration = selectedTemplate ? getTemplateDuration(selectedTemplate, videoDurations) : 0; const totalDuration = selectedTemplate ? getTemplateDuration(selectedTemplate, videoDurations, designMD) : 0;
const fps = 30; const fps = 30;
const totalFrames = Math.max(30, totalDuration * fps); const totalFrames = Math.max(30, totalDuration * fps);
@@ -1,321 +0,0 @@
import React from 'react';
import { X, Type, Image as ImageIcon, Plus, Trash2, Zap, Clock, Layers, Sparkles, Globe, Instagram, AtSign } from 'lucide-react';
import { ExpressScene, ExpressField, SceneLayout, BrandContentPiece, DesignMD, TimelineElement } from '../../../types';
import { useEditor } from '../../../context/EditorContext';
import { CollapsibleSection } from '../../ui/CollapsibleSection';
interface BuilderScenePanelProps {
onClose: () => void;
scene: ExpressScene;
onUpdateScene: (updated: ExpressScene) => void;
brandContent: BrandContentPiece[];
designMD: DesignMD;
isVideo: boolean;
}
/** Layout options */
const LAYOUTS: { value: SceneLayout; label: string; icon: string }[] = [
{ value: 'fullscreen-media', label: 'Pantalla completa', icon: '📸' },
{ value: 'overlay', label: 'Overlay', icon: '🔲' },
{ value: 'split', label: 'Dividido', icon: '◫' },
{ value: 'media-left', label: 'Media izq.', icon: '◧' },
{ value: 'media-right', label: 'Media der.', icon: '◨' },
{ value: 'text-only', label: 'Solo texto', icon: '📝' },
];
/** Brand variables available for insertion */
const BRAND_VARIABLES: { source: ExpressField['brandSource']; label: string; icon: React.ReactNode; type: ExpressField['type'] }[] = [
{ source: 'brand-name', label: 'Nombre de Marca', icon: <Type size={10} />, type: 'text' },
{ source: 'tagline', label: 'Tagline / Eslogan', icon: <Sparkles size={10} />, type: 'text' },
{ source: 'logo', label: 'Logo', icon: <Zap size={10} />, type: 'logo' },
{ source: 'instagram', label: 'Instagram', icon: <Instagram size={10} />, type: 'text' },
{ source: 'tiktok', label: 'TikTok', icon: <AtSign size={10} />, type: 'text' },
{ source: 'twitter', label: 'X / Twitter', icon: <AtSign size={10} />, type: 'text' },
{ source: 'youtube', label: 'YouTube', icon: <AtSign size={10} />, type: 'text' },
{ source: 'website', label: 'Website', icon: <Globe size={10} />, type: 'text' },
];
/**
* BuilderScenePanel — Sliding panel for scene-specific configuration.
*
* This is the template-builder counterpart of TextPanel/ShapesPanel.
* It manages scene metadata (name, type, duration, background, layout)
* and lets users add brand variables and brand assets as TimelineElements.
*/
export const BuilderScenePanel: React.FC<BuilderScenePanelProps> = ({
onClose,
scene,
onUpdateScene,
brandContent,
designMD,
isVideo,
}) => {
const {
setTimelineElements,
setSelectedElementId,
layers,
activeLayerId,
durationInFrames,
} = useEditor();
// ── Add a brand-variable element to the canvas via EditorContext ──
const addBrandField = (
type: ExpressField['type'],
label: string,
brandSource?: ExpressField['brandSource'],
) => {
const newId = 'el-' + Date.now();
const elType: TimelineElement['type'] = type === 'text' ? 'text' : 'image';
// Determine target layer
let targetLayerId = activeLayerId;
const activeLayer = layers.find(l => l.id === activeLayerId);
if (!activeLayer || activeLayer.type === 'brand' || activeLayer.type === 'audio') {
const visual = layers.find(l => l.type === 'visual' || l.type == null);
if (visual) targetLayerId = visual.id;
}
const newElement: TimelineElement = {
id: newId,
layerId: targetLayerId,
type: elType,
content: brandSource ? `{${brandSource}}` : label,
startFrame: 0,
endFrame: durationInFrames,
x: 50,
y: 50,
width: type === 'text' ? 80 : 40,
height: type === 'text' ? 10 : 20,
fontSize: type === 'text' ? 24 : undefined,
fontWeight: type === 'text' ? 400 : undefined,
elementName: label,
notes: JSON.stringify({
__expressField: true,
brandSource,
required: false,
fieldType: type,
}),
};
setTimelineElements(prev => [...prev, newElement]);
setSelectedElementId(newId);
};
// ── Add a brand content asset ──
const addBrandAsset = (asset: BrandContentPiece) => {
const newId = 'el-asset-' + Date.now();
const elType: TimelineElement['type'] = asset.type === 'custom-image' ? 'image' : 'text';
let targetLayerId = activeLayerId;
const activeLayer = layers.find(l => l.id === activeLayerId);
if (!activeLayer || activeLayer.type === 'brand' || activeLayer.type === 'audio') {
const visual = layers.find(l => l.type === 'visual' || l.type == null);
if (visual) targetLayerId = visual.id;
}
const newElement: TimelineElement = {
id: newId,
layerId: targetLayerId,
type: elType,
content: asset.content.text || asset.name,
startFrame: 0,
endFrame: durationInFrames,
x: 50,
y: 50,
width: 40,
height: 20,
fontSize: asset.style.fontSize || 20,
fontWeight: 600,
elementName: asset.name,
notes: JSON.stringify({
__expressField: true,
brandAssetId: asset.id,
required: false,
fieldType: asset.type === 'custom-image' ? 'media' : 'text',
}),
};
setTimelineElements(prev => [...prev, newElement]);
setSelectedElementId(newId);
};
return (
<div className="w-64 bg-neutral-900 border-r border-neutral-800/60 flex flex-col h-full z-10 shrink-0 shadow-lg animate-in slide-in-from-left-2 duration-200">
<div className="p-3 border-b border-neutral-800 flex items-center justify-between">
<h3 className="text-sm font-semibold text-white flex items-center gap-2">
<Layers size={14} className="text-amber-400" />
Escena
</h3>
<button onClick={onClose} title="Cerrar Panel" className="text-neutral-400 hover:text-white p-1 rounded-md hover:bg-neutral-800 transition-colors">
<X size={16} />
</button>
</div>
<div className="p-3 flex-1 overflow-y-auto space-y-4 custom-scrollbar">
{/* Scene name */}
<div className="space-y-1">
<label className="text-[9px] text-neutral-500 uppercase tracking-wider font-semibold">Nombre de la escena</label>
<input
type="text"
value={scene.name}
onChange={(e) => onUpdateScene({ ...scene, name: e.target.value })}
className="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-1.5 text-sm text-white focus:border-violet-500/50 focus:outline-none"
/>
</div>
{/* Type + Duration (video only) */}
{isVideo && (
<div className="flex gap-2">
<div className="flex-1 space-y-1">
<label className="text-[9px] text-neutral-500 uppercase tracking-wider font-semibold">Tipo</label>
<select
value={scene.type}
onChange={(e) => onUpdateScene({ ...scene, type: e.target.value as ExpressScene['type'] })}
className="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-2 py-1.5 text-xs text-white focus:border-violet-500/50 focus:outline-none"
>
<option value="intro">Intro</option>
<option value="content">Contenido</option>
<option value="outro">Outro</option>
</select>
</div>
<div className="w-20 space-y-1">
<label className="text-[9px] text-neutral-500 uppercase tracking-wider font-semibold flex items-center gap-1">
<Clock size={8} /> Duración
</label>
<div className="flex items-center gap-1">
<input
type="number"
min={1}
max={30}
value={scene.durationSeconds}
onChange={(e) => onUpdateScene({ ...scene, durationSeconds: Math.max(1, parseInt(e.target.value) || 1) })}
className="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-2 py-1.5 text-xs text-white text-center focus:border-violet-500/50 focus:outline-none"
/>
<span className="text-[9px] text-neutral-500">s</span>
</div>
</div>
</div>
)}
{/* Quick-add field buttons */}
<div className="space-y-2">
<label className="text-[9px] text-neutral-500 uppercase tracking-wider font-semibold">
Agregar campos
</label>
<div className="flex gap-1">
<button
onClick={() => addBrandField('text', 'Texto')}
title="Agregar campo de texto"
className="flex-1 flex items-center justify-center gap-1 py-1.5 rounded-lg border border-dashed border-neutral-700 text-[9px] text-neutral-500 hover:border-violet-500/50 hover:text-violet-400 transition-all"
>
<Plus size={8} /> Texto
</button>
<button
onClick={() => addBrandField('media', 'Media')}
title="Agregar campo de media"
className="flex-1 flex items-center justify-center gap-1 py-1.5 rounded-lg border border-dashed border-neutral-700 text-[9px] text-neutral-500 hover:border-sky-500/50 hover:text-sky-400 transition-all"
>
<Plus size={8} /> Media
</button>
</div>
</div>
<hr className="border-neutral-800/50" />
{/* Brand Variables */}
<div className="space-y-2">
<label className="text-[9px] text-neutral-500 uppercase tracking-wider font-semibold flex items-center gap-1">
<Zap size={8} className="text-violet-400" /> Variables de Marca
</label>
<div className="grid grid-cols-2 gap-1">
{BRAND_VARIABLES.map(v => (
<button
key={v.source}
onClick={() => addBrandField(v.type, v.label, v.source)}
title={`Insertar {${v.source}}`}
className="flex items-center gap-1.5 px-2 py-1.5 rounded-lg bg-violet-500/5 border border-violet-500/15 text-[9px] text-violet-300 hover:bg-violet-500/10 hover:border-violet-500/30 transition-all"
>
{v.icon} {v.label}
</button>
))}
</div>
</div>
<hr className="border-neutral-800/50" />
{/* ── Diseño y Fondo (collapsible) ── */}
<CollapsibleSection title="Diseño y Fondo">
{/* Layout */}
<div className="space-y-1.5">
<label className="text-[9px] text-neutral-500 uppercase tracking-wider font-semibold flex items-center gap-1">
<Layers size={8} /> Layout
</label>
<div className="grid grid-cols-2 gap-1">
{LAYOUTS.map(l => (
<button
key={l.value}
onClick={() => onUpdateScene({ ...scene, layout: l.value })}
title={l.label}
className={`flex items-center gap-1 px-2 py-1.5 rounded-lg text-[9px] font-medium transition-all border ${
scene.layout === l.value
? 'bg-violet-600/15 border-violet-500/40 text-violet-300'
: 'bg-neutral-800 border-neutral-700 text-neutral-500 hover:text-neutral-300 hover:border-neutral-600'
}`}
>
<span>{l.icon}</span> {l.label}
</button>
))}
</div>
</div>
{/* Background */}
<div className="space-y-1.5">
<label className="text-[9px] text-neutral-500 uppercase tracking-wider font-semibold">Fondo</label>
<div className="flex gap-1">
{(['brand', 'solid', 'gradient', 'media'] as const).map(bg => (
<button
key={bg}
onClick={() => onUpdateScene({ ...scene, background: { type: bg } })}
title={`Fondo: ${bg}`}
className={`flex-1 py-1.5 rounded-lg text-[9px] font-medium transition-all border ${
scene.background?.type === bg
? 'bg-violet-600/15 border-violet-500/40 text-violet-300'
: 'bg-neutral-800 border-neutral-700 text-neutral-500 hover:text-neutral-300'
}`}
>
{bg === 'brand' ? '🎨' : bg === 'solid' ? '⬛' : bg === 'gradient' ? '🌈' : '📷'}
</button>
))}
</div>
</div>
{/* Brand Content Assets */}
{brandContent.length > 0 && (
<div className="space-y-2">
<label className="text-[9px] text-neutral-500 uppercase tracking-wider font-semibold flex items-center gap-1">
<ImageIcon size={8} className="text-amber-400" /> Assets de Marca
</label>
<div className="grid grid-cols-2 gap-1">
{brandContent.map(asset => (
<button
key={asset.id}
onClick={() => addBrandAsset(asset)}
title={`Insertar asset: ${asset.name}`}
className="flex items-center gap-1.5 px-2 py-1.5 rounded-lg bg-amber-500/5 border border-amber-500/15 text-[9px] text-amber-300 hover:bg-amber-500/10 hover:border-amber-500/30 transition-all text-left truncate"
>
{asset.thumbnail ? (
<img src={asset.thumbnail} alt="" className="w-4 h-4 rounded object-cover shrink-0" />
) : (
<div className="w-4 h-4 rounded bg-amber-500/20 shrink-0" />
)}
<span className="truncate">{asset.name}</span>
</button>
))}
</div>
</div>
)}
</CollapsibleSection>
</div>
</div>
);
};
@@ -1,357 +0,0 @@
import React from 'react';
import { Type, Image as ImageIcon, Plus, Trash2, Zap, Clock, Layers, Sparkles, Globe, Instagram, AtSign } from 'lucide-react';
import { ExpressScene, ExpressField, SceneLayout, BrandContentPiece, DesignMD } from '../../../types';
import { FieldInspector } from '../../ui/FieldInspector';
interface SceneConfiguratorProps {
scene: ExpressScene;
onUpdateScene: (updated: ExpressScene) => void;
brandContent: BrandContentPiece[];
designMD: DesignMD;
isVideo: boolean;
selectedFieldId?: string | null;
onSelectField?: (fieldId: string | null) => void;
}
/** Layout options with visual icons */
const LAYOUTS: { value: SceneLayout; label: string; icon: string }[] = [
{ value: 'fullscreen-media', label: 'Pantalla completa', icon: '📸' },
{ value: 'overlay', label: 'Overlay', icon: '🔲' },
{ value: 'split', label: 'Dividido', icon: '◫' },
{ value: 'media-left', label: 'Media izq.', icon: '◧' },
{ value: 'media-right', label: 'Media der.', icon: '◨' },
{ value: 'text-only', label: 'Solo texto', icon: '📝' },
];
/** Brand variables available for insertion */
const BRAND_VARIABLES: { source: ExpressField['brandSource']; label: string; icon: React.ReactNode; type: 'text' | 'media' | 'logo' }[] = [
{ source: 'brand-name', label: 'Nombre de Marca', icon: <Type size={10} />, type: 'text' },
{ source: 'tagline', label: 'Tagline / Eslogan', icon: <Sparkles size={10} />, type: 'text' },
{ source: 'logo', label: 'Logo', icon: <Zap size={10} />, type: 'logo' },
{ source: 'instagram', label: 'Instagram', icon: <Instagram size={10} />, type: 'text' },
{ source: 'tiktok', label: 'TikTok', icon: <AtSign size={10} />, type: 'text' },
{ source: 'twitter', label: 'X / Twitter', icon: <AtSign size={10} />, type: 'text' },
{ source: 'youtube', label: 'YouTube', icon: <AtSign size={10} />, type: 'text' },
{ source: 'website', label: 'Website', icon: <Globe size={10} />, type: 'text' },
];
/**
* SceneConfigurator — Config panel for the active scene in the Template Builder.
* Name, type, duration, layout, editable fields, brand assets, transition, background.
*/
export const SceneConfigurator: React.FC<SceneConfiguratorProps> = ({
scene,
onUpdateScene,
brandContent,
designMD,
isVideo,
selectedFieldId,
onSelectField,
}) => {
const updateField = (fieldId: string, updates: Partial<ExpressField>) => {
onUpdateScene({
...scene,
editableFields: scene.editableFields.map(f =>
f.id === fieldId ? { ...f, ...updates } : f
),
});
};
const addField = (type: ExpressField['type'], label: string, brandSource?: ExpressField['brandSource']) => {
const newField: ExpressField = {
id: `field-${Date.now()}`,
type,
label,
placeholder: label,
required: false,
brandSource,
position: { x: 50, y: 50, w: type === 'text' ? 80 : 60, h: type === 'text' ? 10 : 30 },
style: {
fontSize: type === 'text' ? 24 : undefined,
fontWeight: type === 'text' ? 400 : undefined,
textAlign: 'center',
opacity: 100,
},
};
onUpdateScene({ ...scene, editableFields: [...scene.editableFields, newField] });
};
const addBrandAsset = (asset: BrandContentPiece) => {
const newField: ExpressField = {
id: `field-asset-${asset.id}-${Date.now()}`,
type: asset.type === 'custom-image' ? 'media' : 'text',
label: asset.name,
placeholder: asset.content.text || asset.name,
required: false,
brandAssetId: asset.id,
position: { x: 50, y: 50, w: 40, h: 20 },
style: {
fontSize: asset.style.fontSize || 20,
fontWeight: 600,
textAlign: 'center',
opacity: 100,
},
};
onUpdateScene({ ...scene, editableFields: [...scene.editableFields, newField] });
};
const removeField = (fieldId: string) => {
onUpdateScene({
...scene,
editableFields: scene.editableFields.filter(f => f.id !== fieldId),
});
};
return (
<div className="space-y-4">
{/* Scene name + type */}
<div className="space-y-2">
<label className="text-[9px] text-neutral-500 uppercase tracking-wider font-semibold">Nombre de la escena</label>
<input
type="text"
value={scene.name}
onChange={(e) => onUpdateScene({ ...scene, name: e.target.value })}
className="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-1.5 text-sm text-white focus:border-violet-500/50 focus:outline-none"
/>
</div>
{/* Type + Duration (video only) */}
{isVideo && (
<div className="flex gap-2">
<div className="flex-1 space-y-1">
<label className="text-[9px] text-neutral-500 uppercase tracking-wider font-semibold">Tipo</label>
<select
value={scene.type}
onChange={(e) => onUpdateScene({ ...scene, type: e.target.value as ExpressScene['type'] })}
className="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-2 py-1.5 text-xs text-white focus:border-violet-500/50 focus:outline-none"
>
<option value="intro">Intro</option>
<option value="content">Contenido</option>
<option value="outro">Outro</option>
</select>
</div>
<div className="w-20 space-y-1">
<label className="text-[9px] text-neutral-500 uppercase tracking-wider font-semibold flex items-center gap-1">
<Clock size={8} /> Duración
</label>
<div className="flex items-center gap-1">
<input
type="number"
min={1}
max={30}
value={scene.durationSeconds}
onChange={(e) => onUpdateScene({ ...scene, durationSeconds: Math.max(1, parseInt(e.target.value) || 1) })}
className="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-2 py-1.5 text-xs text-white text-center focus:border-violet-500/50 focus:outline-none"
/>
<span className="text-[9px] text-neutral-500">s</span>
</div>
</div>
</div>
)}
{/* Layout */}
<div className="space-y-1.5">
<label className="text-[9px] text-neutral-500 uppercase tracking-wider font-semibold flex items-center gap-1">
<Layers size={8} /> Layout
</label>
<div className="grid grid-cols-3 gap-1">
{LAYOUTS.map(l => (
<button
key={l.value}
onClick={() => onUpdateScene({ ...scene, layout: l.value })}
title={l.label}
className={`flex items-center gap-1 px-2 py-1.5 rounded-lg text-[9px] font-medium transition-all border ${
scene.layout === l.value
? 'bg-violet-600/15 border-violet-500/40 text-violet-300'
: 'bg-neutral-800 border-neutral-700 text-neutral-500 hover:text-neutral-300 hover:border-neutral-600'
}`}
>
<span>{l.icon}</span> {l.label}
</button>
))}
</div>
</div>
<hr className="border-neutral-800/50" />
{/* Editable Fields */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-[9px] text-neutral-500 uppercase tracking-wider font-semibold">Campos editables</label>
<span className="text-[8px] text-neutral-600">{scene.editableFields.length} campos</span>
</div>
{scene.editableFields.map(field => {
const isSelected = selectedFieldId === field.id;
return (
<div
key={field.id}
onClick={() => onSelectField?.(field.id)}
className={`flex items-center gap-2 rounded-lg px-2.5 py-1.5 cursor-pointer transition-all ${
isSelected
? 'bg-violet-500/10 border border-violet-500/40 ring-1 ring-violet-500/20'
: 'bg-neutral-800/50 border border-neutral-700/50 hover:border-neutral-600'
}`}
>
<span className="text-[10px]">
{field.type === 'text' ? '📝' : field.type === 'media' ? '📷' : '⚡'}
</span>
<input
type="text"
value={field.label}
onChange={(e) => updateField(field.id, { label: e.target.value, placeholder: e.target.value })}
onClick={(e) => e.stopPropagation()}
className="flex-1 bg-transparent text-[10px] text-neutral-300 focus:outline-none"
/>
{field.brandSource && (
<span className="text-[7px] text-violet-400 bg-violet-500/10 px-1 py-0.5 rounded shrink-0">
{`{${field.brandSource}}`}
</span>
)}
{field.brandAssetId && (
<span className="text-[7px] text-amber-400 bg-amber-500/10 px-1 py-0.5 rounded shrink-0">
Asset
</span>
)}
<button
onClick={(e) => { e.stopPropagation(); removeField(field.id); }}
title="Quitar campo"
className="text-neutral-600 hover:text-red-400 transition-colors shrink-0"
>
<Trash2 size={10} />
</button>
</div>
);
})}
{/* Field Inspector (when a field is selected) */}
{selectedFieldId && (() => {
const field = scene.editableFields.find(f => f.id === selectedFieldId);
if (!field) return null;
const brandColors = [designMD.primaryColor, designMD.secondaryColor, designMD.textColor].filter(Boolean);
return (
<FieldInspector
position={field.position}
onPositionChange={(pos) => {
updateField(field.id, {
position: { ...field.position, ...pos },
});
}}
textStyle={field.type === 'text' ? {
fontSize: field.style.fontSize,
fontWeight: field.style.fontWeight,
fontFamily: field.style.fontFamily,
color: field.style.color,
textAlign: field.style.textAlign as 'left' | 'center' | 'right' | undefined,
opacity: field.style.opacity,
} : undefined}
onTextStyleChange={field.type === 'text' ? (style) => {
updateField(field.id, {
style: { ...field.style, ...style },
});
} : undefined}
fieldType={field.type as 'text' | 'media' | 'logo'}
fieldLabel={field.label}
brandFont={designMD.baseFont?.split(',')[0]?.replace(/"/g, '')}
brandColors={brandColors}
/>
);
})()}
{/* Add field buttons */}
<div className="flex gap-1">
<button
onClick={() => addField('text', 'Texto')}
title="Agregar campo de texto"
className="flex-1 flex items-center justify-center gap-1 py-1.5 rounded-lg border border-dashed border-neutral-700 text-[9px] text-neutral-500 hover:border-violet-500/50 hover:text-violet-400 transition-all"
>
<Plus size={8} /> Texto
</button>
<button
onClick={() => addField('media', 'Media')}
title="Agregar campo de media"
className="flex-1 flex items-center justify-center gap-1 py-1.5 rounded-lg border border-dashed border-neutral-700 text-[9px] text-neutral-500 hover:border-sky-500/50 hover:text-sky-400 transition-all"
>
<Plus size={8} /> Media
</button>
</div>
</div>
<hr className="border-neutral-800/50" />
{/* Brand Variables (social handles, name, etc.) */}
<div className="space-y-2">
<label className="text-[9px] text-neutral-500 uppercase tracking-wider font-semibold flex items-center gap-1">
<Zap size={8} className="text-violet-400" /> Variables de Marca
</label>
<div className="grid grid-cols-2 gap-1">
{BRAND_VARIABLES.map(v => (
<button
key={v.source}
onClick={() => addField(v.type, v.label, v.source)}
title={`Insertar {${v.source}}`}
className="flex items-center gap-1.5 px-2 py-1.5 rounded-lg bg-violet-500/5 border border-violet-500/15 text-[9px] text-violet-300 hover:bg-violet-500/10 hover:border-violet-500/30 transition-all"
>
{v.icon} {v.label}
</button>
))}
</div>
</div>
{/* Brand Content Assets */}
{brandContent.length > 0 && (
<>
<hr className="border-neutral-800/50" />
<div className="space-y-2">
<label className="text-[9px] text-neutral-500 uppercase tracking-wider font-semibold flex items-center gap-1">
<ImageIcon size={8} className="text-amber-400" /> Assets de Marca
</label>
<div className="grid grid-cols-2 gap-1">
{brandContent.map(asset => (
<button
key={asset.id}
onClick={() => addBrandAsset(asset)}
title={`Insertar asset: ${asset.name} (ID: ${asset.id})`}
className="flex items-center gap-1.5 px-2 py-1.5 rounded-lg bg-amber-500/5 border border-amber-500/15 text-[9px] text-amber-300 hover:bg-amber-500/10 hover:border-amber-500/30 transition-all text-left truncate"
>
{asset.thumbnail ? (
<img src={asset.thumbnail} alt="" className="w-4 h-4 rounded object-cover shrink-0" />
) : (
<div className="w-4 h-4 rounded bg-amber-500/20 shrink-0" />
)}
<span className="truncate">{asset.name}</span>
</button>
))}
</div>
</div>
</>
)}
<hr className="border-neutral-800/50" />
{/* Background */}
<div className="space-y-1.5">
<label className="text-[9px] text-neutral-500 uppercase tracking-wider font-semibold">Fondo</label>
<div className="flex gap-1">
{(['brand', 'solid', 'gradient', 'media'] as const).map(bg => (
<button
key={bg}
onClick={() => onUpdateScene({ ...scene, background: { type: bg } })}
title={`Fondo: ${bg}`}
className={`flex-1 py-1.5 rounded-lg text-[9px] font-medium transition-all border ${
scene.background?.type === bg
? 'bg-violet-600/15 border-violet-500/40 text-violet-300'
: 'bg-neutral-800 border-neutral-700 text-neutral-500 hover:text-neutral-300'
}`}
>
{bg === 'brand' ? '🎨 Marca' : bg === 'solid' ? '⬛ Sólido' : bg === 'gradient' ? '🌈 Grad' : '📷 Media'}
</button>
))}
</div>
</div>
</div>
);
};
+1 -1
View File
@@ -41,7 +41,7 @@ export const StickersPanel: React.FC<StickersPanelProps> = ({ onClose }) => {
let targetLayerId = activeLayerId; let targetLayerId = activeLayerId;
const activeLayer = layers.find(l => l.id === activeLayerId); const activeLayer = layers.find(l => l.id === activeLayerId);
if (!activeLayer || activeLayer.type === 'brand' || activeLayer.type === 'video' || activeLayer.type === 'audio') { if (!activeLayer || activeLayer.type === 'brand' || activeLayer.type === 'video' || activeLayer.type === 'audio' || activeLayer.type === 'background') {
let visualLayer = layers.find(l => l.type === 'visual' || l.type == null); let visualLayer = layers.find(l => l.type === 'visual' || l.type == null);
if (!visualLayer) { if (!visualLayer) {
visualLayer = { id: 'layer-' + Date.now(), name: 'Capa Gráfica 1', type: 'visual' }; visualLayer = { id: 'layer-' + Date.now(), name: 'Capa Gráfica 1', type: 'visual' };
+1 -1
View File
@@ -35,7 +35,7 @@ export const TextPanel: React.FC<TextPanelProps> = ({ onClose }) => {
// Find or create a visual layer // Find or create a visual layer
let targetLayerId = activeLayerId; let targetLayerId = activeLayerId;
const activeLayer = layers.find(l => l.id === activeLayerId); const activeLayer = layers.find(l => l.id === activeLayerId);
if (!activeLayer || activeLayer.type === 'brand' || activeLayer.type === 'video' || activeLayer.type === 'audio') { if (!activeLayer || activeLayer.type === 'brand' || activeLayer.type === 'video' || activeLayer.type === 'audio' || activeLayer.type === 'background') {
let visualLayer = layers.find(l => l.type === 'visual' || l.type == null); let visualLayer = layers.find(l => l.type === 'visual' || l.type == null);
if (!visualLayer) { if (!visualLayer) {
visualLayer = { id: 'layer-' + Date.now(), name: 'Capa Gráfica 1', type: 'visual' }; visualLayer = { id: 'layer-' + Date.now(), name: 'Capa Gráfica 1', type: 'visual' };
File diff suppressed because it is too large Load Diff
@@ -1,21 +0,0 @@
import React from 'react';
import { Layers, Image as ImageIcon } from 'lucide-react';
export const GraphicLayerPanel: React.FC = () => {
return (
<div className="flex flex-col h-full overflow-hidden">
<div className="p-5 border-b border-neutral-800">
<h2 className="text-sm font-bold text-white mb-1">
<Layers size={16} className="inline mr-2 text-violet-400 align-text-bottom"/> Capa Gráfica
</h2>
<p className="text-[11px] text-neutral-400">Añade textos o imágenes a esta capa</p>
</div>
<div className="p-5 flex-1 space-y-6 overflow-y-auto custom-scrollbar">
<div className="text-center text-neutral-500 text-sm py-10 px-4">
<ImageIcon size={24} className="mx-auto mb-3 opacity-50" />
Selecciona un elemento en la línea temporal para editarlo, o usa la barra de herramientas para añadir uno nuevo.
</div>
</div>
</div>
);
};
@@ -49,7 +49,7 @@ export const ImageLayersPanel: React.FC<ImageLayersPanelProps> = ({
const toggleVisibility = (id: string) => { const toggleVisibility = (id: string) => {
setTimelineElements(prev => prev.map(el => setTimelineElements(prev => prev.map(el =>
el.id === id ? { ...el, opacity: (el.opacity === 0 ? 1 : 0) } : el el.id === id ? { ...el, opacity: (el.opacity === 0 ? 100 : 0) } : el
)); ));
}; };
@@ -0,0 +1,24 @@
import React from 'react';
import { SectionProps, SectionBackgroundRemovalForAllVideoImageStickerElements, SectionContenido, SectionTiming, SectionTransformControlsOpacityScaleRotation, SectionFitModeImagevideo, SectionColorPaletteExtractorImages, SectionFocalPointCoverMode, SectionQuickActions, SectionFieldinspectorPosicinYTamao, SectionZorderLayerDepth, SectionFlipMirror, SectionZorder, SectionCollapsibleColorAdjustments, SectionCollapsibleBorderEffects, SectionCollapsibleKeyframes } from './PropertySections';
export const ImagePropertiesPanel: React.FC<SectionProps> = (props) => {
return (
<div className="space-y-5">
<SectionBackgroundRemovalForAllVideoImageStickerElements {...props} />
<SectionContenido {...props} />
<SectionTiming {...props} />
<SectionTransformControlsOpacityScaleRotation {...props} />
<SectionFitModeImagevideo {...props} />
<SectionColorPaletteExtractorImages {...props} />
<SectionFocalPointCoverMode {...props} />
<SectionQuickActions {...props} />
<SectionFieldinspectorPosicinYTamao {...props} />
<SectionZorderLayerDepth {...props} />
<SectionFlipMirror {...props} />
<SectionZorder {...props} />
<SectionCollapsibleColorAdjustments {...props} />
<SectionCollapsibleBorderEffects {...props} />
<SectionCollapsibleKeyframes {...props} />
</div>
);
};
@@ -0,0 +1,190 @@
import React, { useState } from 'react';
import { ChevronDown, Check } from 'lucide-react';
export const TRANSITIONS = [
{ value: 'none', label: 'Corte Rápido', desc: 'Sin transición', icon: '⚡' },
{ value: 'fade', label: 'Fundido', desc: 'Aparece/desaparece suavemente', icon: '🌅' },
{ value: 'slideUp', label: 'Deslizar Arriba', desc: 'Entra desde abajo', icon: '⬆' },
{ value: 'slideDown', label: 'Deslizar Abajo', desc: 'Entra desde arriba', icon: '⬇' },
{ value: 'slideLeft', label: 'Deslizar Izquierda', desc: 'Entra desde la derecha', icon: '⬅' },
{ value: 'slideRight', label: 'Deslizar Derecha', desc: 'Entra desde la izquierda', icon: '➡' },
{ value: 'scale', label: 'Escalar', desc: 'Zoom In / Zoom Out', icon: '🔍' },
{ value: 'bounce', label: 'Rebote', desc: 'Efecto de rebote elástico', icon: '🏀' },
{ value: 'blur', label: 'Desenfoque', desc: 'Aparece con desenfoque', icon: '🌊' },
{ value: 'spin', label: 'Girar', desc: 'Rotación de 360°', icon: '🔄' },
{ value: 'flip', label: 'Voltear', desc: 'Giro 3D horizontal', icon: '🪞' },
] as const;
export const TEXT_ONLY_TRANSITIONS = [
{ value: 'typewriter', label: 'Máquina de Escribir', desc: 'Letra por letra', icon: '⌨' },
] as const;
export const TransitionPicker: React.FC<{
label: string;
current: string;
duration: number;
isText: boolean;
onChange: (type: string) => void;
onDurationChange: (d: number) => void;
}> = ({ label, current, duration, isText, onChange, onDurationChange }) => {
const [open, setOpen] = useState(false);
const allTransitions = [...TRANSITIONS, ...(isText ? TEXT_ONLY_TRANSITIONS : [])];
const selected = allTransitions.find(t => t.value === current) || TRANSITIONS[0];
const hasTransition = current !== 'none';
return (
<div className="space-y-2">
<label className="block text-[10px] font-medium text-neutral-500 uppercase tracking-wider">{label}</label>
<button
onClick={() => setOpen(!open)}
className={`w-full flex items-center gap-2.5 px-3 py-2 rounded-lg text-xs font-medium transition-all border ${
hasTransition
? 'bg-violet-600/15 border-violet-500/40 text-violet-300 hover:border-violet-400/60'
: 'bg-neutral-950/50 border-neutral-800 text-neutral-400 hover:border-neutral-700'
}`}
>
<span className="text-sm">{selected.icon}</span>
<span className="flex-1 text-left">{selected.label}</span>
<ChevronDown size={12} className={`transition-transform ${open ? 'rotate-180' : ''}`} />
</button>
{open && (
<div className="bg-neutral-950 border border-neutral-800 rounded-lg overflow-hidden shadow-xl mt-1">
{allTransitions.map(t => (
<button
key={t.value}
onClick={() => { onChange(t.value); setOpen(false); }}
className={`w-full flex items-center gap-2.5 px-3 py-2 text-xs transition-colors ${
current === t.value
? 'bg-violet-600/20 text-violet-300'
: 'text-neutral-400 hover:bg-neutral-800/60 hover:text-white'
}`}
>
<span className="text-sm w-5 text-center">{t.icon}</span>
<div className="flex-1 text-left">
<div className="font-medium">{t.label}</div>
<div className="text-[9px] text-neutral-600">{t.desc}</div>
</div>
{current === t.value && <Check size={12} className="text-violet-400" />}
</button>
))}
</div>
)}
{hasTransition && (
<div className="pl-1 space-y-1">
<div className="flex justify-between text-[10px] text-neutral-500">
<span>Duración</span>
<span className="font-mono">{duration}f ({(duration / 30).toFixed(1)}s)</span>
</div>
<input
type="range" min="5" max="60" step="1"
value={duration}
onChange={(e) => onDurationChange(parseInt(e.target.value))}
className="w-full accent-violet-500 h-1"
/>
<div className="flex gap-1 mt-1">
{[
{ frames: 8, label: '0.25s' },
{ frames: 15, label: '0.5s' },
{ frames: 30, label: '1s' },
{ frames: 45, label: '1.5s' },
{ frames: 60, label: '2s' },
].map(d => (
<button
key={d.frames}
onClick={() => onDurationChange(d.frames)}
title={d.label}
className={`flex-1 py-0.5 rounded text-[7px] font-mono transition-colors border ${
duration === d.frames
? 'bg-violet-500/20 border-violet-500/40 text-violet-300'
: 'bg-neutral-900 border-neutral-800 text-neutral-600 hover:text-neutral-400'
}`}
>
{d.label}
</button>
))}
</div>
</div>
)}
</div>
);
};
export const SliderRow: React.FC<{
label: string;
value: number;
min: number;
max: number;
step?: number;
suffix?: string;
onChange: (v: number) => void;
}> = ({ label, value, min, max, step = 1, suffix = '', onChange }) => {
const [isEditing, setIsEditing] = React.useState(false);
const [editVal, setEditVal] = React.useState('');
const dragRef = React.useRef<{ startX: number; startVal: number } | null>(null);
const commitEdit = () => {
const parsed = parseFloat(editVal);
if (!isNaN(parsed)) onChange(Math.max(min, Math.min(max, parsed)));
setIsEditing(false);
};
return (
<div>
<div className="flex justify-between text-[10px] text-neutral-500 mb-0.5">
<span>{label}</span>
{isEditing ? (
<input
type="text"
autoFocus
value={editVal}
onChange={(e) => setEditVal(e.target.value)}
onBlur={commitEdit}
onKeyDown={(e) => { if (e.key === 'Enter') commitEdit(); if (e.key === 'Escape') setIsEditing(false); }}
className="w-14 bg-neutral-900 border border-violet-500 rounded px-1 text-right font-mono text-[10px] text-white outline-none"
/>
) : (
<span
className="font-mono cursor-ew-resize select-none hover:text-violet-300 transition-colors"
title="Click para editar · Arrastra para ajustar"
onPointerDown={(e) => {
e.preventDefault();
dragRef.current = { startX: e.clientX, startVal: value };
(e.target as HTMLElement).setPointerCapture(e.pointerId);
}}
onPointerMove={(e) => {
if (!dragRef.current) return;
const delta = (e.clientX - dragRef.current.startX) / 2;
const newVal = Math.max(min, Math.min(max, dragRef.current.startVal + delta * step));
onChange(step < 1 ? Math.round(newVal * 10) / 10 : Math.round(newVal));
}}
onPointerUp={(e) => {
if (dragRef.current && Math.abs(e.clientX - dragRef.current.startX) < 3) {
setEditVal(String(typeof value === 'number' ? (Number.isInteger(value) ? value : value.toFixed(1)) : value));
setIsEditing(true);
}
dragRef.current = null;
(e.target as HTMLElement).releasePointerCapture(e.pointerId);
}}
onWheel={(e) => {
e.preventDefault();
const dir = e.deltaY > 0 ? -1 : 1;
const mult = e.shiftKey ? 10 : 1;
onChange(Math.max(min, Math.min(max, value + dir * step * mult)));
}}
>
{typeof value === 'number' ? (Number.isInteger(value) ? value : value.toFixed(1)) : value}{suffix}
</span>
)}
</div>
<input
type="range" min={min} max={max} step={step}
value={value}
onChange={(e) => onChange(step < 1 ? parseFloat(e.target.value) : parseInt(e.target.value))}
className="w-full accent-violet-500 h-1"
/>
</div>
);
};
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,21 @@
import React from 'react';
import { SectionProps, SectionShapeProperties, SectionTransitionsInout, SectionAnimationCombos, SectionTiming, SectionTransformControlsOpacityScaleRotation, SectionQuickActions, SectionFieldinspectorPosicinYTamao, SectionZorderLayerDepth, SectionFlipMirror, SectionZorder, SectionCollapsibleBorderEffects, SectionCollapsibleKeyframes } from './PropertySections';
export const ShapePropertiesPanel: React.FC<SectionProps> = (props) => {
return (
<div className="space-y-5">
<SectionShapeProperties {...props} />
<SectionTransitionsInout {...props} />
<SectionAnimationCombos {...props} />
<SectionTiming {...props} />
<SectionTransformControlsOpacityScaleRotation {...props} />
<SectionQuickActions {...props} />
<SectionFieldinspectorPosicinYTamao {...props} />
<SectionZorderLayerDepth {...props} />
<SectionFlipMirror {...props} />
<SectionZorder {...props} />
<SectionCollapsibleBorderEffects {...props} />
<SectionCollapsibleKeyframes {...props} />
</div>
);
};
@@ -0,0 +1,21 @@
import React from 'react';
import { SectionProps, SectionQuickActions, SectionFieldinspectorPosicinYTamao, SectionZorderLayerDepth, SectionContenido, SectionAnimatedTextPresets, SectionTextStyles, SectionTransitionsInout, SectionAnimationCombos, SectionTiming, SectionTransformControlsOpacityScaleRotation, SectionCollapsibleKeyframes, SectionCollapsibleTextShadow } from './PropertySections';
export const TextPropertiesPanel: React.FC<SectionProps> = (props) => {
return (
<div className="space-y-5">
<SectionContenido {...props} />
<SectionAnimatedTextPresets {...props} />
<SectionTextStyles {...props} />
<SectionTransitionsInout {...props} />
<SectionAnimationCombos {...props} />
<SectionTiming {...props} />
<SectionTransformControlsOpacityScaleRotation {...props} />
<SectionQuickActions {...props} />
<SectionFieldinspectorPosicinYTamao {...props} />
<SectionZorderLayerDepth {...props} />
<SectionCollapsibleKeyframes {...props} />
<SectionCollapsibleTextShadow {...props} />
</div>
);
};
@@ -0,0 +1,26 @@
import React from 'react';
import { SectionProps, SectionBrandDisplayModeToggle, SectionBackgroundRemovalForAllVideoImageStickerElements, SectionVolumeControlVideoaudio, SectionSpeedControlVideoaudio, SectionSourceTrimVideoaudio, SectionTiming, SectionTransformControlsOpacityScaleRotation, SectionFitModeImagevideo, SectionFocalPointCoverMode, SectionQuickActions, SectionFieldinspectorPosicinYTamao, SectionZorderLayerDepth, SectionFlipMirror, SectionZorder, SectionCollapsibleColorAdjustments, SectionCollapsibleBorderEffects, SectionCollapsibleKeyframes } from './PropertySections';
export const VideoPropertiesPanel: React.FC<SectionProps> = (props) => {
return (
<div className="space-y-5">
<SectionBrandDisplayModeToggle {...props} />
<SectionBackgroundRemovalForAllVideoImageStickerElements {...props} />
<SectionVolumeControlVideoaudio {...props} />
<SectionSpeedControlVideoaudio {...props} />
<SectionSourceTrimVideoaudio {...props} />
<SectionTiming {...props} />
<SectionTransformControlsOpacityScaleRotation {...props} />
<SectionFitModeImagevideo {...props} />
<SectionFocalPointCoverMode {...props} />
<SectionQuickActions {...props} />
<SectionFieldinspectorPosicinYTamao {...props} />
<SectionZorderLayerDepth {...props} />
<SectionFlipMirror {...props} />
<SectionZorder {...props} />
<SectionCollapsibleColorAdjustments {...props} />
<SectionCollapsibleBorderEffects {...props} />
<SectionCollapsibleKeyframes {...props} />
</div>
);
};
+20 -4
View File
@@ -79,7 +79,7 @@ export const LivePreviewCanvas: React.FC<LivePreviewCanvasProps> = ({
const isScrubbing = useRef(false); const isScrubbing = useRef(false);
const fps = 30; const fps = 30;
const totalDuration = getTemplateDuration(template, videoDurations); const totalDuration = getTemplateDuration(template, videoDurations, designMD);
const totalFrames = Math.max(30, totalDuration * fps); const totalFrames = Math.max(30, totalDuration * fps);
const dimensions = getAspectDimensions(template.aspectRatio); const dimensions = getAspectDimensions(template.aspectRatio);
@@ -144,19 +144,35 @@ export const LivePreviewCanvas: React.FC<LivePreviewCanvasProps> = ({
const sceneSegments = useMemo(() => { const sceneSegments = useMemo(() => {
let offset = 0; let offset = 0;
return template.scenes.map(scene => { return template.scenes.map(scene => {
const durFrames = scene.durationSeconds * fps; let actualDuration = scene.durationSeconds;
if (scene.segmentSource === 'brand') {
if (scene.type === 'intro') {
if (!designMD.introVideoUrl) { actualDuration = 0; }
else { actualDuration = (designMD.introDurationFrames || (scene.durationSeconds * 30)) / 30; }
}
if (scene.type === 'outro') {
if (!designMD.outroVideoUrl) { actualDuration = 0; }
else { actualDuration = (designMD.outroDurationFrames || (scene.durationSeconds * 30)) / 30; }
}
} else if (videoDurations && videoDurations[scene.id]) {
// Use actual video duration if user uploaded one
actualDuration = videoDurations[scene.id];
}
const durFrames = actualDuration * fps;
const seg = { const seg = {
id: scene.id, id: scene.id,
name: scene.type === 'intro' ? 'INTRO' : scene.type === 'outro' ? 'OUTRO' : scene.name, name: scene.type === 'intro' ? 'INTRO' : scene.type === 'outro' ? 'OUTRO' : scene.name,
type: scene.type || 'content', type: scene.type || 'content',
startFrame: offset, startFrame: offset,
endFrame: offset + durFrames, endFrame: offset + durFrames,
widthPct: (durFrames / totalFrames) * 100, widthPct: totalFrames > 0 ? (durFrames / totalFrames) * 100 : 0,
}; };
offset += durFrames; offset += durFrames;
return seg; return seg;
}); });
}, [template, fps, totalFrames]); }, [template, fps, totalFrames, videoDurations, designMD]);
const handlePlayToggle = useCallback(() => { const handlePlayToggle = useCallback(() => {
if (playerRef.current) { if (playerRef.current) {
+4
View File
@@ -25,6 +25,8 @@ interface CanvasWorkspaceProps {
children: React.ReactNode; children: React.ReactNode;
/** Content rendered in the OVERLAY layer — can extend beyond canvas */ /** Content rendered in the OVERLAY layer — can extend beyond canvas */
overlay?: React.ReactNode; overlay?: React.ReactNode;
/** Ref for the inner canvas frame (for coordinate calculations) */
canvasRef?: React.RefObject<HTMLDivElement>;
/** Ref for the overlay's inner reference frame (for drag calculations) */ /** Ref for the overlay's inner reference frame (for drag calculations) */
overlayRef?: React.RefObject<HTMLDivElement>; overlayRef?: React.RefObject<HTMLDivElement>;
/** Pointer event handlers for the extended overlay */ /** Pointer event handlers for the extended overlay */
@@ -41,6 +43,7 @@ export const CanvasWorkspace: React.FC<CanvasWorkspaceProps> = ({
canvasClassName = '', canvasClassName = '',
children, children,
overlay, overlay,
canvasRef,
overlayRef, overlayRef,
onOverlayPointerMove, onOverlayPointerMove,
onOverlayPointerUp, onOverlayPointerUp,
@@ -61,6 +64,7 @@ export const CanvasWorkspace: React.FC<CanvasWorkspaceProps> = ({
{/* Inner canvas container */} {/* Inner canvas container */}
<div <div
ref={canvasRef}
className="relative" className="relative"
style={{ height: '100%', maxHeight: '100%', aspectRatio }} style={{ height: '100%', maxHeight: '100%', aspectRatio }}
> >
+18 -5
View File
@@ -1,9 +1,22 @@
import React from 'react'; import React from 'react';
import { TimelineElement } from '../../types';
export interface TextStylePresetUpdates {
fontSize?: number;
fontWeight?: number;
fontStyle?: 'italic' | 'normal';
textTransform?: 'uppercase' | 'none';
letterSpacing?: number;
lineHeight?: number;
shadowOffset?: number;
shadowBlur?: number;
fontFamily?: string;
textBackgroundColor?: string;
textBackgroundPadding?: number;
textBackgroundRadius?: number;
}
interface TextStylePresetsProps { interface TextStylePresetsProps {
element: TimelineElement; onApplyPreset: (updates: TextStylePresetUpdates) => void;
onUpdate: (updates: Partial<TimelineElement>) => void;
} }
const TEXT_STYLE_PRESETS = [ const TEXT_STYLE_PRESETS = [
@@ -53,7 +66,7 @@ const TEXT_STYLE_PRESETS = [
* TextStylePresets — Quick-apply pre-designed text style configurations. * TextStylePresets — Quick-apply pre-designed text style configurations.
* Each preset adjusts fontSize, fontWeight, transform, spacing, and optional effects. * Each preset adjusts fontSize, fontWeight, transform, spacing, and optional effects.
*/ */
export const TextStylePresets: React.FC<TextStylePresetsProps> = ({ element, onUpdate }) => { export const TextStylePresets: React.FC<TextStylePresetsProps> = ({ onApplyPreset }) => {
return ( return (
<div className="space-y-1.5"> <div className="space-y-1.5">
<span className="text-[9px] text-neutral-500 block">Estilos Prediseñados</span> <span className="text-[9px] text-neutral-500 block">Estilos Prediseñados</span>
@@ -61,7 +74,7 @@ export const TextStylePresets: React.FC<TextStylePresetsProps> = ({ element, onU
{TEXT_STYLE_PRESETS.map((preset) => ( {TEXT_STYLE_PRESETS.map((preset) => (
<button <button
key={preset.name} key={preset.name}
onClick={() => onUpdate(preset.styles)} onClick={() => onApplyPreset(preset.styles)}
title={preset.desc} title={preset.desc}
className="py-1.5 px-2 rounded-lg text-[8px] font-medium bg-neutral-900 border border-neutral-800 text-neutral-500 hover:text-violet-300 hover:border-violet-500/30 transition-all text-left" className="py-1.5 px-2 rounded-lg text-[8px] font-medium bg-neutral-900 border border-neutral-800 text-neutral-500 hover:text-violet-300 hover:border-violet-500/30 transition-all text-left"
> >
@@ -1,5 +1,5 @@
/** /**
* useExportQueue Client-side hook for managing export jobs via SSE. * ExportQueueContext Global Context for managing export jobs via SSE.
* *
* Provides: * Provides:
* - startExport(): Create a new render job * - startExport(): Create a new render job
@@ -8,9 +8,10 @@
* - downloadJob(): Trigger browser download * - downloadJob(): Trigger browser download
* - isConnected: SSE connection status * - isConnected: SSE connection status
*/ */
import { useState, useEffect, useCallback, useRef } from 'react'; import React, { createContext, useContext, useState, useEffect, useCallback, useRef } from 'react';
import type { TimelineElement, TimelineLayer, DesignMD } from '../types'; import type { TimelineElement, TimelineLayer, DesignMD } from '../types';
import { resolveBlobUrls } from '../utils/uploadBlobContent'; import { resolveBlobUrls } from '../utils/uploadBlobContent';
import { useToast } from '../components/ui/ToastProvider';
// ═══ Types (mirror server-side) ═══ // ═══ Types (mirror server-side) ═══
@@ -49,11 +50,30 @@ export interface ExportConfig {
outputFormat?: 'video' | 'image'; outputFormat?: 'video' | 'image';
} }
export function useExportQueue() { interface ExportCallbacks {
onSuccess?: (url: string) => void;
onError?: (err: string) => void;
}
interface ExportQueueContextValue {
jobs: RenderJobClient[];
activeJobs: RenderJobClient[];
hasActiveJobs: boolean;
isConnected: boolean;
startExport: (config: ExportConfig, callbacks?: ExportCallbacks) => Promise<RenderJobClient | null>;
cancelJob: (jobId: string) => Promise<void>;
downloadJob: (job: RenderJobClient) => Promise<void>;
}
const ExportQueueContext = createContext<ExportQueueContextValue | null>(null);
export const ExportQueueProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [jobs, setJobs] = useState<RenderJobClient[]>([]); const [jobs, setJobs] = useState<RenderJobClient[]>([]);
const [isConnected, setIsConnected] = useState(false); const [isConnected, setIsConnected] = useState(false);
const eventSourceRef = useRef<EventSource | null>(null); const eventSourceRef = useRef<EventSource | null>(null);
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const callbacksRef = useRef<Record<string, ExportCallbacks>>({});
const { showToast } = useToast();
// ─── SSE Connection ─── // ─── SSE Connection ───
const connect = useCallback(() => { const connect = useCallback(() => {
@@ -66,7 +86,7 @@ export function useExportQueue() {
es.onopen = () => { es.onopen = () => {
setIsConnected(true); setIsConnected(true);
console.log('📡 SSE connected'); console.log('📡 SSE connected (Global)');
}; };
es.onmessage = (event) => { es.onmessage = (event) => {
@@ -74,14 +94,39 @@ export function useExportQueue() {
const data = JSON.parse(event.data); const data = JSON.parse(event.data);
if (data.type === 'job-update' && data.job) { if (data.type === 'job-update' && data.job) {
const updatedJob = data.job as RenderJobClient;
setJobs(prev => { setJobs(prev => {
const idx = prev.findIndex(j => j.id === data.job.id); const idx = prev.findIndex(j => j.id === updatedJob.id);
const oldJob = idx >= 0 ? prev[idx] : null;
// Check if it just finished
if (updatedJob.status === 'done' && oldJob?.status !== 'done' && updatedJob.downloadUrl) {
const cbs = callbacksRef.current[updatedJob.id];
if (cbs?.onSuccess) {
cbs.onSuccess(updatedJob.downloadUrl);
}
delete callbacksRef.current[updatedJob.id];
if (!cbs?.onSuccess) {
// If there's no custom callback, show a generic toast
showToast('Renderización completada', 'success');
}
} else if (updatedJob.status === 'error' && oldJob?.status !== 'error') {
const cbs = callbacksRef.current[updatedJob.id];
if (cbs?.onError) {
cbs.onError(updatedJob.error || 'Error');
}
delete callbacksRef.current[updatedJob.id];
showToast(`Error de renderización: ${updatedJob.error}`, 'error');
}
if (idx >= 0) { if (idx >= 0) {
const updated = [...prev]; const updated = [...prev];
updated[idx] = data.job; updated[idx] = updatedJob;
return updated; return updated;
} }
return [data.job, ...prev]; return [updatedJob, ...prev];
}); });
} }
} catch (err) { } catch (err) {
@@ -97,7 +142,7 @@ export function useExportQueue() {
// Auto-reconnect after 3s // Auto-reconnect after 3s
reconnectTimeoutRef.current = setTimeout(connect, 3000); reconnectTimeoutRef.current = setTimeout(connect, 3000);
}; };
}, []); }, [showToast]);
// ─── Connect on mount ─── // ─── Connect on mount ───
useEffect(() => { useEffect(() => {
@@ -118,7 +163,7 @@ export function useExportQueue() {
}, [connect]); }, [connect]);
// ─── Start Export ─── // ─── Start Export ───
const startExport = useCallback(async (config: ExportConfig): Promise<RenderJobClient | null> => { const startExport = useCallback(async (config: ExportConfig, callbacks?: ExportCallbacks): Promise<RenderJobClient | null> => {
const isStill = config.format === 'png' || config.format === 'jpeg'; const isStill = config.format === 'png' || config.format === 'jpeg';
// Resolve blob: URLs to persistent server URLs before sending to server-side render // Resolve blob: URLs to persistent server URLs before sending to server-side render
@@ -191,10 +236,16 @@ export function useExportQueue() {
} }
const job: RenderJobClient = await res.json(); const job: RenderJobClient = await res.json();
if (callbacks) {
callbacksRef.current[job.id] = callbacks;
}
setJobs(prev => [job, ...prev.filter(j => j.id !== job.id)]); setJobs(prev => [job, ...prev.filter(j => j.id !== job.id)]);
return job; return job;
} catch (err) { } catch (err) {
console.error('Export start failed:', err); console.error('Export start failed:', err);
if (callbacks?.onError) callbacks.onError(err instanceof Error ? err.message : 'Error');
return null; return null;
} }
}, []); }, []);
@@ -204,6 +255,7 @@ export function useExportQueue() {
try { try {
await fetch(`/api/render/jobs/${jobId}`, { method: 'DELETE' }); await fetch(`/api/render/jobs/${jobId}`, { method: 'DELETE' });
setJobs(prev => prev.filter(j => j.id !== jobId)); setJobs(prev => prev.filter(j => j.id !== jobId));
delete callbacksRef.current[jobId];
} catch (err) { } catch (err) {
console.error('Cancel failed:', err); console.error('Cancel failed:', err);
} }
@@ -242,13 +294,25 @@ export function useExportQueue() {
const activeJobs = jobs.filter(j => j.status === 'queued' || j.status === 'rendering'); const activeJobs = jobs.filter(j => j.status === 'queued' || j.status === 'rendering');
const hasActiveJobs = activeJobs.length > 0; const hasActiveJobs = activeJobs.length > 0;
return { return (
jobs, <ExportQueueContext.Provider value={{
activeJobs, jobs,
hasActiveJobs, activeJobs,
isConnected, hasActiveJobs,
startExport, isConnected,
cancelJob, startExport,
downloadJob, cancelJob,
}; downloadJob,
} }}>
{children}
</ExportQueueContext.Provider>
);
};
export const useExportQueue = () => {
const context = useContext(ExportQueueContext);
if (!context) {
throw new Error('useExportQueue must be used within ExportQueueProvider');
}
return context;
};
+14
View File
@@ -65,11 +65,25 @@ export const Video: React.FC<VideoProps> = ({
// During playback: only seek if drift is significant // During playback: only seek if drift is significant
// This lets the browser's audio decoder run smoothly // This lets the browser's audio decoder run smoothly
if (drift > DRIFT_THRESHOLD_PLAYING) { if (drift > DRIFT_THRESHOLD_PLAYING) {
if (typeof window !== 'undefined') {
(window as any).__pendingSeeks = ((window as any).__pendingSeeks || 0) + 1;
video.addEventListener('seeked', function onSeeked() {
(window as any).__pendingSeeks--;
video.removeEventListener('seeked', onSeeked);
});
}
video.currentTime = targetTime; video.currentTime = targetTime;
} }
} else { } else {
// When paused or seeking: sync precisely for scrubbing accuracy // When paused or seeking: sync precisely for scrubbing accuracy
if (drift > DRIFT_THRESHOLD_PAUSED) { if (drift > DRIFT_THRESHOLD_PAUSED) {
if (typeof window !== 'undefined') {
(window as any).__pendingSeeks = ((window as any).__pendingSeeks || 0) + 1;
video.addEventListener('seeked', function onSeeked() {
(window as any).__pendingSeeks--;
video.removeEventListener('seeked', onSeeked);
});
}
video.currentTime = targetTime; video.currentTime = targetTime;
} }
} }
+12 -3
View File
@@ -95,7 +95,7 @@ export async function renderFrames(options: RenderFrameOptions): Promise<string[
// Navigate to the render page // Navigate to the render page
const renderUrl = `${serveUrl}?renderMode=true`; const renderUrl = `${serveUrl}?renderMode=true`;
await page.goto(renderUrl, { waitUntil: 'networkidle0', timeout: 30000 }); await page.goto(renderUrl, { waitUntil: 'networkidle2', timeout: 30000 });
// Inject composition props and initialize the player // Inject composition props and initialize the player
await page.evaluate( await page.evaluate(
@@ -129,12 +129,21 @@ export async function renderFrames(options: RenderFrameOptions): Promise<string[
(window as any).__BRADLY_RENDER__.seekTo(f); (window as any).__BRADLY_RENDER__.seekTo(f);
}, frame); }, frame);
// Wait for React to render the new frame // Wait for React to render the new frame and videos to seek
await page.evaluate(() => { await page.evaluate(() => {
return new Promise<void>((resolve) => { return new Promise<void>((resolve) => {
// Use requestAnimationFrame to ensure paint is complete // Use requestAnimationFrame to ensure paint is complete
requestAnimationFrame(() => { requestAnimationFrame(() => {
requestAnimationFrame(() => resolve()); requestAnimationFrame(() => {
const checkVideoSeeks = () => {
if (!(window as any).__pendingSeeks) {
resolve();
} else {
setTimeout(checkVideoSeeks, 10);
}
};
checkVideoSeeks();
});
}); });
}); });
}); });
+50 -1
View File
@@ -8,6 +8,12 @@ import { spawn } from 'child_process';
import path from 'path'; import path from 'path';
import fs from 'fs'; import fs from 'fs';
export interface AudioTrackInput {
url: string;
startFrame: number;
volume: number; // 0 to 1
}
export interface EncodeOptions { export interface EncodeOptions {
/** Directory containing numbered frame images */ /** Directory containing numbered frame images */
framesDir: string; framesDir: string;
@@ -25,6 +31,10 @@ export interface EncodeOptions {
height?: number; height?: number;
/** Quality: CRF value (lower = better, 18-28 typical) */ /** Quality: CRF value (lower = better, 18-28 typical) */
crf?: number; crf?: number;
/** Audio tracks to mix */
audioTracks?: AudioTrackInput[];
/** Total frames (used to force exact video length) */
durationInFrames?: number;
/** Progress callback */ /** Progress callback */
onProgress?: (percent: number) => void; onProgress?: (percent: number) => void;
} }
@@ -40,6 +50,8 @@ export async function encodeVideo(options: EncodeOptions): Promise<void> {
fps, fps,
codec, codec,
crf = 23, crf = 23,
audioTracks = [],
durationInFrames,
onProgress, onProgress,
} = options; } = options;
@@ -56,9 +68,14 @@ export async function encodeVideo(options: EncodeOptions): Promise<void> {
const args: string[] = [ const args: string[] = [
'-y', // Overwrite output '-y', // Overwrite output
'-framerate', String(fps), // Input framerate '-framerate', String(fps), // Input framerate
'-i', inputPattern, // Input pattern '-i', inputPattern, // Input 0: Image pattern
]; ];
// Add audio inputs
audioTracks.forEach(track => {
args.push('-i', track.url);
});
// Codec-specific settings // Codec-specific settings
if (codec === 'h264') { if (codec === 'h264') {
args.push( args.push(
@@ -84,6 +101,38 @@ export async function encodeVideo(options: EncodeOptions): Promise<void> {
); );
} }
// Audio codec
args.push('-c:a', 'aac', '-b:a', '192k');
// Build Audio Filter Complex
if (audioTracks.length > 0) {
const filterParts: string[] = [];
const mixInputs: string[] = [];
audioTracks.forEach((track, index) => {
const inputIndex = index + 1; // 0 is video
const delayMs = Math.round((track.startFrame / fps) * 1000);
const outLabel = `[a${inputIndex}]`;
// adelay applies delay to all channels (L|R)
filterParts.push(`[${inputIndex}:a]adelay=${delayMs}|${delayMs},volume=${track.volume}${outLabel}`);
mixInputs.push(outLabel);
});
// Mix all processed audio streams
const mixFilter = `${mixInputs.join('')}amix=inputs=${audioTracks.length}:duration=longest:dropout_transition=2[aout]`;
filterParts.push(mixFilter);
args.push('-filter_complex', filterParts.join(';'));
args.push('-map', '0:v'); // Map video from input 0
args.push('-map', '[aout]'); // Map audio from complex filter
}
// Force video length
if (durationInFrames) {
args.push('-frames:v', String(Math.round(durationInFrames)));
}
args.push(outputPath); args.push(outputPath);
// Count total frames for progress // Count total frames for progress
+54 -2
View File
@@ -7,6 +7,8 @@ interface DragResizeOptions {
onMove: (id: string, x: number, y: number) => void; onMove: (id: string, x: number, y: number) => void;
/** Called during resize with clamped percentage dimensions */ /** Called during resize with clamped percentage dimensions */
onResize?: (id: string, w: number, h: number) => void; onResize?: (id: string, w: number, h: number) => void;
/** Called during rotation with angle in degrees */
onRotate?: (id: string, rotation: number) => void;
/** Snap lines in percentage (e.g. [50] for center snap) */ /** Snap lines in percentage (e.g. [50] for center snap) */
snapLines?: number[]; snapLines?: number[];
/** Distance in % to trigger snap (default: 1.5) */ /** Distance in % to trigger snap (default: 1.5) */
@@ -22,6 +24,9 @@ interface DragState {
origY: number; origY: number;
origW: number; origW: number;
origH: number; origH: number;
origRot?: number;
centerX?: number;
centerY?: number;
} }
/** /**
@@ -36,11 +41,12 @@ export function useDragResize({
containerRef, containerRef,
onMove, onMove,
onResize, onResize,
onRotate,
snapLines = [], snapLines = [],
snapThreshold = 1.5, snapThreshold = 1.5,
minSize = 5, minSize = 5,
}: DragResizeOptions) { }: DragResizeOptions) {
const [mode, setMode] = useState<'move' | 'resize' | null>(null); const [mode, setMode] = useState<'move' | 'resize' | 'rotate' | null>(null);
const [activeId, setActiveId] = useState<string | null>(null); const [activeId, setActiveId] = useState<string | null>(null);
const [dragStart, setDragStart] = useState<DragState | null>(null); const [dragStart, setDragStart] = useState<DragState | null>(null);
const [snapGuides, setSnapGuides] = useState<{ x?: number; y?: number }>({}); const [snapGuides, setSnapGuides] = useState<{ x?: number; y?: number }>({});
@@ -85,6 +91,35 @@ export function useDragResize({
}); });
}, []); }, []);
const startRotate = useCallback((
e: React.PointerEvent,
id: string,
origPos: { x: number; y: number; w: number; h: number; rotation?: number }
) => {
if (!containerRef.current) return;
e.stopPropagation();
e.preventDefault();
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
const rect = containerRef.current.getBoundingClientRect();
const centerX = rect.left + (origPos.x / 100) * rect.width;
const centerY = rect.top + (origPos.y / 100) * rect.height;
setMode('rotate');
setActiveId(id);
setDragStart({
clientX: e.clientX,
clientY: e.clientY,
origX: origPos.x,
origY: origPos.y,
origW: origPos.w,
origH: origPos.h,
origRot: origPos.rotation ?? 0,
centerX,
centerY,
});
}, [containerRef]);
const handlePointerMove = useCallback((e: React.PointerEvent) => { const handlePointerMove = useCallback((e: React.PointerEvent) => {
if (!mode || !activeId || !dragStart || !containerRef.current) return; if (!mode || !activeId || !dragStart || !containerRef.current) return;
@@ -119,8 +154,24 @@ export function useDragResize({
Math.round(newW * 10) / 10, Math.round(newW * 10) / 10,
Math.round(newH * 10) / 10, Math.round(newH * 10) / 10,
); );
} else if (mode === 'rotate' && onRotate && dragStart.centerX !== undefined && dragStart.centerY !== undefined) {
const dx = e.clientX - dragStart.centerX;
const dy = e.clientY - dragStart.centerY;
const currentAngle = Math.atan2(dy, dx) * (180 / Math.PI);
const startDx = dragStart.clientX - dragStart.centerX;
const startDy = dragStart.clientY - dragStart.centerY;
const startAngle = Math.atan2(startDy, startDx) * (180 / Math.PI);
let newRot = (dragStart.origRot || 0) + (currentAngle - startAngle);
// Snap to 45 degree increments if within 5 degrees
const snapRot = Math.round(newRot / 45) * 45;
if (Math.abs(newRot - snapRot) < 5) newRot = snapRot;
onRotate(activeId, Math.round(newRot));
} }
}, [mode, activeId, dragStart, containerRef, onMove, onResize, snapLines, snapThreshold, minSize]); }, [mode, activeId, dragStart, containerRef, onMove, onResize, onRotate, snapLines, snapThreshold, minSize]);
const handlePointerUp = useCallback(() => { const handlePointerUp = useCallback(() => {
setMode(null); setMode(null);
@@ -132,6 +183,7 @@ export function useDragResize({
return { return {
startDrag, startDrag,
startResize, startResize,
startRotate,
handlePointerMove, handlePointerMove,
handlePointerUp, handlePointerUp,
isDragging: mode !== null, isDragging: mode !== null,
+12 -1
View File
@@ -4,11 +4,22 @@ import App from './App.tsx';
import { RenderPage } from './pages/RenderPage.tsx'; import { RenderPage } from './pages/RenderPage.tsx';
import './index.css'; import './index.css';
import { ToastProvider } from './components/ui/ToastProvider';
import { ExportQueueProvider } from './context/ExportQueueContext';
// Detect render mode (used by headless Puppeteer renderer) // Detect render mode (used by headless Puppeteer renderer)
const isRenderMode = new URLSearchParams(window.location.search).has('renderMode'); const isRenderMode = new URLSearchParams(window.location.search).has('renderMode');
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>
{isRenderMode ? <RenderPage /> : <App />} {isRenderMode ? (
<RenderPage />
) : (
<ToastProvider>
<ExportQueueProvider>
<App />
</ExportQueueProvider>
</ToastProvider>
)}
</StrictMode>, </StrictMode>,
); );
+45 -1
View File
@@ -13,7 +13,7 @@ import path from 'path';
import fs from 'fs'; import fs from 'fs';
import crypto from 'crypto'; import crypto from 'crypto';
import { renderFrames, renderStill } from '../engine/renderer/puppeteerRenderer'; import { renderFrames, renderStill } from '../engine/renderer/puppeteerRenderer';
import { encodeVideo, cleanupFrames } from '../engine/renderer/videoEncoder'; import { encodeVideo, cleanupFrames, AudioTrackInput } from '../engine/renderer/videoEncoder';
// ═══ Types ═══ // ═══ Types ═══
@@ -58,6 +58,7 @@ export interface RenderJobCreateParams {
const MAX_CONCURRENT = 1; // Rendering is CPU-intensive const MAX_CONCURRENT = 1; // Rendering is CPU-intensive
const RENDERS_DIR = process.env.BRADLY_RENDERS_DIR || path.join(process.cwd(), 'renders'); const RENDERS_DIR = process.env.BRADLY_RENDERS_DIR || path.join(process.cwd(), 'renders');
const UPLOADS_DIR = process.env.BRADLY_UPLOADS_DIR || path.join(process.cwd(), 'uploads');
// Default serve URL for the running app (dev server or built app) // Default serve URL for the running app (dev server or built app)
const DEFAULT_SERVE_URL = process.env.BRADLY_SERVE_URL || 'http://localhost:5173'; const DEFAULT_SERVE_URL = process.env.BRADLY_SERVE_URL || 'http://localhost:5173';
@@ -236,12 +237,55 @@ async function renderJob(job: RenderJob): Promise<void> {
const codec = job.format === 'webm' ? 'vp8' as const : 'h264' as const; const codec = job.format === 'webm' ? 'vp8' as const : 'h264' as const;
// Extract audio tracks
const audioTracks: AudioTrackInput[] = [];
// Helper to resolve audio URLs for FFmpeg
const resolveAudioUrl = (url: string | undefined): string | null => {
if (!url || typeof url !== 'string') return null;
if (url.startsWith('/api/media/')) {
const filename = url.replace('/api/media/', '');
return path.join(UPLOADS_DIR, filename);
}
if (url.startsWith('http')) {
return url;
}
return null;
};
// 1. Brand audio
const brandAudioUrl = resolveAudioUrl(job.inputProps.designMD?.brandAudioUrl);
if (brandAudioUrl) {
audioTracks.push({
url: brandAudioUrl,
startFrame: 0,
volume: job.inputProps.designMD?.brandAudioVolume ?? 1,
});
}
// 2. Timeline elements
const elements = job.inputProps.timelineElements || [];
elements.forEach((el: any) => {
if ((el.type === 'audio' || el.type === 'video') && el.content) {
const audioUrl = resolveAudioUrl(el.content);
if (audioUrl) {
audioTracks.push({
url: audioUrl,
startFrame: el.startFrame || 0,
volume: el.volume ?? 1,
});
}
}
});
await encodeVideo({ await encodeVideo({
framesDir, framesDir,
framePattern: 'frame-%06d.png', framePattern: 'frame-%06d.png',
outputPath, outputPath,
fps: job.fps, fps: job.fps,
codec, codec,
audioTracks,
durationInFrames: job.durationInFrames,
onProgress: (percent) => { onProgress: (percent) => {
// Encoding is ~30% of the work // Encoding is ~30% of the work
job.progress = 70 + Math.round(percent * 0.29); job.progress = 70 + Math.round(percent * 0.29);
+4
View File
@@ -306,6 +306,10 @@ export interface RenderProps {
activeAction?: 'move' | 'scale' | 'rotate'; activeAction?: 'move' | 'scale' | 'rotate';
brandVisibility?: { logo: boolean; frame: boolean; background: boolean }; brandVisibility?: { logo: boolean; frame: boolean; background: boolean };
outputFormat?: 'video' | 'image'; outputFormat?: 'video' | 'image';
onDragStart?: (e: React.PointerEvent, id: string, origPos: { x: number; y: number; w: number; h: number; rotation?: number }) => void;
startResize?: (e: React.PointerEvent, id: string, origPos: { x: number; y: number; w: number; h: number; rotation?: number }) => void;
startRotate?: (e: React.PointerEvent, id: string, origPos: { x: number; y: number; w: number; h: number; rotation?: number }) => void;
snapGuides?: { x?: number; y?: number };
} }
// ═══ Content Grid / Malla de Contenidos ═══ // ═══ Content Grid / Malla de Contenidos ═══
+33 -4
View File
@@ -87,8 +87,23 @@ export function getAspectDimensions(aspect: string): { w: number; h: number } {
export function getTemplateDuration( export function getTemplateDuration(
template: ExpressTemplate, template: ExpressTemplate,
videoDurations?: Record<string, number>, videoDurations?: Record<string, number>,
designMD?: DesignMD,
): number { ): number {
return template.scenes.reduce((sum, scene) => { return template.scenes.reduce((sum, scene) => {
// If it's a brand segment, read duration from designMD instead of template
if (scene.segmentSource === 'brand' && designMD) {
if (scene.type === 'intro') {
if (!designMD.introVideoUrl) return sum; // Skipped
const frames = designMD.introDurationFrames || (scene.durationSeconds * 30);
return sum + (frames / 30);
}
if (scene.type === 'outro') {
if (!designMD.outroVideoUrl) return sum; // Skipped
const frames = designMD.outroDurationFrames || (scene.durationSeconds * 30);
return sum + (frames / 30);
}
}
// If we know the actual video duration for this scene, use it // If we know the actual video duration for this scene, use it
if (videoDurations && videoDurations[scene.id]) { if (videoDurations && videoDurations[scene.id]) {
return sum + videoDurations[scene.id]; return sum + videoDurations[scene.id];
@@ -121,10 +136,24 @@ export function compileExpressToTimeline(
// Process each scene sequentially — the template's scenes are the sole source of truth // Process each scene sequentially — the template's scenes are the sole source of truth
for (const scene of template.scenes) { for (const scene of template.scenes) {
// Use actual video duration if available (from useVideoDurations) // Default to template's duration
const sceneDuration = (videoDurations && videoDurations[scene.id]) let sceneDuration = scene.durationSeconds;
? videoDurations[scene.id]
: scene.durationSeconds; // Override if brand segment
if (scene.segmentSource === 'brand' && designMD) {
if (scene.type === 'intro') {
if (!designMD.introVideoUrl) { sceneDuration = 0; }
else { sceneDuration = (designMD.introDurationFrames || (scene.durationSeconds * 30)) / 30; }
}
if (scene.type === 'outro') {
if (!designMD.outroVideoUrl) { sceneDuration = 0; }
else { sceneDuration = (designMD.outroDurationFrames || (scene.durationSeconds * 30)) / 30; }
}
} else if (videoDurations && videoDurations[scene.id]) {
// Override if user uploaded video
sceneDuration = videoDurations[scene.id];
}
const sceneDurFrames = sceneDuration * fps; const sceneDurFrames = sceneDuration * fps;
const sceneStart = frameOffset; const sceneStart = frameOffset;
const sceneEnd = frameOffset + sceneDurFrames; const sceneEnd = frameOffset + sceneDurFrames;