395 lines
16 KiB
TypeScript
395 lines
16 KiB
TypeScript
import React, { RefObject, useEffect } from 'react';
|
|
import { Sequence, AbsoluteFill, Img, Video, Audio } from '../../engine/components';
|
|
import { useVideoConfig } from '../../engine/player';
|
|
import { interpolate } from '../../engine/animation';
|
|
import { TimelineElement, TimelineLayer, DesignMD } from '../../types';
|
|
import { calculateElementTransitions } from './useTransitions';
|
|
import { resolveKeyframes } from './keyframeEngine';
|
|
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;
|
|
layer: TimelineLayer | undefined;
|
|
designMD: DesignMD;
|
|
frame: number;
|
|
selectedElementId: string | null;
|
|
activeLayerId: string | null;
|
|
activeAction: CanvasActionMode;
|
|
isImageMode?: boolean;
|
|
onElementClick?: (id: string) => void;
|
|
onElementDoubleClick?: (id: string) => void;
|
|
onElementContextMenu?: (id: string, e: React.MouseEvent) => 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;
|
|
}
|
|
|
|
export const CompositionElement: React.FC<CompositionElementProps> = ({
|
|
element: el,
|
|
layer,
|
|
designMD,
|
|
frame,
|
|
selectedElementId,
|
|
activeLayerId,
|
|
activeAction,
|
|
isImageMode = false,
|
|
onElementClick,
|
|
onElementDoubleClick,
|
|
onElementContextMenu,
|
|
onDragStart,
|
|
startResize,
|
|
startRotate,
|
|
onElementDuplicate,
|
|
onElementDelete,
|
|
onElementLock,
|
|
}) => {
|
|
// ─── Dynamic font loading for text elements ───
|
|
const fontFamily = el.type === 'text' ? (el.fontFamily ?? designMD.baseFont) : null;
|
|
useEffect(() => {
|
|
if (fontFamily) loadGoogleFont(fontFamily);
|
|
}, [fontFamily]);
|
|
|
|
const { width } = useVideoConfig();
|
|
const scaleFactor = width / 1080;
|
|
|
|
// In image mode: all non-locked elements are interactive (Photoshop model)
|
|
// In video mode: only elements on the active layer are interactive
|
|
const isInteractive = isImageMode
|
|
? !el.isLocked
|
|
: (!!activeLayerId && el.layerId === activeLayerId) && !el.isLocked;
|
|
|
|
// 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 = el.scale ?? 1;
|
|
const currentRot = el.rotation ?? 0;
|
|
|
|
const { opacity, transformStr, displayContent } = calculateElementTransitions(
|
|
el, frame, baseOpacity, currentScale, currentRot, undefined, undefined
|
|
);
|
|
|
|
// Resolve position — multi-keyframes take priority over legacy animEnd*
|
|
let currentX = el.x;
|
|
let currentY = el.y;
|
|
|
|
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,
|
|
scale: currentScale, opacity: baseOpacity, rotation: currentRot,
|
|
});
|
|
currentX = resolved.x;
|
|
currentY = resolved.y;
|
|
} else if (!el.keyframes) {
|
|
// Legacy 2-point keyframes
|
|
if (el.animEndX !== undefined) {
|
|
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], [el.y, el.animEndY], { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' });
|
|
}
|
|
}
|
|
|
|
const isFullscreenBrand = el.isBrandElement && (el.brandDisplayMode ?? 'fullscreen') === 'fullscreen' && el.type === 'video';
|
|
|
|
const resolvedBlendMode = (() => {
|
|
// When chroma key is active, transparency is handled by the canvas — no CSS blend needed
|
|
if (el.chromaKeyEnabled) return 'normal';
|
|
if (!el.isBrandElement) return el.blendMode || 'normal';
|
|
if (el.content === designMD.introVideoUrl) return designMD.introBlendMode || el.blendMode || 'normal';
|
|
if (el.content === designMD.outroVideoUrl) return designMD.outroBlendMode || el.blendMode || 'normal';
|
|
return el.blendMode || 'normal';
|
|
})();
|
|
|
|
// Chroma key defaults
|
|
const ckColor = el.chromaKeyColor || '#ffffff';
|
|
const ckTolerance = el.chromaKeyTolerance ?? 30;
|
|
const ckSoftness = el.chromaKeySoftness ?? 10;
|
|
|
|
const scaledBlurAmount = (el.blurAmount ?? 0) * scaleFactor;
|
|
const filterStr = `brightness(${el.brightness ?? 100}%) contrast(${el.contrast ?? 100}%) saturate(${el.saturation ?? 100}%)${el.hueRotate ? ` hue-rotate(${el.hueRotate}deg)` : ''}${el.sepia ? ` sepia(${el.sepia}%)` : ''}${scaledBlurAmount ? ` blur(${scaledBlurAmount}px)` : ''}`;
|
|
|
|
// Contain background: wrap media in a colored container when objectFit='contain' and color is set
|
|
const hasContainBg = (el.objectFit === 'contain' || !el.objectFit) && !!el.containBgColor;
|
|
const containBgStyle: React.CSSProperties | undefined = hasContainBg ? {
|
|
width: '100%',
|
|
height: el.height ? '100%' : 'auto',
|
|
backgroundColor: el.containBgColor!,
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
overflow: 'hidden',
|
|
} : undefined;
|
|
|
|
|
|
// 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 handleStartDrag = (e: React.PointerEvent) => {
|
|
if (!isInteractive) return;
|
|
e.stopPropagation();
|
|
if (e.button === 2) return;
|
|
if (onElementClick) onElementClick(el.id);
|
|
|
|
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';
|
|
|
|
return (
|
|
<Sequence from={el.startFrame} durationInFrames={Math.max(1, el.endFrame - el.startFrame)}>
|
|
{el.type === 'audio' ? ((() => {
|
|
const layerVol = (layer?.volume ?? 100) / 100;
|
|
const elVol = el.volume ?? 1;
|
|
const isMuted = layer?.isMuted === true;
|
|
|
|
// Build volume callback for Remotion <Audio>
|
|
const volumeCallback = (f: number) => {
|
|
if (isMuted) return 0;
|
|
|
|
let vol = layerVol * elVol;
|
|
|
|
// Fade in
|
|
const fadeIn = el.fadeInFrames ?? 0;
|
|
if (fadeIn > 0 && f < fadeIn) {
|
|
vol *= interpolate(f, [0, fadeIn], [0, 1], {
|
|
extrapolateLeft: 'clamp',
|
|
extrapolateRight: 'clamp',
|
|
});
|
|
}
|
|
|
|
// Fade out
|
|
const fadeOut = el.fadeOutFrames ?? 0;
|
|
const clipDuration = el.endFrame - el.startFrame;
|
|
if (fadeOut > 0 && f > clipDuration - fadeOut) {
|
|
vol *= interpolate(f, [clipDuration - fadeOut, clipDuration], [1, 0], {
|
|
extrapolateLeft: 'clamp',
|
|
extrapolateRight: 'clamp',
|
|
});
|
|
}
|
|
|
|
// Volume keyframes
|
|
const vkfs = el.volumeKeyframes;
|
|
if (vkfs && vkfs.length > 0) {
|
|
const sorted = [...vkfs].sort((a, b) => a.frame - b.frame);
|
|
let before = sorted[0];
|
|
let after = sorted[sorted.length - 1];
|
|
for (let i = 0; i < sorted.length - 1; i++) {
|
|
if (f >= sorted[i].frame && f <= sorted[i + 1].frame) {
|
|
before = sorted[i];
|
|
after = sorted[i + 1];
|
|
break;
|
|
}
|
|
}
|
|
if (f <= before.frame) {
|
|
vol *= before.volume;
|
|
} else if (f >= after.frame) {
|
|
vol *= after.volume;
|
|
} else {
|
|
const kfVol = interpolate(f, [before.frame, after.frame], [before.volume, after.volume], {
|
|
extrapolateLeft: 'clamp',
|
|
extrapolateRight: 'clamp',
|
|
});
|
|
vol *= kfVol;
|
|
}
|
|
}
|
|
|
|
return Math.max(0, Math.min(1, vol));
|
|
};
|
|
|
|
return <Audio src={el.content} volume={volumeCallback} />;
|
|
})()) : isFullscreenBrand ? (
|
|
/* ═══ Fullscreen Brand Video ═══ */
|
|
<AbsoluteFill
|
|
style={{
|
|
cursor: isInteractive ? 'pointer' : 'default',
|
|
pointerEvents: isInteractive ? 'auto' : 'none',
|
|
mixBlendMode: resolvedBlendMode !== 'normal' ? resolvedBlendMode as React.CSSProperties['mixBlendMode'] : undefined,
|
|
}}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
if (isInteractive && onElementClick) onElementClick(el.id);
|
|
}}
|
|
>
|
|
{/* Positioned container — matches branding preview (PreviewRemotion) */}
|
|
<div style={{
|
|
position: 'absolute',
|
|
left: `${el.x ?? 0}%`,
|
|
top: `${el.y ?? 0}%`,
|
|
width: `${el.w ?? 100}%`,
|
|
height: `${el.h ?? 100}%`,
|
|
overflow: 'hidden',
|
|
borderRadius: (el.w ?? 100) < 100 || (el.h ?? 100) < 100 ? 8 : 0,
|
|
backgroundColor: el.containBgColor || undefined,
|
|
}}>
|
|
{el.chromaKeyEnabled ? (
|
|
<ChromaKeyVideo
|
|
src={el.content}
|
|
chromaKeyColor={ckColor}
|
|
chromaKeyTolerance={ckTolerance}
|
|
chromaKeySoftness={ckSoftness}
|
|
style={{
|
|
width: '100%',
|
|
height: '100%',
|
|
objectFit: (el.objectFit || (() => {
|
|
if (el.content === designMD.introVideoUrl) return designMD.introVideoFit || 'cover';
|
|
if (el.content === designMD.outroVideoUrl) return designMD.outroVideoFit || 'cover';
|
|
return 'cover';
|
|
})()) as React.CSSProperties['objectFit'],
|
|
opacity: opacity,
|
|
filter: filterStr,
|
|
}}
|
|
/>
|
|
) : (
|
|
<Video
|
|
src={el.content}
|
|
volume={el.volume ?? 1}
|
|
style={{
|
|
width: '100%',
|
|
height: '100%',
|
|
objectFit: (el.objectFit || (() => {
|
|
if (el.content === designMD.introVideoUrl) return designMD.introVideoFit || 'cover';
|
|
if (el.content === designMD.outroVideoUrl) return designMD.outroVideoFit || 'cover';
|
|
return 'cover';
|
|
})()) as React.CSSProperties['objectFit'],
|
|
opacity: opacity,
|
|
pointerEvents: 'none',
|
|
filter: filterStr,
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
{isSelected && (
|
|
<div style={{
|
|
position: 'absolute',
|
|
left: `${el.x ?? 0}%`,
|
|
top: `${el.y ?? 0}%`,
|
|
width: `${el.w ?? 100}%`,
|
|
height: `${el.h ?? 100}%`,
|
|
border: '3px solid #d97706',
|
|
pointerEvents: 'none',
|
|
borderRadius: (el.w ?? 100) < 100 || (el.h ?? 100) < 100 ? 8 : 0,
|
|
}} />
|
|
)}
|
|
</AbsoluteFill>
|
|
) : (
|
|
/* ═══ Normal Positioned Element ═══ */
|
|
<AbsoluteFill style={{ pointerEvents: 'none' }}>
|
|
<div
|
|
style={{
|
|
position: 'absolute',
|
|
left: `${currentX}%`,
|
|
top: `${currentY}%`,
|
|
width: el.type === 'text' ? (el.width ? `${el.width}%` : undefined) : `${el.width ?? 25}%`,
|
|
height: el.height ? `${el.height}%` : undefined,
|
|
transform: `${transformStr}${el.flipH ? ' scaleX(-1)' : ''}${el.flipV ? ' scaleY(-1)' : ''}`,
|
|
opacity: opacity,
|
|
cursor: isInteractive
|
|
? (activeAction === 'move'
|
|
? 'grab'
|
|
: activeAction === 'scale' ? 'nwse-resize'
|
|
: activeAction === 'rotate' ? 'alias'
|
|
: 'grab')
|
|
: (el.isLocked ? 'not-allowed' : 'default'),
|
|
outline: isSelected ? `${Math.max(1, 3 / currentScale)}px dashed ${outlineColor}` : 'none',
|
|
outlineOffset: `${6 / currentScale}px`,
|
|
pointerEvents: isInteractive || isSelected ? 'auto' : 'none',
|
|
mixBlendMode: resolvedBlendMode !== 'normal' ? resolvedBlendMode as React.CSSProperties['mixBlendMode'] : undefined,
|
|
border: el.borderWidth ? `${el.borderWidth * scaleFactor}px ${el.borderStyle ?? 'solid'} ${el.borderColor ?? '#ffffff'}` : undefined,
|
|
borderRadius: el.borderRadius ? `${el.borderRadius * scaleFactor}px` : undefined,
|
|
overflow: (el.height || el.borderRadius) ? 'hidden' : undefined,
|
|
boxShadow: el.boxShadowBlur || el.boxShadowX || el.boxShadowY
|
|
? `${(el.boxShadowX ?? 0) * scaleFactor}px ${(el.boxShadowY ?? 4) * scaleFactor}px ${(el.boxShadowBlur ?? 10) * scaleFactor}px ${el.boxShadowColor ?? 'rgba(0,0,0,0.5)'}`
|
|
: undefined,
|
|
}}
|
|
onClick={(e) => { e.stopPropagation(); }}
|
|
onDoubleClick={(e) => {
|
|
e.stopPropagation();
|
|
if (isInteractive && onElementDoubleClick) onElementDoubleClick(el.id);
|
|
}}
|
|
onContextMenu={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
if (isInteractive && onElementContextMenu) onElementContextMenu(el.id, e as unknown as React.MouseEvent);
|
|
}}
|
|
onPointerDown={handleStartDrag}
|
|
>
|
|
{/* ── Visual Content ── */}
|
|
<ElementRenderer
|
|
element={el}
|
|
designMD={designMD}
|
|
displayContent={displayContent}
|
|
filterStr={filterStr}
|
|
hasContainBg={hasContainBg}
|
|
containBgStyle={containBgStyle}
|
|
ckColor={ckColor}
|
|
ckTolerance={ckTolerance}
|
|
ckSoftness={ckSoftness}
|
|
/>
|
|
|
|
{/* ── Editor Handles ── */}
|
|
<ElementHandles
|
|
isSelected={isSelected}
|
|
activeAction={activeAction}
|
|
currentScale={currentScale}
|
|
onScaleDrag={handleScaleDrag}
|
|
onRotateDrag={handleRotateDrag}
|
|
/>
|
|
</div>
|
|
</AbsoluteFill>
|
|
)}
|
|
</Sequence>
|
|
);
|
|
};
|