1133 lines
36 KiB
TypeScript
1133 lines
36 KiB
TypeScript
"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<HTMLDivElement>(null);
|
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
const { getCachedFrame, cacheFrame, invalidateCache, preRenderNearbyFrames } =
|
|
useFrameCache();
|
|
const lastFrameTimeRef = useRef(0);
|
|
const renderSeqRef = useRef(0);
|
|
const offscreenCanvasRef = useRef<OffscreenCanvas | HTMLCanvasElement | null>(
|
|
null,
|
|
);
|
|
|
|
const audioContextRef = useRef<AudioContext | null>(null);
|
|
const audioGainRef = useRef<GainNode | null>(null);
|
|
const audioBuffersRef = useRef<Map<string, AudioBuffer>>(new Map());
|
|
const playingSourcesRef = useRef<Set<AudioBufferSourceNode>>(new Set());
|
|
const containerRef = useRef<HTMLDivElement>(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<TextElementDragState>({
|
|
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<HTMLDivElement>,
|
|
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<string>();
|
|
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<Promise<void>> = [];
|
|
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 (
|
|
<>
|
|
<div className="bg-panel relative flex h-full min-h-0 w-full min-w-0 flex-col rounded-sm">
|
|
<div
|
|
ref={containerRef}
|
|
className="flex min-h-0 min-w-0 flex-1 flex-col items-center justify-center"
|
|
>
|
|
<div className="flex-1" />
|
|
{shouldRenderPreview ? (
|
|
<div
|
|
ref={previewRef}
|
|
className="relative overflow-hidden border"
|
|
style={{
|
|
width: previewDimensions.width,
|
|
height: previewDimensions.height,
|
|
background:
|
|
activeProject?.backgroundType === "blur"
|
|
? "transparent"
|
|
: activeProject?.backgroundColor || "#000000",
|
|
}}
|
|
>
|
|
{renderBlurBackground()}
|
|
<canvas
|
|
ref={canvasRef}
|
|
style={{
|
|
position: "absolute",
|
|
left: 0,
|
|
top: 0,
|
|
width: previewDimensions.width,
|
|
height: previewDimensions.height,
|
|
}}
|
|
aria-label="Video preview canvas"
|
|
/>
|
|
{activeElements.length === 0 ? (
|
|
<></>
|
|
) : (
|
|
activeElements.map((elementData) => renderElement(elementData))
|
|
)}
|
|
<LayoutGuideOverlay />
|
|
</div>
|
|
) : null}
|
|
|
|
<div className="flex-1" />
|
|
|
|
<PreviewToolbar
|
|
hasAnyElements={hasAnyElements}
|
|
onToggleExpanded={toggleExpanded}
|
|
isExpanded={isExpanded}
|
|
currentTime={currentTime}
|
|
setCurrentTime={setCurrentTime}
|
|
toggle={toggle}
|
|
getTotalDuration={getTotalDuration}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{isExpanded && (
|
|
<FullscreenPreview
|
|
previewDimensions={previewDimensions}
|
|
activeProject={activeProject}
|
|
renderBlurBackground={renderBlurBackground}
|
|
activeElements={activeElements}
|
|
renderElement={renderElement}
|
|
blurBackgroundElements={blurBackgroundElements}
|
|
hasAnyElements={hasAnyElements}
|
|
toggleExpanded={toggleExpanded}
|
|
currentTime={currentTime}
|
|
setCurrentTime={setCurrentTime}
|
|
toggle={toggle}
|
|
getTotalDuration={getTotalDuration}
|
|
/>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
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<HTMLDivElement>) => {
|
|
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<HTMLDivElement>) => {
|
|
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 (
|
|
<div
|
|
data-toolbar
|
|
className="text-foreground relative flex w-full items-center gap-2 p-1 pt-2"
|
|
>
|
|
<div className="text-foreground/90 flex items-center gap-1 text-[0.70rem] tabular-nums">
|
|
<EditableTimecode
|
|
time={currentTime}
|
|
duration={totalDuration}
|
|
format="HH:MM:SS:FF"
|
|
fps={activeProject?.fps || DEFAULT_FPS}
|
|
onTimeChange={seek}
|
|
disabled={!hasAnyElements}
|
|
className="text-foreground/90 hover:bg-white/10"
|
|
/>
|
|
<span className="opacity-50">/</span>
|
|
<span>
|
|
{formatTimeCode(
|
|
totalDuration,
|
|
"HH:MM:SS:FF",
|
|
activeProject?.fps || DEFAULT_FPS,
|
|
)}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-1">
|
|
<Button
|
|
variant="text"
|
|
size="icon"
|
|
onClick={skipBackward}
|
|
disabled={!hasAnyElements}
|
|
className="text-foreground h-auto p-0"
|
|
title="Skip backward 1s"
|
|
>
|
|
<SkipBack className="h-3 w-3" />
|
|
</Button>
|
|
<Button
|
|
variant="text"
|
|
size="icon"
|
|
onClick={toggle}
|
|
disabled={!hasAnyElements}
|
|
className="text-foreground hover:text-foreground/80 h-auto p-0"
|
|
>
|
|
{isPlaying ? (
|
|
<Pause className="h-3 w-3" />
|
|
) : (
|
|
<Play className="h-3 w-3" />
|
|
)}
|
|
</Button>
|
|
<Button
|
|
variant="text"
|
|
size="icon"
|
|
onClick={skipForward}
|
|
disabled={!hasAnyElements}
|
|
className="text-foreground hover:text-foreground/80 h-auto p-0"
|
|
title="Skip forward 1s"
|
|
>
|
|
<SkipForward className="h-3 w-3" />
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="flex flex-1 items-center gap-2">
|
|
<div
|
|
className={cn(
|
|
"bg-foreground/20 relative h-1 flex-1 cursor-pointer rounded-full",
|
|
!hasAnyElements && "cursor-not-allowed opacity-50",
|
|
)}
|
|
onClick={hasAnyElements ? handleTimelineClick : undefined}
|
|
onMouseDown={hasAnyElements ? handleTimelineDrag : undefined}
|
|
style={{ userSelect: "none" }}
|
|
>
|
|
<div
|
|
className={cn(
|
|
"bg-foreground absolute left-0 top-0 h-full rounded-full",
|
|
!isDragging && "duration-100",
|
|
)}
|
|
style={{ width: `${progress}%` }}
|
|
/>
|
|
<div
|
|
className="shadow-xs bg-foreground absolute top-1/2 h-3 w-3 -translate-x-1/2 -translate-y-1/2 rounded-full border border-black/20"
|
|
style={{ left: `${progress}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<Button
|
|
variant="text"
|
|
size="icon"
|
|
className="size-4! text-foreground/80 hover:text-foreground"
|
|
onClick={onToggleExpanded}
|
|
title="Exit fullscreen (Esc)"
|
|
>
|
|
<Expand className="size-4!" />
|
|
</Button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<div className="z-9999 fixed inset-0 flex flex-col">
|
|
<div className="bg-background flex flex-1 items-center justify-center">
|
|
<div
|
|
className="border-border relative m-3 overflow-hidden border"
|
|
style={{
|
|
width: previewDimensions.width,
|
|
height: previewDimensions.height,
|
|
background:
|
|
activeProject?.backgroundType === "blur"
|
|
? "#1a1a1a"
|
|
: activeProject?.backgroundColor || "#1a1a1a",
|
|
}}
|
|
>
|
|
{renderBlurBackground()}
|
|
{activeElements.length === 0 ? (
|
|
<div className="absolute inset-0 flex items-center justify-center text-white/60">
|
|
No elements at current time
|
|
</div>
|
|
) : (
|
|
activeElements.map((elementData, index) =>
|
|
renderElement(elementData, index),
|
|
)
|
|
)}
|
|
<LayoutGuideOverlay />
|
|
</div>
|
|
</div>
|
|
<div className="bg-background p-4">
|
|
<FullscreenToolbar
|
|
hasAnyElements={hasAnyElements}
|
|
onToggleExpanded={toggleExpanded}
|
|
currentTime={currentTime}
|
|
setCurrentTime={setCurrentTime}
|
|
toggle={toggle}
|
|
getTotalDuration={getTotalDuration}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<FullscreenToolbar
|
|
{...{
|
|
hasAnyElements,
|
|
onToggleExpanded,
|
|
currentTime,
|
|
setCurrentTime,
|
|
toggle,
|
|
getTotalDuration,
|
|
}}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
data-toolbar
|
|
className="flex h-auto w-full justify-end gap-2 pb-5 pr-5 pt-4"
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
variant="text"
|
|
size="icon"
|
|
onClick={toggle}
|
|
disabled={!hasAnyElements}
|
|
className="h-auto p-0"
|
|
>
|
|
{isPlaying ? (
|
|
<Pause className="h-3 w-3" />
|
|
) : (
|
|
<Play className="h-3 w-3" />
|
|
)}
|
|
</Button>
|
|
<Popover>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
variant="text"
|
|
size="icon"
|
|
className="mr-1 h-auto p-0"
|
|
title="Toggle layout guide"
|
|
>
|
|
<SocialsIcon className="!size-6" />
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="w-80">
|
|
<div className="grid gap-4">
|
|
<div className="space-y-2">
|
|
<h4 className="font-medium leading-none">Layout guide</h4>
|
|
<p className="text-muted-foreground text-sm">
|
|
Show platform-specific layout guides to help align your
|
|
content with interface elements like profile pictures,
|
|
usernames, and interaction buttons.
|
|
</p>
|
|
</div>
|
|
<div className="grid gap-2">
|
|
<div className="flex items-center space-x-2">
|
|
<Checkbox
|
|
id="none"
|
|
checked={layoutGuide.platform === null}
|
|
onCheckedChange={() =>
|
|
toggleLayoutGuide(layoutGuide.platform || "tiktok")
|
|
}
|
|
/>
|
|
<Label htmlFor="none">None</Label>
|
|
</div>
|
|
{Object.entries(PLATFORM_LAYOUTS).map(([platform, label]) => (
|
|
<div key={platform} className="flex items-center space-x-2">
|
|
<Checkbox
|
|
id={platform}
|
|
checked={layoutGuide.platform === platform}
|
|
onCheckedChange={() =>
|
|
toggleLayoutGuide(platform as PlatformLayout)
|
|
}
|
|
/>
|
|
<Label htmlFor={platform}>{label}</Label>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
<Button
|
|
variant="text"
|
|
size="icon"
|
|
className="size-4!"
|
|
onClick={onToggleExpanded}
|
|
title="Enter fullscreen"
|
|
>
|
|
<Expand className="size-4!" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|