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 = ({ 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 ( {el.type === 'audio' ? ((() => { const layerVol = (layer?.volume ?? 100) / 100; const elVol = el.volume ?? 1; const isMuted = layer?.isMuted === true; // Build volume callback for Remotion