fix(rendering): synchronize FPS, implement render locks, respect brand segment duration, and fix local audio path resolving for ffmpeg
This commit is contained in:
@@ -2,7 +2,6 @@ import React from 'react';
|
||||
import { AbsoluteFill } from '../engine/components';
|
||||
import { useCurrentFrame } from '../engine/player';
|
||||
import { RenderProps } from '../types';
|
||||
import { useCanvasDrag } from './composition/useCanvasDrag';
|
||||
import { BackgroundLayer } from './composition/BackgroundLayer';
|
||||
import { BrandOverlay } from './composition/BrandOverlay';
|
||||
import { CompositionElement } from './composition/CompositionElement';
|
||||
@@ -25,27 +24,23 @@ export const BrandComposition: React.FC<RenderProps> = ({
|
||||
activeLayerId,
|
||||
activeAction,
|
||||
brandVisibility,
|
||||
outputFormat
|
||||
outputFormat,
|
||||
onDragStart,
|
||||
startResize,
|
||||
startRotate,
|
||||
snapGuides
|
||||
}) => {
|
||||
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
|
||||
const brandFullscreenEls = timelineElements.filter(el =>
|
||||
el.isBrandElement && (el.brandDisplayMode ?? 'fullscreen') === 'fullscreen' && el.type === 'video'
|
||||
);
|
||||
const otherElements = timelineElements.filter(el =>
|
||||
!(el.isBrandElement && (el.brandDisplayMode ?? 'fullscreen') === 'fullscreen' && el.type === 'video')
|
||||
);
|
||||
const otherElements = timelineElements.filter(el => {
|
||||
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]) => {
|
||||
let layer = layers.find(l => l.id === el.layerId);
|
||||
@@ -69,23 +64,15 @@ export const BrandComposition: React.FC<RenderProps> = ({
|
||||
activeLayerId={activeLayerId ?? null}
|
||||
activeAction={activeAction ?? 'move'}
|
||||
isImageMode={outputFormat === 'image'}
|
||||
tempPositions={tempPositions}
|
||||
dragStateId={dragState?.id ?? null}
|
||||
containerRef={containerRef}
|
||||
onElementClick={onElementClick}
|
||||
onElementDoubleClick={onElementDoubleClick}
|
||||
onElementContextMenu={onElementContextMenu}
|
||||
onElementDuplicate={onElementDuplicate}
|
||||
onElementDelete={onElementDelete}
|
||||
onElementLock={onElementLock}
|
||||
onDragStart={(id, startX, startY, initialElX, initialElY) => {
|
||||
if (onElementPositionChange) {
|
||||
setDragState({ id, startX, startY, initialElX, initialElY });
|
||||
}
|
||||
}}
|
||||
onTransformStart={(id, type, startX, startY, initialScale, initialRot, centerX, centerY) => {
|
||||
setTransformDragState({ id, type, startX, startY, initialScale, initialRot, centerX, centerY });
|
||||
}}
|
||||
onDragStart={onDragStart}
|
||||
startResize={startResize}
|
||||
startRotate={startRotate}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -93,7 +80,7 @@ export const BrandComposition: React.FC<RenderProps> = ({
|
||||
const showBackground = brandVisibility?.background ?? true;
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={{ backgroundColor: showBackground ? designMD.secondaryColor : 'transparent' }} ref={containerRef}>
|
||||
<AbsoluteFill style={{ backgroundColor: showBackground ? designMD.secondaryColor : 'transparent' }}>
|
||||
{/* Layer 1: Background media (user-uploaded backgrounds) */}
|
||||
<BackgroundLayer timelineElements={timelineElements} layers={layers} />
|
||||
|
||||
@@ -107,7 +94,7 @@ export const BrandComposition: React.FC<RenderProps> = ({
|
||||
{otherElements.map(renderElement)}
|
||||
|
||||
{/* Smart Guides Overlay */}
|
||||
<SmartGuides guides={guides} />
|
||||
<SmartGuides guides={snapGuides || { x: null, y: null }} />
|
||||
</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 { TimelineElement, MediaFilter, TimelineLayer, DesignMD } from '../types';
|
||||
import { AudioLayerPanel } from './properties/AudioLayerPanel';
|
||||
import { GraphicLayerPanel } from './properties/GraphicLayerPanel';
|
||||
|
||||
import { TransitionsPanel } from './properties/TransitionsPanel';
|
||||
import { GlobalSettingsPanel } from './properties/GlobalSettingsPanel';
|
||||
import { ElementPropertiesPanel } from './properties/ElementPropertiesPanel';
|
||||
@@ -116,8 +116,6 @@ export const StudioProperties: React.FC<StudioPropertiesProps> = ({
|
||||
playerRef={playerRef}
|
||||
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
|
||||
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 { BrandComposition } from './BrandComposition';
|
||||
import { RenderProps, TimelineElement } from '../types';
|
||||
import { PlaySquare } from 'lucide-react';
|
||||
import { CanvasWorkspace } from './ui/CanvasWorkspace';
|
||||
import { useEditor } from '../context/EditorContext';
|
||||
import { useDragResize } from '../hooks/useDragResize';
|
||||
import { ElementActionToolbar } from './composition/ElementActionToolbar';
|
||||
import { SAFE_AREAS } from '../config/constants';
|
||||
import { getAudioDuration, durationToFrames } from '../utils/audioMetadata';
|
||||
@@ -55,6 +56,31 @@ export const StudioWorkspace: React.FC<StudioWorkspaceProps> = ({
|
||||
|
||||
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)
|
||||
useEffect(() => {
|
||||
if (!selectedElementId) return;
|
||||
@@ -123,11 +149,14 @@ export const StudioWorkspace: React.FC<StudioWorkspaceProps> = ({
|
||||
const dy = e.clientY - lastPanPos.current.y;
|
||||
setPan(prev => ({ x: prev.x + dx, y: prev.y + dy }));
|
||||
lastPanPos.current = { x: e.clientX, y: e.clientY };
|
||||
} else {
|
||||
handleDragMove(e);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePointerUp = () => {
|
||||
setIsPanning(false);
|
||||
handleDragUp();
|
||||
};
|
||||
|
||||
// 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 lastDropTime = useRef<number>(0);
|
||||
|
||||
// ═══ Drop handler for canvas ═══
|
||||
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
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');
|
||||
if (!data || !setTimelineElements) return;
|
||||
|
||||
@@ -230,18 +266,26 @@ export const StudioWorkspace: React.FC<StudioWorkspaceProps> = ({
|
||||
}
|
||||
}
|
||||
|
||||
setTimelineElements(prev => [...prev, {
|
||||
id: 'el-' + Date.now(),
|
||||
layerId: targetLayerId,
|
||||
type: elementType,
|
||||
content: parsed.src,
|
||||
startFrame: currentFrame,
|
||||
endFrame: Math.min(durationInFrames, currentFrame + 100),
|
||||
x: Math.max(5, Math.min(95, x)),
|
||||
y: Math.max(5, Math.min(95, y)),
|
||||
scale: 1,
|
||||
originalFileName: parsed.fileName,
|
||||
}]);
|
||||
const newId = '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,
|
||||
type: elementType,
|
||||
content: parsed.src,
|
||||
startFrame: currentFrame,
|
||||
endFrame: Math.min(durationInFrames, currentFrame + 100),
|
||||
x: Math.max(5, Math.min(95, x)),
|
||||
y: Math.max(5, Math.min(95, y)),
|
||||
scale: 1,
|
||||
originalFileName: parsed.fileName,
|
||||
}];
|
||||
});
|
||||
|
||||
// Auto-detect audio duration and update endFrame
|
||||
if (elementType === 'audio') {
|
||||
@@ -438,6 +482,7 @@ export const StudioWorkspace: React.FC<StudioWorkspaceProps> = ({
|
||||
isEditing={!!selectedElementId}
|
||||
className=""
|
||||
canvasClassName="rounded"
|
||||
canvasRef={compositionContainerRef}
|
||||
overlay={selectedElementId ? (
|
||||
<>
|
||||
{/* Text editor overlay */}
|
||||
@@ -595,7 +640,12 @@ export const StudioWorkspace: React.FC<StudioWorkspaceProps> = ({
|
||||
setEditingTextId(id);
|
||||
setShowContextMenu(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
onDragStart: startDrag,
|
||||
onTransformStart: undefined,
|
||||
startResize,
|
||||
startRotate,
|
||||
snapGuides,
|
||||
}}
|
||||
durationInFrames={durationInFrames}
|
||||
compositionWidth={dimensions.width}
|
||||
|
||||
@@ -28,10 +28,15 @@ export const BackgroundLayer: React.FC<BackgroundLayerProps> = ({ timelineElemen
|
||||
return (
|
||||
<>
|
||||
{backgroundElements.map((el) => {
|
||||
if (el.isHidden) return null;
|
||||
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 (
|
||||
<Sequence key={el.id} from={el.startFrame} durationInFrames={el.endFrame - el.startFrame}>
|
||||
<AbsoluteFill style={filterStyle}>
|
||||
<AbsoluteFill style={{ ...filterStyle, opacity }}>
|
||||
{el.type === 'color' && (
|
||||
<div style={{
|
||||
width: '100%',
|
||||
|
||||
@@ -8,6 +8,8 @@ import { ChromaKeyImage } from './ChromaKeyImage';
|
||||
import { ChromaKeyVideo } from './ChromaKeyVideo';
|
||||
import type { CanvasActionMode } from './ElementActionToolbar';
|
||||
import { loadGoogleFont } from '../../utils/googleFontsApi';
|
||||
import { ElementRenderer } from './ElementRenderer';
|
||||
import { ElementHandles } from './ElementHandles';
|
||||
|
||||
interface CompositionElementProps {
|
||||
element: TimelineElement;
|
||||
@@ -18,14 +20,12 @@ interface CompositionElementProps {
|
||||
activeLayerId: string | null;
|
||||
activeAction: CanvasActionMode;
|
||||
isImageMode?: boolean;
|
||||
tempPositions: Record<string, { x: number; y: number; scale?: number; rotation?: number }>;
|
||||
dragStateId: string | null;
|
||||
containerRef: RefObject<HTMLDivElement>;
|
||||
onElementClick?: (id: string) => void;
|
||||
onElementDoubleClick?: (id: string) => void;
|
||||
onElementContextMenu?: (id: string, e: React.MouseEvent) => void;
|
||||
onDragStart: (id: string, startX: number, startY: number, initialElX: number, initialElY: number) => void;
|
||||
onTransformStart: (id: string, type: 'scale' | 'rotate', startX: number, startY: number, initialScale: number, initialRot: number, centerX: number, centerY: number) => void;
|
||||
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;
|
||||
onElementDuplicate?: (id: string) => void;
|
||||
onElementDelete?: (id: string) => void;
|
||||
onElementLock?: (id: string) => void;
|
||||
@@ -40,14 +40,12 @@ export const CompositionElement: React.FC<CompositionElementProps> = ({
|
||||
activeLayerId,
|
||||
activeAction,
|
||||
isImageMode = false,
|
||||
tempPositions,
|
||||
dragStateId,
|
||||
containerRef,
|
||||
onElementClick,
|
||||
onElementDoubleClick,
|
||||
onElementContextMenu,
|
||||
onDragStart,
|
||||
onTransformStart,
|
||||
startResize,
|
||||
startRotate,
|
||||
onElementDuplicate,
|
||||
onElementDelete,
|
||||
onElementLock,
|
||||
@@ -66,24 +64,37 @@ export const CompositionElement: React.FC<CompositionElementProps> = ({
|
||||
|
||||
// Skip hidden elements (after all hooks to satisfy Rules of Hooks)
|
||||
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 layerOpacity = layer?.opacity ?? 1;
|
||||
const baseOpacity = ((el.opacity ?? 100) / 100) * layerOpacity;
|
||||
|
||||
const currentScale = tempPositions[el.id]?.scale ?? el.scale ?? 1;
|
||||
const currentRot = tempPositions[el.id]?.rotation ?? el.rotation ?? 0;
|
||||
const tempX = tempPositions[el.id]?.x;
|
||||
const tempY = tempPositions[el.id]?.y;
|
||||
const currentScale = el.scale ?? 1;
|
||||
const currentRot = el.rotation ?? 0;
|
||||
|
||||
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*
|
||||
let currentX = tempX ?? el.x;
|
||||
let currentY = tempY ?? el.y;
|
||||
let currentX = el.x;
|
||||
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
|
||||
const resolved = resolveKeyframes(el.keyframes, frame, {
|
||||
x: el.x, y: el.y,
|
||||
@@ -94,10 +105,10 @@ export const CompositionElement: React.FC<CompositionElementProps> = ({
|
||||
} else if (!el.keyframes) {
|
||||
// Legacy 2-point keyframes
|
||||
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) {
|
||||
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',
|
||||
} : undefined;
|
||||
|
||||
// ── Transform helpers ──
|
||||
|
||||
const startScaleDrag = (e: React.PointerEvent) => {
|
||||
e.stopPropagation();
|
||||
if (!containerRef.current) return;
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
onTransformStart(
|
||||
el.id, 'scale', e.clientX, e.clientY,
|
||||
currentScale, currentRot,
|
||||
rect.left + (currentX / 100) * rect.width,
|
||||
rect.top + (currentY / 100) * rect.height
|
||||
);
|
||||
};
|
||||
// Adapters for the drag hooks
|
||||
const getOrigPos = () => ({
|
||||
x: currentX,
|
||||
y: currentY,
|
||||
w: el.width ?? (el.type === 'text' ? undefined : 25) ?? 25,
|
||||
h: el.height ?? 25,
|
||||
rotation: currentRot,
|
||||
});
|
||||
|
||||
const startRotateDrag = (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) => {
|
||||
const handleStartDrag = (e: React.PointerEvent) => {
|
||||
if (!isInteractive) return;
|
||||
e.stopPropagation();
|
||||
if (e.button === 2) return;
|
||||
if (onElementClick) onElementClick(el.id);
|
||||
|
||||
// In move mode: drag moves. In scale/rotate: start respective transform.
|
||||
if (activeAction === 'move') {
|
||||
onDragStart(el.id, e.clientX, e.clientY, currentX, currentY);
|
||||
} else if (activeAction === 'scale') {
|
||||
startScaleDrag(e);
|
||||
} else if (activeAction === 'rotate') {
|
||||
startRotateDrag(e);
|
||||
|
||||
if (activeAction === 'move' && onDragStart) {
|
||||
onDragStart(e, el.id, getOrigPos());
|
||||
} else if (activeAction === 'scale' && startResize) {
|
||||
startResize(e, el.id, getOrigPos());
|
||||
} else if (activeAction === 'rotate' && startRotate) {
|
||||
startRotate(e, el.id, getOrigPos());
|
||||
}
|
||||
};
|
||||
|
||||
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 ──
|
||||
const outlineColor = el.isLocked ? '#d97706' : '#8b5cf6';
|
||||
|
||||
@@ -325,7 +331,7 @@ export const CompositionElement: React.FC<CompositionElementProps> = ({
|
||||
opacity: opacity,
|
||||
cursor: isInteractive
|
||||
? (activeAction === 'move'
|
||||
? (dragStateId === el.id ? 'grabbing' : 'grab')
|
||||
? 'grab'
|
||||
: activeAction === 'scale' ? 'nwse-resize'
|
||||
: activeAction === 'rotate' ? 'alias'
|
||||
: 'grab')
|
||||
@@ -351,276 +357,29 @@ export const CompositionElement: React.FC<CompositionElementProps> = ({
|
||||
e.stopPropagation();
|
||||
if (isInteractive && onElementContextMenu) onElementContextMenu(el.id, e as unknown as React.MouseEvent);
|
||||
}}
|
||||
onPointerDown={startDrag}
|
||||
onPointerDown={handleStartDrag}
|
||||
>
|
||||
{/* ── Content ── */}
|
||||
{el.type === 'text' ? (
|
||||
<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,
|
||||
// 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;
|
||||
})()
|
||||
)}
|
||||
{/* ── Visual Content ── */}
|
||||
<ElementRenderer
|
||||
element={el}
|
||||
designMD={designMD}
|
||||
displayContent={displayContent}
|
||||
filterStr={filterStr}
|
||||
hasContainBg={hasContainBg}
|
||||
containBgStyle={containBgStyle}
|
||||
ckColor={ckColor}
|
||||
ckTolerance={ckTolerance}
|
||||
ckSoftness={ckSoftness}
|
||||
/>
|
||||
|
||||
{/* ═══ Scale Handles — only in Scale mode ═══ */}
|
||||
{isSelected && 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={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>
|
||||
</>
|
||||
)}
|
||||
{/* ── Editor Handles ── */}
|
||||
<ElementHandles
|
||||
isSelected={isSelected}
|
||||
activeAction={activeAction}
|
||||
currentScale={currentScale}
|
||||
onScaleDrag={handleScaleDrag}
|
||||
onRotateDrag={handleRotateDrag}
|
||||
/>
|
||||
</div>
|
||||
</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 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 backgroundFieldId = useMemo(() => findBackgroundFieldId(template), [template]);
|
||||
const isVideo = template.format === 'video';
|
||||
|
||||
@@ -103,7 +103,7 @@ export const ProductionForm: React.FC<ProductionFormProps> = ({
|
||||
|
||||
const designMD = brand.design;
|
||||
const fps = 30;
|
||||
const totalDuration = getTemplateDuration(template, videoDurations);
|
||||
const totalDuration = getTemplateDuration(template, videoDurations, designMD);
|
||||
const totalFrames = Math.max(30, totalDuration * fps);
|
||||
|
||||
// ─── Compile for ExportModal (only when modal is open — LivePreviewCanvas handles its own compile) ───
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useState, useMemo } from '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 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 { showToast } = useToast();
|
||||
const [format, setFormat] = useState<RenderFormat>('mp4');
|
||||
const [fps, setFps] = useState(30);
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
@@ -92,17 +94,43 @@ export const ExportModal: React.FC<ExportModalProps> = ({
|
||||
const [resIdx, setResIdx] = useState(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
|
||||
const estimatedSize = useMemo(() => {
|
||||
if (isStill) return '~0.5 MB';
|
||||
const seconds = durationInFrames / fps;
|
||||
const pixels = selectedRes.w * selectedRes.h;
|
||||
const bitrateMap: Record<string, number> = { mp4: 5, webm: 3, gif: 15 };
|
||||
const bitrate = bitrateMap[format] ?? 5; // MB per minute per megapixel
|
||||
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`;
|
||||
}, [format, selectedRes, durationInFrames, fps, isStill]);
|
||||
}, [format, selectedRes, durationSeconds, fps, isStill]);
|
||||
|
||||
// Auto-select image format in image mode
|
||||
const filteredFormats = useMemo(() => {
|
||||
@@ -119,15 +147,6 @@ export const ExportModal: React.FC<ExportModalProps> = ({
|
||||
}
|
||||
}, [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 () => {
|
||||
setIsExporting(true);
|
||||
try {
|
||||
@@ -136,15 +155,25 @@ export const ExportModal: React.FC<ExportModalProps> = ({
|
||||
width: selectedRes.w,
|
||||
height: selectedRes.h,
|
||||
fps,
|
||||
durationInFrames: isStill ? 1 : durationInFrames,
|
||||
durationInFrames: actualFrames,
|
||||
designMD,
|
||||
textOverlay,
|
||||
timelineElements,
|
||||
timelineElements: scaledTimelineElements,
|
||||
layers,
|
||||
brandVisibility,
|
||||
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 {
|
||||
setIsExporting(false);
|
||||
}
|
||||
@@ -266,7 +295,7 @@ export const ExportModal: React.FC<ExportModalProps> = ({
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-[10px]">
|
||||
<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>
|
||||
{/* Quality Tier */}
|
||||
<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);
|
||||
}, [selectedTemplate, fieldData, designMD, company, videoDurations]);
|
||||
|
||||
const totalDuration = selectedTemplate ? getTemplateDuration(selectedTemplate, videoDurations) : 0;
|
||||
const totalDuration = selectedTemplate ? getTemplateDuration(selectedTemplate, videoDurations, designMD) : 0;
|
||||
const fps = 30;
|
||||
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;
|
||||
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);
|
||||
if (!visualLayer) {
|
||||
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
|
||||
let targetLayerId = 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);
|
||||
if (!visualLayer) {
|
||||
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) => {
|
||||
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 fps = 30;
|
||||
const totalDuration = getTemplateDuration(template, videoDurations);
|
||||
const totalDuration = getTemplateDuration(template, videoDurations, designMD);
|
||||
const totalFrames = Math.max(30, totalDuration * fps);
|
||||
const dimensions = getAspectDimensions(template.aspectRatio);
|
||||
|
||||
@@ -144,19 +144,35 @@ export const LivePreviewCanvas: React.FC<LivePreviewCanvasProps> = ({
|
||||
const sceneSegments = useMemo(() => {
|
||||
let offset = 0;
|
||||
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 = {
|
||||
id: scene.id,
|
||||
name: scene.type === 'intro' ? 'INTRO' : scene.type === 'outro' ? 'OUTRO' : scene.name,
|
||||
type: scene.type || 'content',
|
||||
startFrame: offset,
|
||||
endFrame: offset + durFrames,
|
||||
widthPct: (durFrames / totalFrames) * 100,
|
||||
widthPct: totalFrames > 0 ? (durFrames / totalFrames) * 100 : 0,
|
||||
};
|
||||
offset += durFrames;
|
||||
return seg;
|
||||
});
|
||||
}, [template, fps, totalFrames]);
|
||||
}, [template, fps, totalFrames, videoDurations, designMD]);
|
||||
|
||||
const handlePlayToggle = useCallback(() => {
|
||||
if (playerRef.current) {
|
||||
|
||||
@@ -25,6 +25,8 @@ interface CanvasWorkspaceProps {
|
||||
children: React.ReactNode;
|
||||
/** Content rendered in the OVERLAY layer — can extend beyond canvas */
|
||||
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) */
|
||||
overlayRef?: React.RefObject<HTMLDivElement>;
|
||||
/** Pointer event handlers for the extended overlay */
|
||||
@@ -41,6 +43,7 @@ export const CanvasWorkspace: React.FC<CanvasWorkspaceProps> = ({
|
||||
canvasClassName = '',
|
||||
children,
|
||||
overlay,
|
||||
canvasRef,
|
||||
overlayRef,
|
||||
onOverlayPointerMove,
|
||||
onOverlayPointerUp,
|
||||
@@ -61,6 +64,7 @@ export const CanvasWorkspace: React.FC<CanvasWorkspaceProps> = ({
|
||||
|
||||
{/* Inner canvas container */}
|
||||
<div
|
||||
ref={canvasRef}
|
||||
className="relative"
|
||||
style={{ height: '100%', maxHeight: '100%', aspectRatio }}
|
||||
>
|
||||
|
||||
@@ -1,9 +1,22 @@
|
||||
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 {
|
||||
element: TimelineElement;
|
||||
onUpdate: (updates: Partial<TimelineElement>) => void;
|
||||
onApplyPreset: (updates: TextStylePresetUpdates) => void;
|
||||
}
|
||||
|
||||
const TEXT_STYLE_PRESETS = [
|
||||
@@ -53,7 +66,7 @@ const TEXT_STYLE_PRESETS = [
|
||||
* TextStylePresets — Quick-apply pre-designed text style configurations.
|
||||
* 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 (
|
||||
<div className="space-y-1.5">
|
||||
<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) => (
|
||||
<button
|
||||
key={preset.name}
|
||||
onClick={() => onUpdate(preset.styles)}
|
||||
onClick={() => onApplyPreset(preset.styles)}
|
||||
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"
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user