"use client"; import { useTimelineStore } from "@/stores/timeline-store"; import { TimelineElement, TimelineTrack } from "@/types/timeline"; import { useMediaStore } from "@/stores/media-store"; import { MediaFile } from "@/types/media"; import { usePlaybackStore } from "@/stores/playback-store"; import { useEditorStore } from "@/stores/editor-store"; import { PLATFORM_LAYOUTS } from "@/constants/editor-constants"; import type { PlatformLayout } from "@/types/editor"; import { Button } from "@/components/ui/button"; import { Play, Pause, Expand, SkipBack, SkipForward } from "lucide-react"; import { useState, useRef, useEffect, useCallback } from "react"; import { renderTimelineFrame } from "@/lib/timeline-renderer"; import { cn } from "@/lib/utils"; import { formatTimeCode } from "@/lib/time-utils"; import { EditableTimecode } from "@/components/ui/editable-timecode"; import { useFrameCache } from "@/hooks/use-frame-cache"; import { useSceneStore } from "@/stores/scene-store"; import { DEFAULT_CANVAS_SIZE, DEFAULT_FPS } from "@/constants/editor-constants"; import { useProjectStore } from "@/stores/project-store"; import { TextElementDragState } from "@/types/editor"; import { LayoutGuideOverlay } from "./layout-guide-overlay"; import { Popover, PopoverTrigger, PopoverContent, } from "@/components/ui/popover"; import { Checkbox } from "@/components/ui/checkbox"; import { Label } from "@/components/ui/label"; import { SocialsIcon } from "@/components/icons"; interface ActiveElement { element: TimelineElement; track: TimelineTrack; mediaItem: MediaFile | null; } export function PreviewPanel() { const { tracks, getTotalDuration, updateTextElement } = useTimelineStore(); const { mediaFiles } = useMediaStore(); const { currentTime, isPlaying, volume, muted, toggle, setCurrentTime } = usePlaybackStore(); const { activeProject } = useProjectStore(); const { currentScene } = useSceneStore(); const previewRef = useRef(null); const canvasRef = useRef(null); const { getCachedFrame, cacheFrame, invalidateCache, preRenderNearbyFrames } = useFrameCache(); const lastFrameTimeRef = useRef(0); const renderSeqRef = useRef(0); const offscreenCanvasRef = useRef( null, ); const audioContextRef = useRef(null); const audioGainRef = useRef(null); const audioBuffersRef = useRef>(new Map()); const playingSourcesRef = useRef>(new Set()); const containerRef = useRef(null); const [previewDimensions, setPreviewDimensions] = useState({ width: 0, height: 0, }); const [isExpanded, setIsExpanded] = useState(false); const canvasSize = activeProject?.canvasSize || DEFAULT_CANVAS_SIZE; const [dragState, setDragState] = useState({ isDragging: false, elementId: null, trackId: null, startX: 0, startY: 0, initialElementX: 0, initialElementY: 0, currentX: 0, currentY: 0, elementWidth: 0, elementHeight: 0, }); useEffect(() => { const updatePreviewSize = () => { if (!containerRef.current) return; let availableWidth, availableHeight; if (isExpanded) { const controlsHeight = 80; const marginSpace = 24; availableWidth = window.innerWidth - marginSpace; availableHeight = window.innerHeight - controlsHeight - marginSpace; } else { const container = containerRef.current.getBoundingClientRect(); const computedStyle = getComputedStyle(containerRef.current); const paddingTop = parseFloat(computedStyle.paddingTop); const paddingBottom = parseFloat(computedStyle.paddingBottom); const paddingLeft = parseFloat(computedStyle.paddingLeft); const paddingRight = parseFloat(computedStyle.paddingRight); const gap = parseFloat(computedStyle.gap) || 16; const toolbar = containerRef.current.querySelector("[data-toolbar]"); const toolbarHeight = toolbar ? toolbar.getBoundingClientRect().height : 0; availableWidth = container.width - paddingLeft - paddingRight; availableHeight = container.height - paddingTop - paddingBottom - toolbarHeight - (toolbarHeight > 0 ? gap : 0); } const targetRatio = canvasSize.width / canvasSize.height; const containerRatio = availableWidth / availableHeight; let width, height; if (containerRatio > targetRatio) { height = availableHeight * (isExpanded ? 0.95 : 1); width = height * targetRatio; } else { width = availableWidth * (isExpanded ? 0.95 : 1); height = width / targetRatio; } setPreviewDimensions({ width, height }); }; updatePreviewSize(); const resizeObserver = new ResizeObserver(updatePreviewSize); if (containerRef.current) { resizeObserver.observe(containerRef.current); } if (isExpanded) { window.addEventListener("resize", updatePreviewSize); } return () => { resizeObserver.disconnect(); if (isExpanded) { window.removeEventListener("resize", updatePreviewSize); } }; }, [canvasSize.width, canvasSize.height, isExpanded]); useEffect(() => { const handleEscapeKey = (event: KeyboardEvent) => { if (event.key === "Escape" && isExpanded) { setIsExpanded(false); } }; if (isExpanded) { document.addEventListener("keydown", handleEscapeKey); document.body.style.overflow = "hidden"; } else { document.body.style.overflow = ""; } return () => { document.removeEventListener("keydown", handleEscapeKey); document.body.style.overflow = ""; }; }, [isExpanded]); useEffect(() => { const handleMouseMove = (e: MouseEvent) => { if (!dragState.isDragging) return; const deltaX = e.clientX - dragState.startX; const deltaY = e.clientY - dragState.startY; const scaleRatio = previewDimensions.width / canvasSize.width; const newX = dragState.initialElementX + deltaX / scaleRatio; const newY = dragState.initialElementY + deltaY / scaleRatio; const halfWidth = dragState.elementWidth / scaleRatio / 2; const halfHeight = dragState.elementHeight / scaleRatio / 2; const constrainedX = Math.max( -canvasSize.width / 2 + halfWidth, Math.min(canvasSize.width / 2 - halfWidth, newX), ); const constrainedY = Math.max( -canvasSize.height / 2 + halfHeight, Math.min(canvasSize.height / 2 - halfHeight, newY), ); setDragState((prev) => ({ ...prev, currentX: constrainedX, currentY: constrainedY, })); }; const handleMouseUp = () => { if (dragState.isDragging && dragState.trackId && dragState.elementId) { updateTextElement(dragState.trackId, dragState.elementId, { x: dragState.currentX, y: dragState.currentY, }); } setDragState((prev) => ({ ...prev, isDragging: false })); }; if (dragState.isDragging) { document.addEventListener("mousemove", handleMouseMove); document.addEventListener("mouseup", handleMouseUp); document.body.style.cursor = "grabbing"; document.body.style.userSelect = "none"; } return () => { document.removeEventListener("mousemove", handleMouseMove); document.removeEventListener("mouseup", handleMouseUp); document.body.style.cursor = ""; document.body.style.userSelect = ""; }; }, [dragState, previewDimensions, canvasSize, updateTextElement]); // Clear the frame cache when background settings change since they affect rendering useEffect(() => { invalidateCache(); }, [ mediaFiles, activeProject?.backgroundColor, activeProject?.backgroundType, invalidateCache, ]); const handleTextMouseDown = ( e: React.MouseEvent, element: any, trackId: string, ) => { e.preventDefault(); e.stopPropagation(); const rect = e.currentTarget.getBoundingClientRect(); setDragState({ isDragging: true, elementId: element.id, trackId, startX: e.clientX, startY: e.clientY, initialElementX: element.x, initialElementY: element.y, currentX: element.x, currentY: element.y, elementWidth: rect.width, elementHeight: rect.height, }); }; const toggleExpanded = useCallback(() => { setIsExpanded((prev) => !prev); }, []); const hasAnyElements = tracks.some((track) => track.elements.length > 0); const shouldRenderPreview = hasAnyElements || activeProject?.backgroundType; const getActiveElements = (): ActiveElement[] => { const activeElements: ActiveElement[] = []; // Iterate tracks from bottom to top so topmost track renders last (on top) [...tracks].reverse().forEach((track) => { track.elements.forEach((element) => { if (element.hidden) return; const elementStart = element.startTime; const elementEnd = element.startTime + (element.duration - element.trimStart - element.trimEnd); if (currentTime >= elementStart && currentTime < elementEnd) { let mediaItem = null; if (element.type === "media") { mediaItem = element.mediaId === "test" ? null : mediaFiles.find((item) => item.id === element.mediaId) || null; } activeElements.push({ element, track, mediaItem }); } }); }); return activeElements; }; const activeElements = getActiveElements(); // Ensure first frame after mount/seek renders immediately useEffect(() => { const onSeek = () => { lastFrameTimeRef.current = -Infinity; renderSeqRef.current++; }; window.addEventListener("playback-seek", onSeek as EventListener); lastFrameTimeRef.current = -Infinity; return () => { window.removeEventListener("playback-seek", onSeek as EventListener); }; }, []); // Web Audio: schedule only on play/pause/seek/volume/mute changes useEffect(() => { const stopAll = () => { for (const src of playingSourcesRef.current) { try { src.stop(); } catch {} } playingSourcesRef.current.clear(); }; type WebAudioWindow = Window & { AudioContext?: typeof AudioContext; webkitAudioContext?: typeof AudioContext; }; const ensureAudioGraph = async () => { if (!audioContextRef.current) { const win = window as WebAudioWindow; const Ctx = win.AudioContext ?? win.webkitAudioContext; if (!Ctx) return; audioContextRef.current = new Ctx(); } if (!audioGainRef.current) { audioGainRef.current = audioContextRef.current!.createGain(); audioGainRef.current.connect(audioContextRef.current!.destination); } if (audioContextRef.current!.state === "suspended") { try { await audioContextRef.current!.resume(); } catch {} } const gainValue = muted ? 0 : Math.max(0, Math.min(1, volume)); audioGainRef.current!.gain.setValueAtTime( gainValue, audioContextRef.current!.currentTime, ); }; const scheduleNow = async () => { await ensureAudioGraph(); const audioCtx = audioContextRef.current!; const gain = audioGainRef.current!; const tracksSnapshot = useTimelineStore.getState().tracks; const mediaList = mediaFiles; const idToMedia = new Map(mediaList.map((m) => [m.id, m] as const)); const playbackNow = usePlaybackStore.getState().currentTime; const audible: Array<{ id: string; elementStart: number; trimStart: number; trimEnd: number; duration: number; muted: boolean; trackMuted: boolean; }> = []; const uniqueIds = new Set(); for (const track of tracksSnapshot) { for (const element of track.elements) { if (element.type !== "media") continue; const media = idToMedia.get(element.mediaId); if (!media || media.type !== "audio") continue; const visibleDuration = element.duration - element.trimStart - element.trimEnd; if (visibleDuration <= 0) continue; const localTime = playbackNow - element.startTime + element.trimStart; if (localTime < 0 || localTime >= visibleDuration) continue; audible.push({ id: media.id, elementStart: element.startTime, trimStart: element.trimStart, trimEnd: element.trimEnd, duration: element.duration, muted: !!element.muted, trackMuted: !!track.muted, }); uniqueIds.add(media.id); } } if (audible.length === 0) return; // Decode buffers as needed const decodePromises: Array> = []; for (const id of uniqueIds) { if (!audioBuffersRef.current.has(id)) { const mediaItem = idToMedia.get(id); if (!mediaItem) continue; const p = (async () => { const arr = await mediaItem.file.arrayBuffer(); const buf = await audioCtx.decodeAudioData(arr.slice(0)); audioBuffersRef.current.set(id, buf); })(); decodePromises.push(p); } } await Promise.all(decodePromises); const startAt = audioCtx.currentTime + 0.02; for (const entry of audible) { if (entry.muted || entry.trackMuted) continue; const buffer = audioBuffersRef.current.get(entry.id); if (!buffer) continue; const visibleDuration = entry.duration - entry.trimStart - entry.trimEnd; const localTime = Math.max( 0, playbackNow - entry.elementStart + entry.trimStart, ); const playDuration = Math.max(0, visibleDuration - localTime); if (playDuration <= 0) continue; const src = audioCtx.createBufferSource(); src.buffer = buffer; src.connect(gain); try { src.start(startAt, localTime, playDuration); playingSourcesRef.current.add(src); } catch {} } }; const onSeek = () => { if (!isPlaying) return; for (const src of playingSourcesRef.current) { try { src.stop(); } catch {} } playingSourcesRef.current.clear(); void scheduleNow(); }; // Apply volume/mute changes immediately void ensureAudioGraph(); // Start/stop on play state changes for (const src of playingSourcesRef.current) { try { src.stop(); } catch {} } playingSourcesRef.current.clear(); if (isPlaying) { void scheduleNow(); } window.addEventListener("playback-seek", onSeek as EventListener); return () => { window.removeEventListener("playback-seek", onSeek as EventListener); for (const src of playingSourcesRef.current) { try { src.stop(); } catch {} } playingSourcesRef.current.clear(); }; }, [isPlaying, volume, muted, mediaFiles]); // Canvas: draw current frame with caching useEffect(() => { const draw = async () => { const canvas = canvasRef.current; if (!canvas) return; const mainCtx = canvas.getContext("2d"); if (!mainCtx) return; // Set canvas internal resolution to avoid blurry scaling const displayWidth = Math.max(1, Math.floor(previewDimensions.width)); const displayHeight = Math.max(1, Math.floor(previewDimensions.height)); if (canvas.width !== displayWidth || canvas.height !== displayHeight) { canvas.width = displayWidth; canvas.height = displayHeight; } // Throttle rendering to project FPS during playback only const fps = activeProject?.fps || DEFAULT_FPS; const minDelta = 1 / fps; if (isPlaying) { if (currentTime - lastFrameTimeRef.current < minDelta) { return; } lastFrameTimeRef.current = currentTime; } const cachedFrame = getCachedFrame( currentTime, tracks, mediaFiles, activeProject, currentScene?.id, ); if (cachedFrame) { mainCtx.putImageData(cachedFrame, 0, 0); // Pre-render nearby frames in background if (!isPlaying) { // Only during scrubbing to avoid interfering with playback preRenderNearbyFrames( currentTime, tracks, mediaFiles, activeProject, async (time: number) => { const tempCanvas = document.createElement("canvas"); tempCanvas.width = displayWidth; tempCanvas.height = displayHeight; const tempCtx = tempCanvas.getContext("2d"); if (!tempCtx) throw new Error("Failed to create temp canvas context"); await renderTimelineFrame({ ctx: tempCtx, time, canvasWidth: displayWidth, canvasHeight: displayHeight, tracks, mediaFiles, backgroundType: activeProject?.backgroundType, blurIntensity: activeProject?.blurIntensity, backgroundColor: activeProject?.backgroundType === "blur" ? undefined : activeProject?.backgroundColor || "#000000", projectCanvasSize: canvasSize, }); return tempCtx.getImageData(0, 0, displayWidth, displayHeight); }, currentScene?.id, 3, ); } else { // Small lookahead while playing preRenderNearbyFrames( currentTime, tracks, mediaFiles, activeProject, async (time: number) => { const tempCanvas = document.createElement("canvas"); tempCanvas.width = displayWidth; tempCanvas.height = displayHeight; const tempCtx = tempCanvas.getContext("2d"); if (!tempCtx) throw new Error("Failed to create temp canvas context"); await renderTimelineFrame({ ctx: tempCtx, time, canvasWidth: displayWidth, canvasHeight: displayHeight, tracks, mediaFiles, backgroundType: activeProject?.backgroundType, blurIntensity: activeProject?.blurIntensity, backgroundColor: activeProject?.backgroundType === "blur" ? undefined : activeProject?.backgroundColor || "#000000", projectCanvasSize: canvasSize, }); return tempCtx.getImageData(0, 0, displayWidth, displayHeight); }, currentScene?.id, 1, ); } return; } // Cache miss - render from scratch if (!offscreenCanvasRef.current) { const hasOffscreen = typeof (globalThis as unknown as { OffscreenCanvas?: unknown }) .OffscreenCanvas !== "undefined"; if (hasOffscreen) { // eslint-disable-next-line @typescript-eslint/no-explicit-any offscreenCanvasRef.current = new (globalThis as any).OffscreenCanvas( displayWidth, displayHeight, ) as OffscreenCanvas; } else { const c = document.createElement("canvas"); c.width = displayWidth; c.height = displayHeight; offscreenCanvasRef.current = c; } } // Ensure size matches if ( offscreenCanvasRef.current && (offscreenCanvasRef.current as HTMLCanvasElement).getContext ) { const c = offscreenCanvasRef.current as HTMLCanvasElement; if (c.width !== displayWidth || c.height !== displayHeight) { c.width = displayWidth; c.height = displayHeight; } } else { const c = offscreenCanvasRef.current as OffscreenCanvas; // @ts-ignore width/height exist on OffscreenCanvas in modern browsers if ( (c as unknown as { width: number }).width !== displayWidth || (c as unknown as { height: number }).height !== displayHeight ) { // @ts-ignore (c as unknown as { width: number }).width = displayWidth; // @ts-ignore (c as unknown as { height: number }).height = displayHeight; } } const offscreenCanvas = offscreenCanvasRef.current as | HTMLCanvasElement | OffscreenCanvas; const offCtx = (offscreenCanvas as HTMLCanvasElement).getContext ? (offscreenCanvas as HTMLCanvasElement).getContext("2d") : (offscreenCanvas as OffscreenCanvas).getContext("2d"); if (!offCtx) return; await renderTimelineFrame({ ctx: offCtx as CanvasRenderingContext2D, time: currentTime, canvasWidth: displayWidth, canvasHeight: displayHeight, tracks, mediaFiles, backgroundType: activeProject?.backgroundType, blurIntensity: activeProject?.blurIntensity, backgroundColor: activeProject?.backgroundType === "blur" ? undefined : activeProject?.backgroundColor || "#000000", projectCanvasSize: canvasSize, }); const imageData = (offCtx as CanvasRenderingContext2D).getImageData( 0, 0, displayWidth, displayHeight, ); cacheFrame( currentTime, imageData, tracks, mediaFiles, activeProject, currentScene?.id, ); // Blit offscreen to visible canvas mainCtx.clearRect(0, 0, displayWidth, displayHeight); if ((offscreenCanvas as HTMLCanvasElement).getContext) { mainCtx.drawImage(offscreenCanvas as HTMLCanvasElement, 0, 0); } else { mainCtx.drawImage( offscreenCanvas as unknown as CanvasImageSource, 0, 0, ); } }; void draw(); }, [ activeElements, currentTime, previewDimensions.width, previewDimensions.height, canvasSize.width, canvasSize.height, activeProject?.backgroundType, activeProject?.backgroundColor, getCachedFrame, cacheFrame, preRenderNearbyFrames, isPlaying, ]); // Get media elements for blur background (video/image only) const getBlurBackgroundElements = (): ActiveElement[] => { return activeElements.filter( ({ element, mediaItem }) => element.type === "media" && mediaItem && (mediaItem.type === "video" || mediaItem.type === "image") && element.mediaId !== "test", // Exclude test elements ); }; const blurBackgroundElements = getBlurBackgroundElements(); // Render blur background layer (handled by canvas now) const renderBlurBackground = () => null; // Render an element (canvas handles visuals now). Audio playback to be implemented via Web Audio. const renderElement = (_elementData: ActiveElement) => null; return ( <>
{shouldRenderPreview ? (
{renderBlurBackground()} {activeElements.length === 0 ? ( <> ) : ( activeElements.map((elementData) => renderElement(elementData)) )}
) : null}
{isExpanded && ( )} ); } function FullscreenToolbar({ hasAnyElements, onToggleExpanded, currentTime, setCurrentTime, toggle, getTotalDuration, }: { hasAnyElements: boolean; onToggleExpanded: () => void; currentTime: number; setCurrentTime: (time: number) => void; toggle: () => void; getTotalDuration: () => number; }) { const { isPlaying, seek } = usePlaybackStore(); const { activeProject } = useProjectStore(); const [isDragging, setIsDragging] = useState(false); const totalDuration = getTotalDuration(); const progress = totalDuration > 0 ? (currentTime / totalDuration) * 100 : 0; const handleTimelineClick = (e: React.MouseEvent) => { if (!hasAnyElements) return; const rect = e.currentTarget.getBoundingClientRect(); const clickX = e.clientX - rect.left; const percentage = Math.max(0, Math.min(1, clickX / rect.width)); const newTime = percentage * totalDuration; setCurrentTime(Math.max(0, Math.min(newTime, totalDuration))); }; const handleTimelineDrag = (e: React.MouseEvent) => { if (!hasAnyElements) return; e.preventDefault(); e.stopPropagation(); const rect = e.currentTarget.getBoundingClientRect(); setIsDragging(true); const handleMouseMove = (moveEvent: MouseEvent) => { moveEvent.preventDefault(); const dragX = moveEvent.clientX - rect.left; const percentage = Math.max(0, Math.min(1, dragX / rect.width)); const newTime = percentage * totalDuration; setCurrentTime(Math.max(0, Math.min(newTime, totalDuration))); }; const handleMouseUp = () => { setIsDragging(false); document.removeEventListener("mousemove", handleMouseMove); document.removeEventListener("mouseup", handleMouseUp); document.body.style.userSelect = ""; }; document.body.style.userSelect = "none"; document.addEventListener("mousemove", handleMouseMove); document.addEventListener("mouseup", handleMouseUp); handleMouseMove(e.nativeEvent); }; const skipBackward = () => { const newTime = Math.max(0, currentTime - 1); setCurrentTime(newTime); }; const skipForward = () => { const newTime = Math.min(totalDuration, currentTime + 1); setCurrentTime(newTime); }; return (
/ {formatTimeCode( totalDuration, "HH:MM:SS:FF", activeProject?.fps || DEFAULT_FPS, )}
); } function FullscreenPreview({ previewDimensions, activeProject, renderBlurBackground, activeElements, renderElement, blurBackgroundElements, hasAnyElements, toggleExpanded, currentTime, setCurrentTime, toggle, getTotalDuration, }: { previewDimensions: { width: number; height: number }; activeProject: any; renderBlurBackground: () => React.ReactNode; activeElements: ActiveElement[]; renderElement: (elementData: ActiveElement, index: number) => React.ReactNode; blurBackgroundElements: ActiveElement[]; hasAnyElements: boolean; toggleExpanded: () => void; currentTime: number; setCurrentTime: (time: number) => void; toggle: () => void; getTotalDuration: () => number; }) { return (
{renderBlurBackground()} {activeElements.length === 0 ? (
No elements at current time
) : ( activeElements.map((elementData, index) => renderElement(elementData, index), ) )}
); } function PreviewToolbar({ hasAnyElements, onToggleExpanded, isExpanded, currentTime, setCurrentTime, toggle, getTotalDuration, }: { hasAnyElements: boolean; onToggleExpanded: () => void; isExpanded: boolean; currentTime: number; setCurrentTime: (time: number) => void; toggle: () => void; getTotalDuration: () => number; }) { const { isPlaying } = usePlaybackStore(); const { layoutGuide, toggleLayoutGuide } = useEditorStore(); if (isExpanded) { return ( ); } return (

Layout guide

Show platform-specific layout guides to help align your content with interface elements like profile pictures, usernames, and interaction buttons.

toggleLayoutGuide(layoutGuide.platform || "tiktok") } />
{Object.entries(PLATFORM_LAYOUTS).map(([platform, label]) => (
toggleLayoutGuide(platform as PlatformLayout) } />
))}
); }