feat: canvas rendering

This commit is contained in:
Maze Winther 2025-08-01 13:01:18 +02:00
parent 09cb8c8d98
commit 99ee6a91ef
2 changed files with 590 additions and 225 deletions

View File

@ -0,0 +1,561 @@
"use client";
import { useRef, useEffect, useCallback, useMemo } from "react";
import { useTimelineStore } from "@/stores/timeline-store";
import { useMediaStore, type MediaItem } from "@/stores/media-store";
import { usePlaybackStore } from "@/stores/playback-store";
import { useEditorStore } from "@/stores/editor-store";
import { useProjectStore } from "@/stores/project-store";
import { TimelineElement, TimelineTrack } from "@/types/timeline";
interface ActiveElement {
element: TimelineElement;
track: TimelineTrack;
mediaItem: MediaItem | null;
}
interface CanvasRendererProps {
width: number;
height: number;
className?: string;
}
// Single video source manager like CapCut with predictive loading
class VideoSourceManager {
private videos = new Map<string, HTMLVideoElement>();
private loadingPromises = new Map<string, Promise<void>>();
private frameCache = new Map<string, Map<number, ImageBitmap>>();
private currentSeeks = new Map<string, Promise<void>>();
private preloadQueue = new Set<string>();
private lastRequestedTime = new Map<string, number>();
getVideo(src: string): HTMLVideoElement {
if (!this.videos.has(src)) {
const video = document.createElement("video");
video.src = src;
video.muted = true;
video.preload = "metadata";
// Disable browser's own seeking optimizations
video.setAttribute("playsinline", "true");
this.videos.set(src, video);
const loadPromise = new Promise<void>((resolve, reject) => {
video.addEventListener("loadeddata", () => resolve(), { once: true });
video.addEventListener("error", reject, { once: true });
});
this.loadingPromises.set(src, loadPromise);
this.frameCache.set(src, new Map());
}
return this.videos.get(src)!;
}
async ensureLoaded(src: string): Promise<void> {
this.getVideo(src);
const promise = this.loadingPromises.get(src);
if (promise) {
await promise;
}
}
// CapCut-style: seek once, extract frame, cache it + predictive loading
async getFrameAt(src: string, time: number): Promise<ImageBitmap | null> {
const video = this.getVideo(src);
await this.ensureLoaded(src);
// Round to frame boundary for caching (use project FPS)
const frameTime = Math.round(time * 30) / 30; // TODO: Use actual project FPS
const cache = this.frameCache.get(src)!;
if (cache.has(frameTime)) {
// Start predictive loading for nearby frames
this.startPredictiveLoading(src, time);
return cache.get(frameTime)!;
}
// Track scrubbing direction for smarter preloading
const lastTime = this.lastRequestedTime.get(src) || time;
this.lastRequestedTime.set(src, time);
const scrubbingDirection = time > lastTime ? 1 : -1;
// Extract this frame synchronously for frame accuracy
const bitmap = await this.extractFrame(src, time);
// Start background preloading only after we have the current frame
if (bitmap) {
this.startPredictiveLoading(src, time, scrubbingDirection);
}
return bitmap;
}
private async extractFrame(
src: string,
time: number
): Promise<ImageBitmap | null> {
const video = this.getVideo(src);
const frameTime = Math.round(time * 30) / 30;
const cache = this.frameCache.get(src)!;
// Ensure only one seek per video at a time
const seekKey = src;
if (this.currentSeeks.has(seekKey)) {
await this.currentSeeks.get(seekKey);
// Check cache again after waiting
if (cache.has(frameTime)) {
return cache.get(frameTime)!;
}
}
// Seek to exact time
const seekPromise = this.seekToTime(video, time);
this.currentSeeks.set(seekKey, seekPromise);
try {
await seekPromise;
// Extract frame as ImageBitmap (GPU-optimized)
if (video.readyState >= 2) {
const bitmap = await createImageBitmap(video);
cache.set(frameTime, bitmap);
return bitmap;
}
} catch (error) {
console.warn("Frame extraction failed:", error);
} finally {
this.currentSeeks.delete(seekKey);
}
return null;
}
// After Effects style predictive loading
private startPredictiveLoading(
src: string,
currentTime: number,
direction: number = 1
) {
const key = `${src}-${currentTime}`;
if (this.preloadQueue.has(key)) return;
this.preloadQueue.add(key);
// Background preload next 5 frames in scrubbing direction (reduced for performance)
setTimeout(async () => {
const frameInterval = 1 / 30; // TODO: Use project FPS
const framesToPreload = 5; // Reduced to prevent overwhelming
for (let i = 1; i <= framesToPreload; i++) {
const preloadTime = currentTime + direction * i * frameInterval;
const frameTime = Math.round(preloadTime * 30) / 30;
const cache = this.frameCache.get(src)!;
if (!cache.has(frameTime)) {
// Don't await - let it load in background
this.extractFrame(src, preloadTime).catch(() => {
// Ignore preload failures
});
// Small delay between preloads to not overwhelm
await new Promise((resolve) => setTimeout(resolve, 20));
}
}
this.preloadQueue.delete(key);
}, 100); // Longer delay to prioritize current frame
}
private async seekToTime(
video: HTMLVideoElement,
time: number
): Promise<void> {
if (Math.abs(video.currentTime - time) < 0.01) return; // Already there
return new Promise<void>((resolve) => {
const onSeeked = () => {
video.removeEventListener("seeked", onSeeked);
resolve();
};
video.addEventListener("seeked", onSeeked);
video.currentTime = time;
// Fallback timeout
setTimeout(() => {
video.removeEventListener("seeked", onSeeked);
resolve();
}, 100);
});
}
cleanup() {
// Cleanup ImageBitmaps
for (const cache of this.frameCache.values()) {
for (const bitmap of cache.values()) {
bitmap.close();
}
cache.clear();
}
this.videos.clear();
this.loadingPromises.clear();
this.frameCache.clear();
this.currentSeeks.clear();
this.preloadQueue.clear();
this.lastRequestedTime.clear();
}
}
const videoManager = new VideoSourceManager();
export function CanvasRenderer({
width,
height,
className,
}: CanvasRendererProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const { tracks } = useTimelineStore();
const { mediaItems } = useMediaStore();
const { currentTime } = usePlaybackStore();
const { canvasSize } = useEditorStore();
const { activeProject } = useProjectStore();
// Convert timeline time to frame number
const timeToFrame = useCallback(
(time: number): number => {
const fps = activeProject?.fps || 30;
return Math.round(time * fps);
},
[activeProject?.fps]
);
// Convert frame number to time
const frameToTime = useCallback(
(frame: number): number => {
const fps = activeProject?.fps || 30;
return frame / fps;
},
[activeProject?.fps]
);
// Get active elements at current time (frame-based)
const getActiveElements = useCallback((): ActiveElement[] => {
const activeElements: ActiveElement[] = [];
const currentFrame = timeToFrame(currentTime);
tracks.forEach((track) => {
track.elements.forEach((element) => {
if (element.hidden) return;
const elementStartFrame = timeToFrame(element.startTime);
const elementEndFrame = timeToFrame(
element.startTime +
(element.duration - element.trimStart - element.trimEnd)
);
// Frame-based range check (no floating point issues)
if (
currentFrame >= elementStartFrame &&
currentFrame < elementEndFrame
) {
let mediaItem = null;
if (element.type === "media") {
mediaItem =
element.mediaId === "test"
? null
: mediaItems.find((item) => item.id === element.mediaId) ||
null;
}
activeElements.push({ element, track, mediaItem });
}
});
});
return activeElements;
}, [tracks, mediaItems, currentTime, timeToFrame]);
// Calculate video time for an element
const calculateVideoTime = useCallback(
(element: TimelineElement): number => {
const relativeTime = currentTime - element.startTime;
const videoTime = element.trimStart + relativeTime;
return Math.max(0, Math.min(element.duration, videoTime));
},
[currentTime]
);
// Render function with double buffering
const render = useCallback(async () => {
const startTime = performance.now();
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
// Create offscreen canvas for double buffering
const offscreen = document.createElement("canvas");
offscreen.width = width;
offscreen.height = height;
const offscreenCtx = offscreen.getContext("2d");
if (!offscreenCtx) return;
// Clear offscreen canvas
offscreenCtx.clearRect(0, 0, width, height);
// Set background
const backgroundColor =
activeProject?.backgroundType === "blur"
? "transparent"
: activeProject?.backgroundColor || "#000000";
if (backgroundColor !== "transparent") {
offscreenCtx.fillStyle = backgroundColor;
offscreenCtx.fillRect(0, 0, width, height);
}
const activeElements = getActiveElements();
// Render media elements first (background layer)
for (const elementData of activeElements) {
const { element, mediaItem } = elementData;
if (element.type === "media" && mediaItem) {
if (mediaItem.type === "video") {
await renderVideoElement(offscreenCtx, element, mediaItem);
} else if (mediaItem.type === "image") {
await renderImageElement(offscreenCtx, element, mediaItem);
}
}
}
// Render text elements last (on top)
for (const elementData of activeElements) {
const { element } = elementData;
if (element.type === "text") {
renderTextElement(offscreenCtx, element);
}
}
// Copy complete frame to visible canvas in one operation
ctx.clearRect(0, 0, width, height);
ctx.drawImage(offscreen, 0, 0);
// Render complete
}, [width, height, activeProject, getActiveElements]);
// CapCut-style video rendering: single source + frame extraction
const renderVideoElement = async (
ctx: CanvasRenderingContext2D,
element: TimelineElement,
mediaItem: MediaItem
) => {
try {
const videoTime = calculateVideoTime(element);
// Get specific frame from single video source
const frameBitmap = await videoManager.getFrameAt(
mediaItem.url!,
videoTime
);
if (frameBitmap) {
// Calculate aspect ratio and positioning
const videoAspect = frameBitmap.width / frameBitmap.height;
const canvasAspect = width / height;
let drawWidth, drawHeight, drawX, drawY;
if (videoAspect > canvasAspect) {
// Video is wider - fit to width
drawWidth = width;
drawHeight = width / videoAspect;
drawX = 0;
drawY = (height - drawHeight) / 2;
} else {
// Video is taller - fit to height
drawHeight = height;
drawWidth = height * videoAspect;
drawX = (width - drawWidth) / 2;
drawY = 0;
}
// Draw the cached frame bitmap (GPU-optimized)
ctx.drawImage(frameBitmap, drawX, drawY, drawWidth, drawHeight);
} else {
// Frame accuracy is critical - show loading indicator for correct time
ctx.fillStyle = "#1a1a1a";
ctx.fillRect(0, 0, width, height);
// Subtle loading indicator
ctx.fillStyle = "#666";
ctx.font = "14px sans-serif";
ctx.textAlign = "center";
ctx.fillText("⏳", width / 2, height / 2);
}
} catch (error) {
console.error("Failed to render video element:", error);
// Draw fallback
ctx.fillStyle = "#333";
ctx.fillRect(0, 0, width, height);
ctx.fillStyle = "#fff";
ctx.font = "16px sans-serif";
ctx.textAlign = "center";
ctx.fillText("Video Loading...", width / 2, height / 2);
}
};
// Render image element
const renderImageElement = async (
ctx: CanvasRenderingContext2D,
element: TimelineElement,
mediaItem: MediaItem
) => {
try {
const img = new Image();
img.src = mediaItem.url!;
await new Promise<void>((resolve, reject) => {
img.onload = () => resolve();
img.onerror = reject;
});
// Calculate aspect ratio and positioning
const imgAspect = img.width / img.height;
const canvasAspect = width / height;
let drawWidth, drawHeight, drawX, drawY;
if (imgAspect > canvasAspect) {
// Image is wider - fit to width
drawWidth = width;
drawHeight = width / imgAspect;
drawX = 0;
drawY = (height - drawHeight) / 2;
} else {
// Image is taller - fit to height
drawHeight = height;
drawWidth = height * imgAspect;
drawX = (width - drawWidth) / 2;
drawY = 0;
}
ctx.drawImage(img, drawX, drawY, drawWidth, drawHeight);
} catch (error) {
console.error("Failed to render image element:", error);
}
};
// Render text element
const renderTextElement = (
ctx: CanvasRenderingContext2D,
element: TimelineElement
) => {
try {
if (element.type !== "text") return;
// Convert element coordinates to canvas coordinates
const scaleX = width / canvasSize.width;
const scaleY = height / canvasSize.height;
const x = (canvasSize.width / 2 + element.x) * scaleX;
const y = (canvasSize.height / 2 + element.y) * scaleY;
ctx.save();
// Set text properties with fallback font
const fontSize = Math.max(
12,
element.fontSize * Math.min(scaleX, scaleY)
);
const fontFamily = element.fontFamily || "Arial, sans-serif";
ctx.font = `${element.fontWeight || "normal"} ${element.fontStyle || "normal"} ${fontSize}px ${fontFamily}`;
ctx.fillStyle = element.color || "#ffffff";
ctx.textAlign = (element.textAlign as CanvasTextAlign) || "center";
ctx.textBaseline = "middle";
ctx.globalAlpha = element.opacity ?? 1;
// Apply rotation if any
if (element.rotation && element.rotation !== 0) {
ctx.translate(x, y);
ctx.rotate((element.rotation * Math.PI) / 180);
ctx.translate(-x, -y);
}
// Draw background if specified
if (
element.backgroundColor &&
element.backgroundColor !== "transparent"
) {
const metrics = ctx.measureText(element.content || "");
const padding = 8 * Math.min(scaleX, scaleY);
ctx.fillStyle = element.backgroundColor;
ctx.fillRect(
x - metrics.width / 2 - padding,
y - fontSize / 2 - padding / 2,
metrics.width + padding * 2,
fontSize + padding
);
ctx.fillStyle = element.color || "#ffffff";
}
// Draw text
ctx.fillText(element.content || "", x, y);
ctx.restore();
} catch (error) {
console.error("Failed to render text element:", error);
}
};
// Better debouncing with async operation prevention
const debouncedRender = useMemo(() => {
let timeoutId: NodeJS.Timeout;
let isRendering = false;
let callCount = 0;
return async () => {
callCount++;
// Skip if already rendering
if (isRendering) {
return;
}
clearTimeout(timeoutId);
timeoutId = setTimeout(async () => {
if (isRendering) return;
isRendering = true;
try {
await render();
} finally {
isRendering = false;
}
}, 16); // ~60fps max
};
}, [render, currentTime]);
// Re-render when currentTime changes
useEffect(() => {
debouncedRender();
}, [debouncedRender]);
// Cleanup on unmount
useEffect(() => {
return () => {
videoManager.cleanup();
};
}, []);
return (
<canvas
ref={canvasRef}
width={width}
height={height}
className={className}
style={{ width: "100%", height: "100%" }}
/>
);
}

View File

@ -6,8 +6,8 @@ import { useMediaStore, type MediaItem } from "@/stores/media-store";
import { usePlaybackStore } from "@/stores/playback-store";
import { useEditorStore } from "@/stores/editor-store";
import { useAspectRatio } from "@/hooks/use-aspect-ratio";
import { VideoPlayer } from "@/components/ui/video-player";
import { AudioPlayer } from "@/components/ui/audio-player";
import { CanvasRenderer } from "./canvas-renderer";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
@ -259,220 +259,16 @@ export function PreviewPanel() {
const activeElements = getActiveElements();
// 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
);
};
// Get audio elements that need HTML audio players
const audioElements = activeElements.filter(
(e) => e.element.type === "media" && e.mediaItem?.type === "audio"
);
const blurBackgroundElements = getBlurBackgroundElements();
// Note: Blur background and visual elements now handled by CanvasRenderer
// Render blur background layer
const renderBlurBackground = () => {
if (
!activeProject?.backgroundType ||
activeProject.backgroundType !== "blur" ||
blurBackgroundElements.length === 0
) {
return null;
}
// Note: Blur background now handled by CanvasRenderer
// Use the first media element for background (could be enhanced to use primary/focused element)
const backgroundElement = blurBackgroundElements[0];
const { element, mediaItem } = backgroundElement;
if (!mediaItem) return null;
const blurIntensity = activeProject.blurIntensity || 8;
if (mediaItem.type === "video") {
return (
<div
key={`blur-${element.id}`}
className="absolute inset-0 overflow-hidden"
style={{
filter: `blur(${blurIntensity}px)`,
transform: "scale(1.1)", // Slightly zoom to avoid blur edge artifacts
transformOrigin: "center",
}}
>
<VideoPlayer
src={mediaItem.url!}
poster={mediaItem.thumbnailUrl}
clipStartTime={element.startTime}
trimStart={element.trimStart}
trimEnd={element.trimEnd}
clipDuration={element.duration}
className="w-full h-full object-cover"
/>
</div>
);
}
if (mediaItem.type === "image") {
return (
<div
key={`blur-${element.id}`}
className="absolute inset-0 overflow-hidden"
style={{
filter: `blur(${blurIntensity}px)`,
transform: "scale(1.1)", // Slightly zoom to avoid blur edge artifacts
transformOrigin: "center",
}}
>
<img
src={mediaItem.url!}
alt={mediaItem.name}
className="w-full h-full object-cover"
draggable={false}
/>
</div>
);
}
return null;
};
// Render an element
const renderElement = (elementData: ActiveElement, index: number) => {
const { element, mediaItem } = elementData;
// Text elements
if (element.type === "text") {
const fontClassName =
FONT_CLASS_MAP[element.fontFamily as keyof typeof FONT_CLASS_MAP] || "";
const scaleRatio = previewDimensions.width / canvasSize.width;
return (
<div
key={element.id}
className="absolute flex items-center justify-center cursor-grab"
onMouseDown={(e) =>
handleTextMouseDown(e, element, elementData.track.id)
}
style={{
left: `${
50 +
((dragState.isDragging && dragState.elementId === element.id
? dragState.currentX
: element.x) /
canvasSize.width) *
100
}%`,
top: `${
50 +
((dragState.isDragging && dragState.elementId === element.id
? dragState.currentY
: element.y) /
canvasSize.height) *
100
}%`,
transform: `translate(-50%, -50%) rotate(${element.rotation}deg) scale(${scaleRatio})`,
opacity: element.opacity,
zIndex: 100 + index, // Text elements on top
}}
>
<div
className={fontClassName}
style={{
fontSize: `${element.fontSize}px`,
color: element.color,
backgroundColor: element.backgroundColor,
textAlign: element.textAlign,
fontWeight: element.fontWeight,
fontStyle: element.fontStyle,
textDecoration: element.textDecoration,
padding: "4px 8px",
borderRadius: "2px",
whiteSpace: "nowrap",
// Fallback for system fonts that don't have classes
...(fontClassName === "" && { fontFamily: element.fontFamily }),
}}
>
{element.content}
</div>
</div>
);
}
// Media elements
if (element.type === "media") {
// Test elements
if (!mediaItem || element.mediaId === "test") {
return (
<div
key={element.id}
className="absolute inset-0 bg-linear-to-br from-blue-500/20 to-purple-500/20 flex items-center justify-center"
>
<div className="text-center">
<div className="text-2xl mb-2">🎬</div>
<p className="text-xs text-white">{element.name}</p>
</div>
</div>
);
}
// Video elements
if (mediaItem.type === "video") {
return (
<div
key={element.id}
className="absolute inset-0 flex items-center justify-center"
>
<VideoPlayer
src={mediaItem.url!}
poster={mediaItem.thumbnailUrl}
clipStartTime={element.startTime}
trimStart={element.trimStart}
trimEnd={element.trimEnd}
clipDuration={element.duration}
/>
</div>
);
}
// Image elements
if (mediaItem.type === "image") {
return (
<div
key={element.id}
className="absolute inset-0 flex items-center justify-center"
>
<img
src={mediaItem.url!}
alt={mediaItem.name}
className="max-w-full max-h-full object-contain"
draggable={false}
/>
</div>
);
}
// Audio elements (no visual representation)
if (mediaItem.type === "audio") {
return (
<div key={element.id} className="absolute inset-0">
<AudioPlayer
src={mediaItem.url!}
clipStartTime={element.startTime}
trimStart={element.trimStart}
trimEnd={element.trimEnd}
clipDuration={element.duration}
trackMuted={elementData.track.muted}
/>
</div>
);
}
}
return null;
};
// Note: Element rendering now handled by CanvasRenderer
return (
<>
@ -495,23 +291,31 @@ export function PreviewPanel() {
: activeProject?.backgroundColor || "#000000",
}}
>
{renderBlurBackground()}
{activeElements.length === 0 ? (
{/* Canvas renderer handles all visual elements */}
<CanvasRenderer
width={previewDimensions.width}
height={previewDimensions.height}
className="absolute inset-0"
/>
{/* Audio elements still use HTML audio for playback */}
{audioElements.map((elementData) => (
<AudioPlayer
key={elementData.element.id}
src={elementData.mediaItem!.url!}
clipStartTime={elementData.element.startTime}
trimStart={elementData.element.trimStart}
trimEnd={elementData.element.trimEnd}
clipDuration={elementData.element.duration}
trackMuted={elementData.track.muted}
/>
))}
{activeElements.length === 0 && (
<div className="absolute inset-0 flex items-center justify-center text-muted-foreground">
No elements at current time
</div>
) : (
activeElements.map((elementData, index) =>
renderElement(elementData, index)
)
)}
{activeProject?.backgroundType === "blur" &&
blurBackgroundElements.length === 0 &&
activeElements.length > 0 && (
<div className="absolute bottom-2 left-2 right-2 bg-black/70 text-white text-xs p-2 rounded">
Add a video or image to use blur background
</div>
)}
</div>
) : null}