fix(rendering): synchronize FPS, implement render locks, respect brand segment duration, and fix local audio path resolving for ffmpeg
This commit is contained in:
+8
-6
@@ -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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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,8 +266,15 @@ 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);
|
||||||
|
|
||||||
|
setTimelineElements(prev => {
|
||||||
|
// Prevent duplicate drops in the same millisecond or duplicate event bubbling
|
||||||
|
if (prev.some(el => el.id === newId)) return prev;
|
||||||
|
|
||||||
|
return [...prev, {
|
||||||
|
id: newId,
|
||||||
layerId: targetLayerId,
|
layerId: targetLayerId,
|
||||||
type: elementType,
|
type: elementType,
|
||||||
content: parsed.src,
|
content: parsed.src,
|
||||||
@@ -241,7 +284,8 @@ export const StudioWorkspace: React.FC<StudioWorkspaceProps> = ({
|
|||||||
y: Math.max(5, Math.min(95, y)),
|
y: Math.max(5, Math.min(95, y)),
|
||||||
scale: 1,
|
scale: 1,
|
||||||
originalFileName: parsed.fileName,
|
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%',
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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';
|
||||||
|
|||||||
@@ -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) ───
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -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' };
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 }}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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,7 +294,8 @@ 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 (
|
||||||
|
<ExportQueueContext.Provider value={{
|
||||||
jobs,
|
jobs,
|
||||||
activeJobs,
|
activeJobs,
|
||||||
hasActiveJobs,
|
hasActiveJobs,
|
||||||
@@ -250,5 +303,16 @@ export function useExportQueue() {
|
|||||||
startExport,
|
startExport,
|
||||||
cancelJob,
|
cancelJob,
|
||||||
downloadJob,
|
downloadJob,
|
||||||
|
}}>
|
||||||
|
{children}
|
||||||
|
</ExportQueueContext.Provider>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const useExportQueue = () => {
|
||||||
|
const context = useContext(ExportQueueContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useExportQueue must be used within ExportQueueProvider');
|
||||||
}
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 ═══
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user