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

This commit is contained in:
2026-06-02 20:40:30 -05:00
parent b7656cf8eb
commit 9503dbfabc
39 changed files with 3556 additions and 3506 deletions
+80 -321
View File
@@ -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>
)}