From 21dac9f09e17f4f1be4ddfad7474400787051a7c Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Sun, 31 Aug 2025 00:58:33 +0200 Subject: [PATCH 01/29] fix space key to play --- apps/web/src/stores/keybindings-store.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/web/src/stores/keybindings-store.ts b/apps/web/src/stores/keybindings-store.ts index 7fa0da5e..febcaa30 100644 --- a/apps/web/src/stores/keybindings-store.ts +++ b/apps/web/src/stores/keybindings-store.ts @@ -206,6 +206,9 @@ function getPressedKey(ev: KeyboardEvent): string | null { const key = (ev.key ?? "").toLowerCase(); const code = ev.code ?? ""; + if (code === "Space" || key === " " || key === "spacebar" || key === "space") + return "space"; + // Check arrow keys if (key.startsWith("arrow")) { return key.slice(5); @@ -213,7 +216,6 @@ function getPressedKey(ev: KeyboardEvent): string | null { // Check for special keys if (key === "tab") return "tab"; - if (key === " " || key === "space") return "space"; if (key === "home") return "home"; if (key === "end") return "end"; if (key === "delete") return "delete"; From e222f15d1dd50aa50be8c7424d86aa7fedf0defc Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Sun, 31 Aug 2025 13:26:53 +0200 Subject: [PATCH 02/29] feat: implement timeline caching with cache indicator --- .../src/components/editor/preview-panel.tsx | 110 +++++- .../src/components/editor/timeline/index.tsx | 102 +++--- .../timeline/timeline-cache-indicator.tsx | 116 +++++++ .../editor/timeline/timeline-marker.tsx | 67 ++++ apps/web/src/hooks/use-frame-cache.ts | 320 ++++++++++++++++++ apps/web/src/lib/export.ts | 23 +- 6 files changed, 672 insertions(+), 66 deletions(-) create mode 100644 apps/web/src/components/editor/timeline/timeline-cache-indicator.tsx create mode 100644 apps/web/src/components/editor/timeline/timeline-marker.tsx create mode 100644 apps/web/src/hooks/use-frame-cache.ts diff --git a/apps/web/src/components/editor/preview-panel.tsx b/apps/web/src/components/editor/preview-panel.tsx index 1654624c..c53cdafb 100644 --- a/apps/web/src/components/editor/preview-panel.tsx +++ b/apps/web/src/components/editor/preview-panel.tsx @@ -13,6 +13,7 @@ import { renderTimelineFrame } from "@/lib/timeline-renderer"; import { cn } from "@/lib/utils"; import { formatTimeCode } from "@/lib/time"; import { EditableTimecode } from "@/components/ui/editable-timecode"; +import { useFrameCache } from "@/hooks/use-frame-cache"; import { DEFAULT_CANVAS_SIZE, DEFAULT_FPS, @@ -44,6 +45,8 @@ export function PreviewPanel() { const { activeProject } = useProjectStore(); 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( @@ -216,6 +219,17 @@ export function PreviewPanel() { }; }, [dragState, previewDimensions, canvasSize, updateTextElement]); + // Invalidate cache when timeline changes + useEffect(() => { + invalidateCache(); + }, [ + tracks, + mediaFiles, + activeProject?.backgroundColor, + activeProject?.backgroundType, + invalidateCache, + ]); + const handleTextMouseDown = ( e: React.MouseEvent, element: any, @@ -449,7 +463,7 @@ export function PreviewPanel() { }; }, [isPlaying, volume, muted, mediaFiles]); - // Canvas: draw current frame for visible elements using offscreen compositing + // Canvas: draw current frame with caching useEffect(() => { const draw = async () => { const canvas = canvasRef.current; @@ -475,10 +489,86 @@ export function PreviewPanel() { lastFrameTimeRef.current = currentTime; } - // Invalidate older async renders when user scrubs rapidly - const mySeq = (renderSeqRef.current += 1); + const cachedFrame = getCachedFrame( + currentTime, + tracks, + mediaFiles, + activeProject + ); + if (cachedFrame) { + mainCtx.putImageData(cachedFrame, 0, 0); - // Offscreen buffer to avoid flicker (reuse canvas) + // 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, + backgroundColor: + activeProject?.backgroundType === "blur" + ? "transparent" + : activeProject?.backgroundColor || "#000000", + projectCanvasSize: canvasSize, + }); + + return tempCtx.getImageData(0, 0, displayWidth, displayHeight); + } + ); + } 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, + backgroundColor: + activeProject?.backgroundType === "blur" + ? "transparent" + : activeProject?.backgroundColor || "#000000", + projectCanvasSize: canvasSize, + }); + + return tempCtx.getImageData(0, 0, displayWidth, displayHeight); + }, + 1 + ); + } + return; + } + + // Cache miss - render from scratch if (!offscreenCanvasRef.current) { const hasOffscreen = typeof (globalThis as unknown as { OffscreenCanvas?: unknown }) @@ -541,6 +631,14 @@ export function PreviewPanel() { projectCanvasSize: canvasSize, }); + const imageData = (offCtx as CanvasRenderingContext2D).getImageData( + 0, + 0, + displayWidth, + displayHeight + ); + cacheFrame(currentTime, imageData, tracks, mediaFiles, activeProject); + // Blit offscreen to visible canvas mainCtx.clearRect(0, 0, displayWidth, displayHeight); if ((offscreenCanvas as HTMLCanvasElement).getContext) { @@ -564,6 +662,10 @@ export function PreviewPanel() { canvasSize.height, activeProject?.backgroundType, activeProject?.backgroundColor, + getCachedFrame, + cacheFrame, + preRenderNearbyFrames, + isPlaying, ]); // Get media elements for blur background (video/image only) diff --git a/apps/web/src/components/editor/timeline/index.tsx b/apps/web/src/components/editor/timeline/index.tsx index c994d707..90a19d61 100644 --- a/apps/web/src/components/editor/timeline/index.tsx +++ b/apps/web/src/components/editor/timeline/index.tsx @@ -57,6 +57,9 @@ import { useSelectionBox } from "@/hooks/use-selection-box"; import { SnapIndicator } from "../snap-indicator"; import { SnapPoint } from "@/hooks/use-timeline-snapping"; import type { DragData, TimelineTrack, TrackType } from "@/types/timeline"; +import { TimelineCacheIndicator } from "./timeline-cache-indicator"; +import { TimelineMarker } from "./timeline-marker"; +import { useFrameCache } from "@/hooks/use-frame-cache"; import { getTrackHeight, getCumulativeHeightBefore, @@ -85,6 +88,7 @@ export function Timeline() { const { mediaFiles, addMediaFile } = useMediaStore(); const { activeProject } = useProjectStore(); const { currentTime, duration, seek, setDuration } = usePlaybackStore(); + const { getRenderStatus } = useFrameCache(); const [isDragOver, setIsDragOver] = useState(false); const { addElementToNewTrack } = useTimelineStore(); const dragCounterRef = useRef(0); @@ -637,7 +641,7 @@ export function Timeline() { {/* Timeline Header with Ruler */}
{/* Track Labels Header */} -
+
{/* Empty space */} . @@ -658,7 +662,21 @@ export function Timeline() { onClick={handleTimelineContentClick} data-ruler-area > - + { + if (isUpdatingRef.current) return; + isUpdatingRef.current = true; + const tracksViewport = tracksScrollRef.current; + if (tracksViewport) { + tracksViewport.scrollLeft = ( + e.currentTarget as HTMLDivElement + ).scrollLeft; + } + isUpdatingRef.current = false; + }} + >
+ {/* Time markers */} {(() => { // Calculate appropriate time interval based on zoom level @@ -693,55 +719,13 @@ export function Timeline() { time % (interval >= 1 ? Math.max(1, interval) : 1) === 0; return ( -
- - {(() => { - const formatTime = (seconds: number) => { - const hours = Math.floor(seconds / 3600); - const minutes = Math.floor((seconds % 3600) / 60); - const secs = seconds % 60; - - if (hours > 0) { - return `${hours}:${minutes - .toString() - .padStart(2, "0")}:${Math.floor(secs) - .toString() - .padStart(2, "0")}`; - } - if (minutes > 0) { - return `${minutes}:${Math.floor(secs) - .toString() - .padStart(2, "0")}`; - } - if (interval >= 1) { - return `${Math.floor(secs)}s`; - } - return `${secs.toFixed(1)}s`; - }; - return formatTime(time); - })()} - -
+ time={time} + zoomLevel={zoomLevel} + interval={interval} + isMainMarker={isMainMarker} + /> ); }).filter(Boolean); })()} @@ -836,7 +820,21 @@ export function Timeline() { containerRef={tracksContainerRef} isActive={selectionBox?.isActive || false} /> - + { + if (isUpdatingRef.current) return; + isUpdatingRef.current = true; + const rulerViewport = rulerScrollRef.current; + if (rulerViewport) { + rulerViewport.scrollLeft = ( + e.currentTarget as HTMLDivElement + ).scrollLeft; + } + isUpdatingRef.current = false; + }} + >
+
diff --git a/apps/web/src/components/editor/timeline/timeline-cache-indicator.tsx b/apps/web/src/components/editor/timeline/timeline-cache-indicator.tsx new file mode 100644 index 00000000..f6494738 --- /dev/null +++ b/apps/web/src/components/editor/timeline/timeline-cache-indicator.tsx @@ -0,0 +1,116 @@ +"use client"; + +import { cn } from "@/lib/utils"; +import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; +import { TimelineTrack } from "@/types/timeline"; +import { MediaFile } from "@/types/media"; +import { TProject } from "@/types/project"; + +interface CacheSegment { + startTime: number; + endTime: number; + cached: boolean; +} + +interface TimelineCacheIndicatorProps { + duration: number; + zoomLevel: number; + tracks: TimelineTrack[]; + mediaFiles: MediaFile[]; + activeProject: TProject | null; + getRenderStatus: ( + time: number, + tracks: TimelineTrack[], + mediaFiles: MediaFile[], + activeProject: TProject | null + ) => "cached" | "not-cached"; +} + +export function TimelineCacheIndicator({ + duration, + zoomLevel, + tracks, + mediaFiles, + activeProject, + getRenderStatus, +}: TimelineCacheIndicatorProps) { + // Calculate cache segments by sampling the timeline + const calculateCacheSegments = (): CacheSegment[] => { + const segments: CacheSegment[] = []; + const sampleRate = 10; // Sample every 0.1 seconds + const totalSamples = Math.ceil(duration * sampleRate); + + if (totalSamples === 0) { + return [{ startTime: 0, endTime: duration, cached: false }]; + } + + let currentSegment: CacheSegment | null = null; + + for (let i = 0; i <= totalSamples; i++) { + const time = i / sampleRate; + const cached = + getRenderStatus(time, tracks, mediaFiles, activeProject) === "cached"; + + if (!currentSegment) { + // Start first segment + currentSegment = { + startTime: time, + endTime: time, + cached, + }; + } else if (currentSegment.cached === cached) { + // Extend current segment + currentSegment.endTime = time; + } else { + // Finish current segment and start new one + segments.push(currentSegment); + currentSegment = { + startTime: time, + endTime: time, + cached, + }; + } + } + + // Add the last segment + if (currentSegment) { + currentSegment.endTime = duration; + segments.push(currentSegment); + } + + return segments; + }; + + const cacheSegments = calculateCacheSegments(); + + return ( +
+ {cacheSegments.map((segment, index) => { + const startX = + segment.startTime * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; + const endX = + segment.endTime * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; + const width = endX - startX; + + return ( +
+ ); + })} +
+ ); +} diff --git a/apps/web/src/components/editor/timeline/timeline-marker.tsx b/apps/web/src/components/editor/timeline/timeline-marker.tsx new file mode 100644 index 00000000..1351a457 --- /dev/null +++ b/apps/web/src/components/editor/timeline/timeline-marker.tsx @@ -0,0 +1,67 @@ +"use client"; + +import { cn } from "@/lib/utils"; +import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; + +interface TimelineMarkerProps { + time: number; + zoomLevel: number; + interval: number; + isMainMarker: boolean; +} + +export function TimelineMarker({ + time, + zoomLevel, + interval, + isMainMarker, +}: TimelineMarkerProps) { + return ( +
+ + {(() => { + const formatTime = (seconds: number) => { + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + const secs = seconds % 60; + + if (hours > 0) { + return `${hours}:${minutes + .toString() + .padStart(2, "0")}:${Math.floor(secs) + .toString() + .padStart(2, "0")}`; + } + if (minutes > 0) { + return `${minutes}:${Math.floor(secs) + .toString() + .padStart(2, "0")}`; + } + if (interval >= 1) { + return `${Math.floor(secs)}s`; + } + return `${secs.toFixed(1)}s`; + }; + return formatTime(time); + })()} + +
+ ); +} diff --git a/apps/web/src/hooks/use-frame-cache.ts b/apps/web/src/hooks/use-frame-cache.ts new file mode 100644 index 00000000..8dc99966 --- /dev/null +++ b/apps/web/src/hooks/use-frame-cache.ts @@ -0,0 +1,320 @@ +import { useRef, useCallback } from "react"; +import { + TimelineTrack, + TimelineElement, + MediaElement, + TextElement, +} from "@/types/timeline"; +import { MediaFile } from "@/types/media"; +import { TProject } from "@/types/project"; + +interface CachedFrame { + imageData: ImageData; + timelineHash: string; + timestamp: number; +} + +interface FrameCacheOptions { + maxCacheSize?: number; // Maximum number of cached frames + cacheResolution?: number; // Frames per second to cache at +} + +// Shared singleton cache across hook instances (HMR-safe) +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const __frameCacheGlobal: any = globalThis as any; +const __sharedFrameCache: Map = + __frameCacheGlobal.__sharedFrameCache ?? new Map(); +__frameCacheGlobal.__sharedFrameCache = __sharedFrameCache; + +export function useFrameCache(options: FrameCacheOptions = {}) { + const { maxCacheSize = 300, cacheResolution = 30 } = options; // 10 seconds at 30fps + + const frameCacheRef = useRef(__sharedFrameCache); + + // Generate a hash of the timeline state that affects rendering + const getTimelineHash = useCallback( + ( + time: number, + tracks: TimelineTrack[], + mediaFiles: MediaFile[], + activeProject: TProject | null + ): string => { + // Get elements that are active at this time + const activeElements: Array<{ + id: string; + type: string; + startTime: number; + duration: number; + trimStart: number; + trimEnd: number; + mediaId?: string; + // Text-specific properties + content?: string; + fontSize?: number; + fontFamily?: string; + color?: string; + backgroundColor?: string; + x?: number; + y?: number; + rotation?: number; + opacity?: number; + }> = []; + + for (const track of tracks) { + if (track.muted) continue; + + for (const element of track.elements) { + // Check if element has hidden property (some elements might not have it) + const isHidden = "hidden" in element ? element.hidden : false; + if (isHidden) continue; + + const elementStart = element.startTime; + const elementEnd = + element.startTime + + (element.duration - element.trimStart - element.trimEnd); + + if (time >= elementStart && time < elementEnd) { + if (element.type === "media") { + const mediaElement = element as MediaElement; + activeElements.push({ + id: element.id, + type: element.type, + startTime: element.startTime, + duration: element.duration, + trimStart: element.trimStart, + trimEnd: element.trimEnd, + mediaId: mediaElement.mediaId, + }); + } else if (element.type === "text") { + const textElement = element as TextElement; + activeElements.push({ + id: element.id, + type: element.type, + startTime: element.startTime, + duration: element.duration, + trimStart: element.trimStart, + trimEnd: element.trimEnd, + content: textElement.content, + fontSize: textElement.fontSize, + fontFamily: textElement.fontFamily, + color: textElement.color, + backgroundColor: textElement.backgroundColor, + x: textElement.x, + y: textElement.y, + rotation: textElement.rotation, + opacity: textElement.opacity, + }); + } + } + } + } + + // Include project settings that affect rendering + const projectState = { + backgroundColor: activeProject?.backgroundColor, + backgroundType: activeProject?.backgroundType, + blurIntensity: activeProject?.blurIntensity, + canvasSize: activeProject?.canvasSize, + }; + + return JSON.stringify({ + activeElements, + projectState, + time: Math.floor(time * cacheResolution) / cacheResolution, // Quantize time + }); + }, + [cacheResolution] + ); + + // Check if a frame is cached and valid + const isFrameCached = useCallback( + ( + time: number, + tracks: TimelineTrack[], + mediaFiles: MediaFile[], + activeProject: TProject | null + ): boolean => { + const frameKey = Math.floor(time * cacheResolution); + const cached = frameCacheRef.current.get(frameKey); + + if (!cached) return false; + + const currentHash = getTimelineHash( + time, + tracks, + mediaFiles, + activeProject + ); + return cached.timelineHash === currentHash; + }, + [getTimelineHash, cacheResolution] + ); + + // Get cached frame if available and valid + const getCachedFrame = useCallback( + ( + time: number, + tracks: TimelineTrack[], + mediaFiles: MediaFile[], + activeProject: TProject | null + ): ImageData | null => { + const frameKey = Math.floor(time * cacheResolution); + const cached = frameCacheRef.current.get(frameKey); + + if (!cached) return null; + + const currentHash = getTimelineHash( + time, + tracks, + mediaFiles, + activeProject + ); + if (cached.timelineHash !== currentHash) { + // Cache is stale, remove it + frameCacheRef.current.delete(frameKey); + return null; + } + + return cached.imageData; + }, + [getTimelineHash, cacheResolution] + ); + + // Cache a rendered frame + const cacheFrame = useCallback( + ( + time: number, + imageData: ImageData, + tracks: TimelineTrack[], + mediaFiles: MediaFile[], + activeProject: TProject | null + ): void => { + const frameKey = Math.floor(time * cacheResolution); + const timelineHash = getTimelineHash( + time, + tracks, + mediaFiles, + activeProject + ); + + // Enforce cache size limit (LRU eviction) + if (frameCacheRef.current.size >= maxCacheSize) { + // Remove oldest entries + const entries = Array.from(frameCacheRef.current.entries()); + entries.sort((a, b) => a[1].timestamp - b[1].timestamp); + + // Remove oldest 20% of entries + const toRemove = Math.floor(entries.length * 0.2); + for (let i = 0; i < toRemove; i++) { + frameCacheRef.current.delete(entries[i][0]); + } + } + + frameCacheRef.current.set(frameKey, { + imageData, + timelineHash, + timestamp: Date.now(), + }); + }, + [getTimelineHash, cacheResolution, maxCacheSize] + ); + + // Clear cache when timeline changes significantly + const invalidateCache = useCallback(() => { + frameCacheRef.current.clear(); + }, []); + + // Get render status for timeline indicator + const getRenderStatus = useCallback( + ( + time: number, + tracks: TimelineTrack[], + mediaFiles: MediaFile[], + activeProject: TProject | null + ): "cached" | "not-cached" => { + return isFrameCached(time, tracks, mediaFiles, activeProject) + ? "cached" + : "not-cached"; + }, + [isFrameCached] + ); + + // Pre-render frames around current time + const preRenderNearbyFrames = useCallback( + async ( + currentTime: number, + tracks: TimelineTrack[], + mediaFiles: MediaFile[], + activeProject: TProject | null, + renderFunction: (time: number) => Promise, + range: number = 3 // seconds + ) => { + const framesToPreRender: number[] = []; + + // Calculate frames to pre-render (around current time) + for ( + let offset = -range; + offset <= range; + offset += 1 / cacheResolution + ) { + const time = currentTime + offset; + if (time < 0) continue; + + if (!isFrameCached(time, tracks, mediaFiles, activeProject)) { + framesToPreRender.push(time); + } + } + + // Expand to full 1-second buckets to avoid fragmented tiny cache regions + const secondsToPreRender = new Set(); + for (const t of framesToPreRender) { + secondsToPreRender.add(Math.floor(t)); + } + + const expandedTimes: number[] = []; + for (const s of secondsToPreRender) { + for (let k = 0; k < cacheResolution; k++) { + const t = s + k / cacheResolution; + if (t < 0) continue; + if (!isFrameCached(t, tracks, mediaFiles, activeProject)) { + expandedTimes.push(t); + } + } + } + + // Sort forward-first near currentTime to improve perceived responsiveness + expandedTimes.sort((a, b) => { + const da = a >= currentTime ? a - currentTime : currentTime - a + 1e6; + const db = b >= currentTime ? b - currentTime : currentTime - b + 1e6; + return da - db; + }); + + // Cap total scheduled renders to avoid jank (e.g., up to 90 frames) + const CAP = Math.max(30, Math.min(90, cacheResolution * 3)); + const toSchedule = expandedTimes.slice(0, CAP); + + // Pre-render during idle time + for (const time of toSchedule) { + requestIdleCallback(async () => { + try { + const imageData = await renderFunction(time); + cacheFrame(time, imageData, tracks, mediaFiles, activeProject); + } catch (error) { + console.warn(`Pre-render failed for time ${time}:`, error); + } + }); + } + }, + [isFrameCached, cacheFrame, cacheResolution] + ); + + return { + isFrameCached, + getCachedFrame, + cacheFrame, + invalidateCache, + getRenderStatus, + preRenderNearbyFrames, + cacheSize: frameCacheRef.current.size, + }; +} diff --git a/apps/web/src/lib/export.ts b/apps/web/src/lib/export.ts index f25f5f6b..2dd41855 100644 --- a/apps/web/src/lib/export.ts +++ b/apps/web/src/lib/export.ts @@ -16,6 +16,8 @@ import { useMediaStore } from "@/stores/media-store"; import { useProjectStore } from "@/stores/project-store"; import { DEFAULT_FPS } from "@/stores/project-store"; import { ExportOptions, ExportResult } from "@/types/export"; +import { TimelineTrack } from "@/types/timeline"; +import { MediaFile } from "@/types/media"; export const DEFAULT_EXPORT_OPTIONS: ExportOptions = { format: "mp4", @@ -40,8 +42,8 @@ interface AudioElement { } async function createTimelineAudioBuffer( - tracks: any[], - mediaFiles: any[], + tracks: TimelineTrack[], + mediaFiles: MediaFile[], duration: number, sampleRate: number = 44100 ): Promise { @@ -51,7 +53,7 @@ async function createTimelineAudioBuffer( // Collect all audio elements from timeline const audioElements: AudioElement[] = []; - const mediaMap = new Map(mediaFiles.map((m) => [m.id, m])); + const mediaMap = new Map(mediaFiles.map((m) => [m.id, m])); for (const track of tracks) { if (track.muted) continue; @@ -59,11 +61,12 @@ async function createTimelineAudioBuffer( for (const element of track.elements) { if (element.type !== "media") continue; - const mediaItem = mediaMap.get(element.mediaId); + const mediaElement = element; + const mediaItem = mediaMap.get(mediaElement.mediaId); if (!mediaItem || mediaItem.type !== "audio") continue; const visibleDuration = - element.duration - element.trimStart - element.trimEnd; + mediaElement.duration - mediaElement.trimStart - mediaElement.trimEnd; if (visibleDuration <= 0) continue; try { @@ -75,11 +78,11 @@ async function createTimelineAudioBuffer( audioElements.push({ buffer: audioBuffer, - startTime: element.startTime, - duration: element.duration, - trimStart: element.trimStart, - trimEnd: element.trimEnd, - muted: element.muted || track.muted || false, + startTime: mediaElement.startTime, + duration: mediaElement.duration, + trimStart: mediaElement.trimStart, + trimEnd: mediaElement.trimEnd, + muted: mediaElement.muted || track.muted || false, }); } catch (error) { console.warn(`Failed to decode audio file ${mediaItem.name}:`, error); From d747fb458d2fbebd2ce007132b43cd973bd5513f Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Sun, 31 Aug 2025 13:40:41 +0200 Subject: [PATCH 03/29] feat: make timeline renderer 15x faster --- apps/web/src/lib/timeline-renderer.ts | 51 +++---- apps/web/src/lib/video-cache.ts | 186 ++++++++++++++++++++++++++ apps/web/src/stores/media-store.ts | 5 + 3 files changed, 218 insertions(+), 24 deletions(-) create mode 100644 apps/web/src/lib/video-cache.ts diff --git a/apps/web/src/lib/timeline-renderer.ts b/apps/web/src/lib/timeline-renderer.ts index 6241a7d9..08785369 100644 --- a/apps/web/src/lib/timeline-renderer.ts +++ b/apps/web/src/lib/timeline-renderer.ts @@ -1,6 +1,6 @@ -import { Input, ALL_FORMATS, BlobSource, VideoSampleSink } from "mediabunny"; import type { TimelineTrack } from "@/types/timeline"; import type { MediaFile } from "@/types/media"; +import { videoCache } from "./video-cache"; export interface RenderContext { ctx: CanvasRenderingContext2D; @@ -65,31 +65,34 @@ export async function renderTimelineFrame({ for (const { element, mediaItem } of active) { if (element.type === "media" && mediaItem) { if (mediaItem.type === "video") { - const input = new Input({ - source: new BlobSource(mediaItem.file), - formats: ALL_FORMATS, - }); - const track = await input.getPrimaryVideoTrack(); - if (!track) continue; - const decodable = await track.canDecode(); - if (!decodable) continue; - const sink = new VideoSampleSink(track); + try { + const localTime = time - element.startTime + element.trimStart; - const localTime = time - element.startTime + element.trimStart; - const sample = await sink.getSample(localTime); - if (!sample) continue; + const frame = await videoCache.getFrameAt( + mediaItem.id, + mediaItem.file, + localTime + ); + if (!frame) continue; - const mediaW = Math.max(1, mediaItem.width || canvasWidth); - const mediaH = Math.max(1, mediaItem.height || canvasHeight); - const containScale = Math.min( - canvasWidth / mediaW, - canvasHeight / mediaH - ); - const drawW = mediaW * containScale; - const drawH = mediaH * containScale; - const drawX = (canvasWidth - drawW) / 2; - const drawY = (canvasHeight - drawH) / 2; - sample.draw(ctx, drawX, drawY, drawW, drawH); + const mediaW = Math.max(1, mediaItem.width || canvasWidth); + const mediaH = Math.max(1, mediaItem.height || canvasHeight); + const containScale = Math.min( + canvasWidth / mediaW, + canvasHeight / mediaH + ); + const drawW = mediaW * containScale; + const drawH = mediaH * containScale; + const drawX = (canvasWidth - drawW) / 2; + const drawY = (canvasHeight - drawH) / 2; + + ctx.drawImage(frame.canvas, drawX, drawY, drawW, drawH); + } catch (error) { + console.warn( + `Failed to render video frame for ${mediaItem.name}:`, + error + ); + } } if (mediaItem.type === "image") { const img = new Image(); diff --git a/apps/web/src/lib/video-cache.ts b/apps/web/src/lib/video-cache.ts new file mode 100644 index 00000000..aeb62d1c --- /dev/null +++ b/apps/web/src/lib/video-cache.ts @@ -0,0 +1,186 @@ +import { + Input, + ALL_FORMATS, + BlobSource, + CanvasSink, + WrappedCanvas, +} from "mediabunny"; + +interface VideoSinkData { + sink: CanvasSink; + iterator: AsyncGenerator | null; + currentFrame: WrappedCanvas | null; + lastTime: number; +} +export class VideoCache { + private sinks = new Map(); + private initPromises = new Map>(); + + async getFrameAt( + mediaId: string, + file: File, + time: number + ): Promise { + await this.ensureSink(mediaId, file); + + const sinkData = this.sinks.get(mediaId); + if (!sinkData) return null; + + if ( + sinkData.currentFrame && + this.isFrameValid(sinkData.currentFrame, time) + ) { + return sinkData.currentFrame; + } + + if ( + sinkData.iterator && + sinkData.currentFrame && + time >= sinkData.lastTime && + time < sinkData.lastTime + 2.0 + ) { + const frame = await this.iterateToTime(sinkData, time); + if (frame) return frame; + } + + return await this.seekToTime(sinkData, time); + } + + private isFrameValid(frame: WrappedCanvas, time: number): boolean { + return time >= frame.timestamp && time < frame.timestamp + frame.duration; + } + private async iterateToTime( + sinkData: VideoSinkData, + targetTime: number + ): Promise { + if (!sinkData.iterator) return null; + + try { + while (true) { + const { value: frame, done } = await sinkData.iterator.next(); + + if (done || !frame) break; + + sinkData.currentFrame = frame; + sinkData.lastTime = frame.timestamp; + + if (this.isFrameValid(frame, targetTime)) { + return frame; + } + + if (frame.timestamp > targetTime + 1.0) break; + } + } catch (error) { + console.warn("Iterator failed, will restart:", error); + sinkData.iterator = null; + } + + return null; + } + private async seekToTime( + sinkData: VideoSinkData, + time: number + ): Promise { + try { + if (sinkData.iterator) { + await sinkData.iterator.return(); + sinkData.iterator = null; + } + + sinkData.iterator = sinkData.sink.canvases(time); + sinkData.lastTime = time; + + const { value: frame } = await sinkData.iterator.next(); + + if (frame) { + sinkData.currentFrame = frame; + return frame; + } + } catch (error) { + console.warn("Failed to seek video:", error); + } + + return null; + } + private async ensureSink(mediaId: string, file: File): Promise { + if (this.sinks.has(mediaId)) return; + + if (this.initPromises.has(mediaId)) { + await this.initPromises.get(mediaId); + return; + } + + const initPromise = this.initializeSink(mediaId, file); + this.initPromises.set(mediaId, initPromise); + + try { + await initPromise; + } finally { + this.initPromises.delete(mediaId); + } + } + private async initializeSink(mediaId: string, file: File): Promise { + try { + const input = new Input({ + source: new BlobSource(file), + formats: ALL_FORMATS, + }); + + const videoTrack = await input.getPrimaryVideoTrack(); + if (!videoTrack) { + throw new Error("No video track found"); + } + + const canDecode = await videoTrack.canDecode(); + if (!canDecode) { + throw new Error("Video codec not supported for decoding"); + } + + const sink = new CanvasSink(videoTrack, { + poolSize: 3, + fit: "contain", + }); + + this.sinks.set(mediaId, { + sink, + iterator: null, + currentFrame: null, + lastTime: -1, + }); + } catch (error) { + console.error(`Failed to initialize video sink for ${mediaId}:`, error); + throw error; + } + } + + clearVideo(mediaId: string): void { + const sinkData = this.sinks.get(mediaId); + if (sinkData) { + if (sinkData.iterator) { + sinkData.iterator.return(); + } + + this.sinks.delete(mediaId); + } + + this.initPromises.delete(mediaId); + } + + clearAll(): void { + for (const [mediaId] of this.sinks) { + this.clearVideo(mediaId); + } + } + + getStats() { + return { + totalSinks: this.sinks.size, + activeSinks: Array.from(this.sinks.values()).filter((s) => s.iterator) + .length, + cachedFrames: Array.from(this.sinks.values()).filter( + (s) => s.currentFrame + ).length, + }; + } +} +export const videoCache = new VideoCache(); diff --git a/apps/web/src/stores/media-store.ts b/apps/web/src/stores/media-store.ts index 2b7cb62e..c9e29fff 100644 --- a/apps/web/src/stores/media-store.ts +++ b/apps/web/src/stores/media-store.ts @@ -3,6 +3,7 @@ import { storageService } from "@/lib/storage/storage-service"; import { useTimelineStore } from "./timeline-store"; import { generateUUID } from "@/lib/utils"; import { MediaType, MediaFile } from "@/types/media"; +import { videoCache } from "@/lib/video-cache"; interface MediaStore { mediaFiles: MediaFile[]; @@ -165,6 +166,8 @@ export const useMediaStore = create((set, get) => ({ const state = get(); const item = state.mediaFiles.find((media) => media.id === id); + videoCache.clearVideo(id); + // Cleanup object URLs to prevent memory leaks if (item?.url) { URL.revokeObjectURL(item.url); @@ -289,6 +292,8 @@ export const useMediaStore = create((set, get) => ({ clearAllMedia: () => { const state = get(); + videoCache.clearAll(); + // Cleanup all object URLs state.mediaFiles.forEach((item) => { if (item.url) { From e7d75ce1f882975d2053cb6304d4edb4cb6cfcae Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Sun, 31 Aug 2025 13:43:52 +0200 Subject: [PATCH 04/29] fix: add fallback --- apps/web/src/lib/export.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/lib/export.ts b/apps/web/src/lib/export.ts index 2dd41855..8b1ab83e 100644 --- a/apps/web/src/lib/export.ts +++ b/apps/web/src/lib/export.ts @@ -14,7 +14,7 @@ import { renderTimelineFrame } from "./timeline-renderer"; import { useTimelineStore } from "@/stores/timeline-store"; import { useMediaStore } from "@/stores/media-store"; import { useProjectStore } from "@/stores/project-store"; -import { DEFAULT_FPS } from "@/stores/project-store"; +import { DEFAULT_FPS, DEFAULT_CANVAS_SIZE } from "@/stores/project-store"; import { ExportOptions, ExportResult } from "@/types/export"; import { TimelineTrack } from "@/types/timeline"; import { MediaFile } from "@/types/media"; @@ -171,7 +171,7 @@ export async function exportProject( } const exportFps = fps || activeProject.fps || DEFAULT_FPS; - const canvasSize = activeProject.canvasSize; + const canvasSize = activeProject.canvasSize || DEFAULT_CANVAS_SIZE; const outputFormat = format === "webm" ? new WebMOutputFormat() : new Mp4OutputFormat(); From ae50aee4b6520430a30765663935a33b1250fa85 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Sun, 31 Aug 2025 13:48:29 +0200 Subject: [PATCH 05/29] style: spacing --- apps/web/src/components/keyboard-shortcuts-help.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/keyboard-shortcuts-help.tsx b/apps/web/src/components/keyboard-shortcuts-help.tsx index dd6a500b..689c3ee3 100644 --- a/apps/web/src/components/keyboard-shortcuts-help.tsx +++ b/apps/web/src/components/keyboard-shortcuts-help.tsx @@ -111,7 +111,7 @@ export function KeyboardShortcutsHelp() { - + Keyboard Shortcuts @@ -123,7 +123,7 @@ export function KeyboardShortcutsHelp() {
-
+
{categories.map((category) => (

From 3db766646759bf59f11ef3f8640d1aca6d394c15 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Sun, 31 Aug 2025 13:53:13 +0200 Subject: [PATCH 06/29] more styling --- apps/web/src/components/editor/export-button.tsx | 2 +- apps/web/src/components/ui/button.tsx | 2 +- apps/web/src/components/ui/popover.tsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/editor/export-button.tsx b/apps/web/src/components/editor/export-button.tsx index c9738e81..41a5f65c 100644 --- a/apps/web/src/components/editor/export-button.tsx +++ b/apps/web/src/components/editor/export-button.tsx @@ -132,7 +132,7 @@ function ExportPopover({ }; return ( - + <>

diff --git a/apps/web/src/components/ui/button.tsx b/apps/web/src/components/ui/button.tsx index 0e07ccb8..3d17c042 100644 --- a/apps/web/src/components/ui/button.tsx +++ b/apps/web/src/components/ui/button.tsx @@ -18,7 +18,7 @@ const buttonVariants = cva( destructive: "bg-destructive text-destructive-foreground shadow-xs hover:bg-destructive/90", outline: - "border border-input bg-background shadow-xs hover:opacity-75 transition-opacity hover:text-accent-foreground", + "border border-input bg-transparent shadow-xs hover:opacity-75 transition-opacity hover:text-accent-foreground", secondary: "bg-secondary text-secondary-foreground shadow-xs hover:bg-foreground/15 border border-input", text: "bg-transparent p-0 rounded-none opacity-100 hover:opacity-50 transition-opacity", // Instead of ghost (matches app better) diff --git a/apps/web/src/components/ui/popover.tsx b/apps/web/src/components/ui/popover.tsx index 47782a7f..406ff8eb 100644 --- a/apps/web/src/components/ui/popover.tsx +++ b/apps/web/src/components/ui/popover.tsx @@ -21,7 +21,7 @@ const PopoverContent = React.forwardRef< align={align} sideOffset={sideOffset} className={cn( - "z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-hidden data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", + "z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-[0_0_10px_rgba(0,0,0,0.15)] outline-hidden data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", className )} {...props} From 29fdd8568dbb9c67ee19492781f3f03eb2c3bd24 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Sun, 31 Aug 2025 14:10:59 +0200 Subject: [PATCH 07/29] refactor: improve types, fix background blur, some other stuff --- .../editor/media-panel/views/settings.tsx | 5 +- .../src/components/editor/preview-panel.tsx | 12 +- apps/web/src/lib/export.ts | 4 +- apps/web/src/lib/timeline-renderer.ts | 145 +++++++++++++++--- apps/web/src/stores/project-store.ts | 10 +- apps/web/src/types/project.ts | 4 +- 6 files changed, 148 insertions(+), 32 deletions(-) diff --git a/apps/web/src/components/editor/media-panel/views/settings.tsx b/apps/web/src/components/editor/media-panel/views/settings.tsx index df1ec54f..6cdd1b65 100644 --- a/apps/web/src/components/editor/media-panel/views/settings.tsx +++ b/apps/web/src/components/editor/media-panel/views/settings.tsx @@ -16,6 +16,7 @@ import { } from "../../properties-panel/property-item"; import { FPS_PRESETS } from "@/constants/timeline-constants"; import { useProjectStore } from "@/stores/project-store"; +import type { BlurIntensity } from "@/types/project"; import { useEditorStore } from "@/stores/editor-store"; import { useAspectRatio } from "@/hooks/use-aspect-ratio"; import Image from "next/image"; @@ -215,7 +216,7 @@ BackgroundPreviews.displayName = "BackgroundPreviews"; function BackgroundView() { const { activeProject, updateBackgroundType } = useProjectStore(); - const blurLevels = useMemo( + const blurLevels = useMemo>( () => [ { label: "Light", value: 4 }, { label: "Medium", value: 8 }, @@ -225,7 +226,7 @@ function BackgroundView() { ); const handleBlurSelect = useCallback( - async (blurIntensity: number) => { + async (blurIntensity: BlurIntensity) => { await updateBackgroundType("blur", { blurIntensity }); }, [updateBackgroundType] diff --git a/apps/web/src/components/editor/preview-panel.tsx b/apps/web/src/components/editor/preview-panel.tsx index c53cdafb..b7cc9578 100644 --- a/apps/web/src/components/editor/preview-panel.tsx +++ b/apps/web/src/components/editor/preview-panel.tsx @@ -521,9 +521,11 @@ export function PreviewPanel() { canvasHeight: displayHeight, tracks, mediaFiles, + backgroundType: activeProject?.backgroundType, + blurIntensity: activeProject?.blurIntensity, backgroundColor: activeProject?.backgroundType === "blur" - ? "transparent" + ? undefined : activeProject?.backgroundColor || "#000000", projectCanvasSize: canvasSize, }); @@ -553,9 +555,11 @@ export function PreviewPanel() { canvasHeight: displayHeight, tracks, mediaFiles, + backgroundType: activeProject?.backgroundType, + blurIntensity: activeProject?.blurIntensity, backgroundColor: activeProject?.backgroundType === "blur" - ? "transparent" + ? undefined : activeProject?.backgroundColor || "#000000", projectCanvasSize: canvasSize, }); @@ -624,9 +628,11 @@ export function PreviewPanel() { canvasHeight: displayHeight, tracks, mediaFiles, + backgroundType: activeProject?.backgroundType, + blurIntensity: activeProject?.blurIntensity, backgroundColor: activeProject?.backgroundType === "blur" - ? "transparent" + ? undefined : activeProject?.backgroundColor || "#000000", projectCanvasSize: canvasSize, }); diff --git a/apps/web/src/lib/export.ts b/apps/web/src/lib/export.ts index 8b1ab83e..52c58736 100644 --- a/apps/web/src/lib/export.ts +++ b/apps/web/src/lib/export.ts @@ -252,9 +252,11 @@ export async function exportProject( canvasHeight: canvas.height, tracks, mediaFiles, + backgroundType: activeProject.backgroundType, + blurIntensity: activeProject.blurIntensity, backgroundColor: activeProject.backgroundType === "blur" - ? "transparent" + ? undefined : activeProject.backgroundColor || "#000000", projectCanvasSize: canvasSize, }); diff --git a/apps/web/src/lib/timeline-renderer.ts b/apps/web/src/lib/timeline-renderer.ts index 08785369..ddc361b5 100644 --- a/apps/web/src/lib/timeline-renderer.ts +++ b/apps/web/src/lib/timeline-renderer.ts @@ -1,5 +1,6 @@ import type { TimelineTrack } from "@/types/timeline"; import type { MediaFile } from "@/types/media"; +import type { BlurIntensity } from "@/types/project"; import { videoCache } from "./video-cache"; export interface RenderContext { @@ -10,9 +11,29 @@ export interface RenderContext { tracks: TimelineTrack[]; mediaFiles: MediaFile[]; backgroundColor?: string; + backgroundType?: "color" | "blur"; + blurIntensity?: BlurIntensity; projectCanvasSize?: { width: number; height: number }; } +const imageElementCache = new Map(); + +async function getImageElement( + mediaItem: MediaFile +): Promise { + const cacheKey = mediaItem.id; + const cached = imageElementCache.get(cacheKey); + if (cached) return cached; + const img = new Image(); + await new Promise((resolve, reject) => { + img.onload = () => resolve(); + img.onerror = () => reject(new Error("Image load failed")); + img.src = mediaItem.url || URL.createObjectURL(mediaItem.file); + }); + imageElementCache.set(cacheKey, img); + return img; +} + export async function renderTimelineFrame({ ctx, time, @@ -21,6 +42,8 @@ export async function renderTimelineFrame({ tracks, mediaFiles, backgroundColor, + backgroundType, + blurIntensity, projectCanvasSize, }: RenderContext): Promise { // Background @@ -44,7 +67,7 @@ export async function renderTimelineFrame({ for (let t = tracks.length - 1; t >= 0; t -= 1) { const track = tracks[t]; for (const element of track.elements) { - if ((element as any).hidden) continue; + if (element.hidden) continue; const elementStart = element.startTime; const elementEnd = element.startTime + @@ -62,6 +85,72 @@ export async function renderTimelineFrame({ } } + // If background is set to blur, draw the active media as a blurred cover layer first + if (backgroundType === "blur") { + const blurPx = Math.max(0, blurIntensity ?? 8); + // Find a suitable media element (video/image) among active elements + const bgCandidate = active.find(({ element, mediaItem }) => { + return ( + element.type === "media" && + mediaItem !== null && + (mediaItem.type === "video" || mediaItem.type === "image") + ); + }); + if (bgCandidate && bgCandidate.mediaItem) { + const { element, mediaItem } = bgCandidate; + try { + if (mediaItem.type === "video") { + const localTime = time - element.startTime + element.trimStart; + const frame = await videoCache.getFrameAt( + mediaItem.id, + mediaItem.file, + Math.max(0, localTime) + ); + if (frame) { + const mediaW = Math.max(1, mediaItem.width || canvasWidth); + const mediaH = Math.max(1, mediaItem.height || canvasHeight); + const coverScale = Math.max( + canvasWidth / mediaW, + canvasHeight / mediaH + ); + const drawW = mediaW * coverScale; + const drawH = mediaH * coverScale; + const drawX = (canvasWidth - drawW) / 2; + const drawY = (canvasHeight - drawH) / 2; + ctx.save(); + ctx.filter = `blur(${blurPx}px)`; + ctx.drawImage(frame.canvas, drawX, drawY, drawW, drawH); + ctx.restore(); + } + } else if (mediaItem.type === "image") { + const img = await getImageElement(mediaItem); + const mediaW = Math.max( + 1, + mediaItem.width || img.naturalWidth || canvasWidth + ); + const mediaH = Math.max( + 1, + mediaItem.height || img.naturalHeight || canvasHeight + ); + const coverScale = Math.max( + canvasWidth / mediaW, + canvasHeight / mediaH + ); + const drawW = mediaW * coverScale; + const drawH = mediaH * coverScale; + const drawX = (canvasWidth - drawW) / 2; + const drawY = (canvasHeight - drawH) / 2; + ctx.save(); + ctx.filter = `blur(${blurPx}px)`; + ctx.drawImage(img, drawX, drawY, drawW, drawH); + ctx.restore(); + } + } catch { + // Ignore background blur failures; foreground will still render + } + } + } + for (const { element, mediaItem } of active) { if (element.type === "media" && mediaItem) { if (mediaItem.type === "video") { @@ -121,33 +210,47 @@ export async function renderTimelineFrame({ } } if (element.type === "text") { - const posX = canvasWidth / 2 + (element as any).x * scaleX; - const posY = canvasHeight / 2 + (element as any).y * scaleY; + const text = element; + const posX = canvasWidth / 2 + text.x * scaleX; + const posY = canvasHeight / 2 + text.y * scaleY; ctx.save(); ctx.translate(posX, posY); - ctx.rotate(((element as any).rotation * Math.PI) / 180); - ctx.globalAlpha = Math.max(0, Math.min(1, (element as any).opacity)); - const px = (element as any).fontSize * scaleX; - const weight = (element as any).fontWeight === "bold" ? "bold " : ""; - const style = (element as any).fontStyle === "italic" ? "italic " : ""; - ctx.font = `${style}${weight}${px}px ${(element as any).fontFamily}`; - ctx.fillStyle = (element as any).color; - ctx.textAlign = (element as any).textAlign; + ctx.rotate((text.rotation * Math.PI) / 180); + ctx.globalAlpha = Math.max(0, Math.min(1, text.opacity)); + const px = text.fontSize * scaleX; + const weight = text.fontWeight === "bold" ? "bold " : ""; + const style = text.fontStyle === "italic" ? "italic " : ""; + ctx.font = `${style}${weight}${px}px ${text.fontFamily}`; + ctx.fillStyle = text.color; + ctx.textAlign = text.textAlign as CanvasTextAlign; ctx.textBaseline = "middle"; - const metrics = ctx.measureText((element as any).content); - const ascent = - (metrics as unknown as { actualBoundingBoxAscent?: number }) - .actualBoundingBoxAscent ?? px * 0.8; - const descent = - (metrics as unknown as { actualBoundingBoxDescent?: number }) - .actualBoundingBoxDescent ?? px * 0.2; + const metrics = ctx.measureText(text.content); + const hasBoxMetrics = + "actualBoundingBoxAscent" in metrics && + "actualBoundingBoxDescent" in metrics; + const ascent = hasBoxMetrics + ? ( + metrics as TextMetrics & { + actualBoundingBoxAscent: number; + actualBoundingBoxDescent: number; + } + ).actualBoundingBoxAscent + : px * 0.8; + const descent = hasBoxMetrics + ? ( + metrics as TextMetrics & { + actualBoundingBoxAscent: number; + actualBoundingBoxDescent: number; + } + ).actualBoundingBoxDescent + : px * 0.2; const textW = metrics.width; const textH = ascent + descent; const padX = 8 * scaleX; const padY = 4 * scaleX; - if ((element as any).backgroundColor) { + if (text.backgroundColor) { ctx.save(); - ctx.fillStyle = (element as any).backgroundColor; + ctx.fillStyle = text.backgroundColor; let bgLeft = -textW / 2; if (ctx.textAlign === "left") bgLeft = 0; if (ctx.textAlign === "right") bgLeft = -textW; @@ -159,7 +262,7 @@ export async function renderTimelineFrame({ ); ctx.restore(); } - ctx.fillText((element as any).content, 0, 0); + ctx.fillText(text.content, 0, 0); ctx.restore(); } } diff --git a/apps/web/src/stores/project-store.ts b/apps/web/src/stores/project-store.ts index b924ea22..ed5eda73 100644 --- a/apps/web/src/stores/project-store.ts +++ b/apps/web/src/stores/project-store.ts @@ -1,4 +1,4 @@ -import { TProject } from "@/types/project"; +import { TProject, BlurIntensity } from "@/types/project"; import { create } from "zustand"; import { storageService } from "@/lib/storage/storage-service"; import { toast } from "sonner"; @@ -44,7 +44,7 @@ interface ProjectStore { updateProjectBackground: (backgroundColor: string) => Promise; updateBackgroundType: ( type: "color" | "blur", - options?: { backgroundColor?: string; blurIntensity?: number } + options?: { backgroundColor?: string; blurIntensity?: BlurIntensity } ) => Promise; updateProjectFps: (fps: number) => Promise; updateCanvasSize: (size: CanvasSize, mode: CanvasMode) => Promise; @@ -399,7 +399,7 @@ export const useProjectStore = create((set, get) => ({ updateBackgroundType: async ( type: "color" | "blur", - options?: { backgroundColor?: string; blurIntensity?: number } + options?: { backgroundColor?: string; blurIntensity?: BlurIntensity } ) => { const { activeProject } = get(); if (!activeProject) return; @@ -410,7 +410,9 @@ export const useProjectStore = create((set, get) => ({ ...(options?.backgroundColor && { backgroundColor: options.backgroundColor, }), - ...(options?.blurIntensity && { blurIntensity: options.blurIntensity }), + ...(options?.blurIntensity !== undefined && { + blurIntensity: options.blurIntensity, + }), updatedAt: new Date(), }; diff --git a/apps/web/src/types/project.ts b/apps/web/src/types/project.ts index bf510942..66de858a 100644 --- a/apps/web/src/types/project.ts +++ b/apps/web/src/types/project.ts @@ -1,5 +1,7 @@ import { CanvasSize } from "./editor"; +export type BlurIntensity = 4 | 8 | 18; + export interface TProject { id: string; name: string; @@ -9,7 +11,7 @@ export interface TProject { mediaItems?: string[]; backgroundColor?: string; backgroundType?: "color" | "blur"; - blurIntensity?: number; // in pixels (4, 8, 18) + blurIntensity?: BlurIntensity; fps?: number; bookmarks?: number[]; canvasSize: CanvasSize; From 73a156c002b9f97b133d89b1e07f11eec7641576 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Sun, 31 Aug 2025 14:35:24 +0200 Subject: [PATCH 08/29] gradient backgrounds now work --- .../editor/media-panel/views/settings.tsx | 4 +- apps/web/src/lib/canvas-gradients.ts | 169 ++++++++++++++++++ apps/web/src/lib/timeline-renderer.ts | 16 +- 3 files changed, 186 insertions(+), 3 deletions(-) create mode 100644 apps/web/src/lib/canvas-gradients.ts diff --git a/apps/web/src/components/editor/media-panel/views/settings.tsx b/apps/web/src/components/editor/media-panel/views/settings.tsx index 6cdd1b65..d2d21f36 100644 --- a/apps/web/src/components/editor/media-panel/views/settings.tsx +++ b/apps/web/src/components/editor/media-panel/views/settings.tsx @@ -178,9 +178,9 @@ const BackgroundPreviews = memo( }) => { return useMemo( () => - backgrounds.map((bg) => ( + backgrounds.map((bg, index) => (
{ + if (!p) return (full || 0) / 2; + if (p.endsWith("%")) return (parseFloat(p) / 100) * (full || 0); + if (p === "left") return 0; + if (p === "right") return full || 0; + if (p === "top") return 0; + if (p === "bottom") return full || 0; + if (p === "center") return (full || 0) / 2; + return (full || 0) / 2; + }; + if (px && py) { + cx = parsePos(px, width); + cy = parsePos(py, height); + } else if (px) { + cx = parsePos(px, width); + cy = parsePos(px, height); + } + } else { + parts.unshift(first); + } + const r = Math.hypot(width, height); + const grad = ctx.createRadialGradient(cx, cy, 0, cx, cy, r); + const colorStops = parts; + const n = Math.max(1, colorStops.length - 1); + for (let i = 0; i < colorStops.length; i += 1) { + const color = extractColorFromStop(colorStops[i] as string); + grad.addColorStop(Math.min(1, Math.max(0, i / n)), color); + } + ctx.fillStyle = grad; + ctx.fillRect(0, 0, width, height); +} + +export function drawCssBackground( + ctx: CanvasRenderingContext2D, + width: number, + height: number, + css: string +): void { + const layers = splitBackgroundLayers(css).filter(Boolean); + for (let i = layers.length - 1; i >= 0; i -= 1) { + const layer = layers[i] as string; + if (layer.startsWith("linear-gradient(")) { + drawLinearGradient(ctx, width, height, layer); + } else if (layer.startsWith("radial-gradient(")) { + drawRadialGradient(ctx, width, height, layer); + } else if ( + layer.startsWith("#") || + layer.startsWith("rgb") || + layer.startsWith("hsl") + ) { + ctx.fillStyle = layer; + ctx.fillRect(0, 0, width, height); + } + } +} diff --git a/apps/web/src/lib/timeline-renderer.ts b/apps/web/src/lib/timeline-renderer.ts index ddc361b5..4d00129c 100644 --- a/apps/web/src/lib/timeline-renderer.ts +++ b/apps/web/src/lib/timeline-renderer.ts @@ -2,6 +2,7 @@ import type { TimelineTrack } from "@/types/timeline"; import type { MediaFile } from "@/types/media"; import type { BlurIntensity } from "@/types/project"; import { videoCache } from "./video-cache"; +import { drawCssBackground } from "./canvas-gradients"; export interface RenderContext { ctx: CanvasRenderingContext2D; @@ -48,11 +49,24 @@ export async function renderTimelineFrame({ }: RenderContext): Promise { // Background ctx.clearRect(0, 0, canvasWidth, canvasHeight); - if (backgroundColor && backgroundColor !== "transparent") { + if ( + backgroundColor && + backgroundColor !== "transparent" && + !backgroundColor.includes("gradient") + ) { ctx.fillStyle = backgroundColor; ctx.fillRect(0, 0, canvasWidth, canvasHeight); } + // If backgroundColor is a CSS gradient string, draw it using JS (no foreignObject) to avoid CORS tainting + if (backgroundColor && backgroundColor.includes("gradient")) { + try { + drawCssBackground(ctx, canvasWidth, canvasHeight, backgroundColor); + } catch { + // best-effort; ignore failures + } + } + const scaleX = projectCanvasSize ? canvasWidth / projectCanvasSize.width : 1; const scaleY = projectCanvasSize ? canvasHeight / projectCanvasSize.height From 46a74b30ba3a3ff9b499677bb5d63ea3befea56b Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Sun, 31 Aug 2025 14:43:25 +0200 Subject: [PATCH 09/29] update roadmap to check off export --- apps/web/src/app/roadmap/page.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/app/roadmap/page.tsx b/apps/web/src/app/roadmap/page.tsx index b26da5aa..b5397b63 100644 --- a/apps/web/src/app/roadmap/page.tsx +++ b/apps/web/src/app/roadmap/page.tsx @@ -47,8 +47,8 @@ const roadmapItems: { description: "The foundation that enables everything else. Real-time preview, video rendering, export functionality. Once this works, we can add effects, filters, transitions - basically everything that makes a video editor powerful.", status: { - text: "In Progress", - type: "pending", + text: "Completed", + type: "complete", }, }, { From 2c5d5dc23437e288d7e6936e0337583dc8dbc782 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Sun, 31 Aug 2025 19:57:50 +0200 Subject: [PATCH 10/29] bunch of small improvements --- .../src/components/editor/export-button.tsx | 291 ++++---- .../editor/media-panel/views/captions.tsx | 2 +- .../src/components/editor/panel-base-view.tsx | 5 +- .../src/components/editor/preview-panel.tsx | 7 +- apps/web/src/data/colors/syntax-ui.tsx | 64 +- apps/web/src/hooks/use-frame-cache.ts | 640 +++++++++--------- apps/web/src/lib/canvas-gradients.ts | 388 ++++++----- apps/web/src/lib/timeline-renderer.ts | 8 +- 8 files changed, 750 insertions(+), 655 deletions(-) diff --git a/apps/web/src/components/editor/export-button.tsx b/apps/web/src/components/editor/export-button.tsx index 41a5f65c..246e8b6f 100644 --- a/apps/web/src/components/editor/export-button.tsx +++ b/apps/web/src/components/editor/export-button.tsx @@ -16,7 +16,7 @@ import { DEFAULT_EXPORT_OPTIONS, } from "@/lib/export"; import { useProjectStore } from "@/stores/project-store"; -import { Download, X } from "lucide-react"; +import { Check, Copy, Download, RotateCcw, X } from "lucide-react"; import { ExportFormat, ExportQuality, ExportResult } from "@/types/export"; import { PropertyGroup } from "./properties-panel/property-item"; @@ -134,135 +134,184 @@ function ExportPopover({ return ( <> -
-

- {isExporting ? "Exporting project" : "Export project"} -

- -
+ {exportResult && !exportResult.success ? ( + + ) : ( + <> +
+

+ {isExporting ? "Exporting project" : "Export project"} +

+ +
-
- {!isExporting && ( - <> -
- - setFormat(value as ExportFormat)} - > -
- - -
-
- - -
-
-
+
+ {!isExporting && ( + <> +
+ + + setFormat(value as ExportFormat) + } + > +
+ + +
+
+ + +
+
+
- - - setQuality(value as ExportQuality) - } - > -
- - -
-
- - -
-
- - -
-
- - -
-
-
+ + + setQuality(value as ExportQuality) + } + > +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
- -
- setIncludeAudio(!!checked)} - /> - + +
+ + setIncludeAudio(!!checked) + } + /> + +
+
-
-
- - - )} + + + )} - {isExporting && ( -
-
-
-

- {Math.round(progress * 100)}% -

-

100%

+ {isExporting && ( +
+
+
+

+ {Math.round(progress * 100)}% +

+

100%

+
+ +
+ +
- -
- - + )}
- )} - - {exportResult && !exportResult.success && ( -
-
Export failed
-

- {exportResult.error || "Unknown error occurred"} -

- -
- )} -
+ + )} ); } + +function ExportError({ + error, + onRetry, +}: { + error: string; + onRetry: () => void; +}) { + const [copied, setCopied] = useState(false); + + const handleCopy = async () => { + await navigator.clipboard.writeText(error); + setCopied(true); + setTimeout(() => setCopied(false), 1000); + }; + + return ( +
+
+

Export failed

+

+ {error} +

+
+ +
+ + +
+
+ ); +} diff --git a/apps/web/src/components/editor/media-panel/views/captions.tsx b/apps/web/src/components/editor/media-panel/views/captions.tsx index 59403a8d..fbe5b418 100644 --- a/apps/web/src/components/editor/media-panel/views/captions.tsx +++ b/apps/web/src/components/editor/media-panel/views/captions.tsx @@ -188,7 +188,7 @@ export function Captions() { }; return ( - + -
{children}
+
{children}
); } @@ -40,7 +41,7 @@ export function PanelBaseView({ ref, }: PanelBaseViewProps) { return ( -
+
{!tabs || tabs.length === 0 ? ( {children} ) : ( diff --git a/apps/web/src/components/editor/preview-panel.tsx b/apps/web/src/components/editor/preview-panel.tsx index b7cc9578..8b1abd81 100644 --- a/apps/web/src/components/editor/preview-panel.tsx +++ b/apps/web/src/components/editor/preview-panel.tsx @@ -260,6 +260,7 @@ export function PreviewPanel() { }, []); const hasAnyElements = tracks.some((track) => track.elements.length > 0); + const shouldRenderPreview = hasAnyElements || activeProject?.backgroundType; const getActiveElements = (): ActiveElement[] => { const activeElements: ActiveElement[] = []; @@ -701,7 +702,7 @@ export function PreviewPanel() { className="flex-1 flex flex-col items-center justify-center min-h-0 min-w-0" >
- {hasAnyElements ? ( + {shouldRenderPreview ? (
{activeElements.length === 0 ? ( -
- No elements at current time -
+ <> ) : ( activeElements.map((elementData) => renderElement(elementData)) )} diff --git a/apps/web/src/data/colors/syntax-ui.tsx b/apps/web/src/data/colors/syntax-ui.tsx index 6a7677b4..50c7e7cc 100644 --- a/apps/web/src/data/colors/syntax-ui.tsx +++ b/apps/web/src/data/colors/syntax-ui.tsx @@ -1,32 +1,32 @@ -// These are the gradients from Syntax UI (https://syntaxui.com/effects/gradients) - -export const syntaxUIGradients = [ - // Cyan to Blue gradients - "linear-gradient(to right, #22d3ee, #0ea5e9, #0284c7)", - "linear-gradient(to right, #bfdbfe, #a5f3fc)", - "linear-gradient(to right, #22d3ee, #0ea5e9, #0284c7)", - - // Purple gradients - "linear-gradient(to right, #e9d5ff, #d8b4fe, #c084fc)", - "linear-gradient(to right, #c4b5fd, #a78bfa, #8b5cf6)", - - // Blue gradients - "linear-gradient(to right, #93c5fd, #60a5fa, #3b82f6)", - "linear-gradient(to right, #93c5fd, #60a5fa, #3b82f6)", - - // Green gradients - "linear-gradient(to right, #6ee7b7, #34d399, #10b981)", - "linear-gradient(to right, #d1fae5, #a7f3d0, #6ee7b7)", - - // Red gradient - "linear-gradient(to right, #fca5a5, #f87171, #ef4444)", - - // Yellow/Orange gradient - "linear-gradient(to right, #fde68a, #fbbf24, #f59e0b)", - - // Pink gradient - "linear-gradient(to right, #fbcfe8, #f9a8d4, #f472b6)", - - // Neon radial gradient - "radial-gradient(circle at bottom left, #ff00ff, #00ffff)", -]; +// These are the gradients from Syntax UI (https://syntaxui.com/effects/gradients) + +export const syntaxUIGradients = [ + // Cyan to Blue gradients + "linear-gradient(to right, #22d3ee, #0ea5e9, #0284c7)", + "linear-gradient(to right, #bfdbfe, #a5f3fc)", + "linear-gradient(to right, #22d3ee, #0ea5e9, #0284c7)", + + // Purple gradients + "linear-gradient(to right, #e9d5ff, #d8b4fe, #c084fc)", + "linear-gradient(to right, #c4b5fd, #a78bfa, #8b5cf6)", + + // Blue gradients + "linear-gradient(to right, #93c5fd, #60a5fa, #3b82f6)", + "linear-gradient(to right, #93c5fd, #60a5fa, #3b82f6)", + + // Green gradients + "linear-gradient(to right, #6ee7b7, #34d399, #10b981)", + "linear-gradient(to right, #d1fae5, #a7f3d0, #6ee7b7)", + + // Red gradient + "linear-gradient(to right, #fca5a5, #f87171, #ef4444)", + + // Yellow/Orange gradient + "linear-gradient(to right, #fde68a, #fbbf24, #f59e0b)", + + // Pink gradient + "linear-gradient(to right, #fbcfe8, #f9a8d4, #f472b6)", + + // Neon radial gradient + "radial-gradient(circle at bottom left, #ff00ff, #00ffff)", +]; diff --git a/apps/web/src/hooks/use-frame-cache.ts b/apps/web/src/hooks/use-frame-cache.ts index 8dc99966..2abfb912 100644 --- a/apps/web/src/hooks/use-frame-cache.ts +++ b/apps/web/src/hooks/use-frame-cache.ts @@ -1,320 +1,320 @@ -import { useRef, useCallback } from "react"; -import { - TimelineTrack, - TimelineElement, - MediaElement, - TextElement, -} from "@/types/timeline"; -import { MediaFile } from "@/types/media"; -import { TProject } from "@/types/project"; - -interface CachedFrame { - imageData: ImageData; - timelineHash: string; - timestamp: number; -} - -interface FrameCacheOptions { - maxCacheSize?: number; // Maximum number of cached frames - cacheResolution?: number; // Frames per second to cache at -} - -// Shared singleton cache across hook instances (HMR-safe) -// eslint-disable-next-line @typescript-eslint/no-explicit-any -const __frameCacheGlobal: any = globalThis as any; -const __sharedFrameCache: Map = - __frameCacheGlobal.__sharedFrameCache ?? new Map(); -__frameCacheGlobal.__sharedFrameCache = __sharedFrameCache; - -export function useFrameCache(options: FrameCacheOptions = {}) { - const { maxCacheSize = 300, cacheResolution = 30 } = options; // 10 seconds at 30fps - - const frameCacheRef = useRef(__sharedFrameCache); - - // Generate a hash of the timeline state that affects rendering - const getTimelineHash = useCallback( - ( - time: number, - tracks: TimelineTrack[], - mediaFiles: MediaFile[], - activeProject: TProject | null - ): string => { - // Get elements that are active at this time - const activeElements: Array<{ - id: string; - type: string; - startTime: number; - duration: number; - trimStart: number; - trimEnd: number; - mediaId?: string; - // Text-specific properties - content?: string; - fontSize?: number; - fontFamily?: string; - color?: string; - backgroundColor?: string; - x?: number; - y?: number; - rotation?: number; - opacity?: number; - }> = []; - - for (const track of tracks) { - if (track.muted) continue; - - for (const element of track.elements) { - // Check if element has hidden property (some elements might not have it) - const isHidden = "hidden" in element ? element.hidden : false; - if (isHidden) continue; - - const elementStart = element.startTime; - const elementEnd = - element.startTime + - (element.duration - element.trimStart - element.trimEnd); - - if (time >= elementStart && time < elementEnd) { - if (element.type === "media") { - const mediaElement = element as MediaElement; - activeElements.push({ - id: element.id, - type: element.type, - startTime: element.startTime, - duration: element.duration, - trimStart: element.trimStart, - trimEnd: element.trimEnd, - mediaId: mediaElement.mediaId, - }); - } else if (element.type === "text") { - const textElement = element as TextElement; - activeElements.push({ - id: element.id, - type: element.type, - startTime: element.startTime, - duration: element.duration, - trimStart: element.trimStart, - trimEnd: element.trimEnd, - content: textElement.content, - fontSize: textElement.fontSize, - fontFamily: textElement.fontFamily, - color: textElement.color, - backgroundColor: textElement.backgroundColor, - x: textElement.x, - y: textElement.y, - rotation: textElement.rotation, - opacity: textElement.opacity, - }); - } - } - } - } - - // Include project settings that affect rendering - const projectState = { - backgroundColor: activeProject?.backgroundColor, - backgroundType: activeProject?.backgroundType, - blurIntensity: activeProject?.blurIntensity, - canvasSize: activeProject?.canvasSize, - }; - - return JSON.stringify({ - activeElements, - projectState, - time: Math.floor(time * cacheResolution) / cacheResolution, // Quantize time - }); - }, - [cacheResolution] - ); - - // Check if a frame is cached and valid - const isFrameCached = useCallback( - ( - time: number, - tracks: TimelineTrack[], - mediaFiles: MediaFile[], - activeProject: TProject | null - ): boolean => { - const frameKey = Math.floor(time * cacheResolution); - const cached = frameCacheRef.current.get(frameKey); - - if (!cached) return false; - - const currentHash = getTimelineHash( - time, - tracks, - mediaFiles, - activeProject - ); - return cached.timelineHash === currentHash; - }, - [getTimelineHash, cacheResolution] - ); - - // Get cached frame if available and valid - const getCachedFrame = useCallback( - ( - time: number, - tracks: TimelineTrack[], - mediaFiles: MediaFile[], - activeProject: TProject | null - ): ImageData | null => { - const frameKey = Math.floor(time * cacheResolution); - const cached = frameCacheRef.current.get(frameKey); - - if (!cached) return null; - - const currentHash = getTimelineHash( - time, - tracks, - mediaFiles, - activeProject - ); - if (cached.timelineHash !== currentHash) { - // Cache is stale, remove it - frameCacheRef.current.delete(frameKey); - return null; - } - - return cached.imageData; - }, - [getTimelineHash, cacheResolution] - ); - - // Cache a rendered frame - const cacheFrame = useCallback( - ( - time: number, - imageData: ImageData, - tracks: TimelineTrack[], - mediaFiles: MediaFile[], - activeProject: TProject | null - ): void => { - const frameKey = Math.floor(time * cacheResolution); - const timelineHash = getTimelineHash( - time, - tracks, - mediaFiles, - activeProject - ); - - // Enforce cache size limit (LRU eviction) - if (frameCacheRef.current.size >= maxCacheSize) { - // Remove oldest entries - const entries = Array.from(frameCacheRef.current.entries()); - entries.sort((a, b) => a[1].timestamp - b[1].timestamp); - - // Remove oldest 20% of entries - const toRemove = Math.floor(entries.length * 0.2); - for (let i = 0; i < toRemove; i++) { - frameCacheRef.current.delete(entries[i][0]); - } - } - - frameCacheRef.current.set(frameKey, { - imageData, - timelineHash, - timestamp: Date.now(), - }); - }, - [getTimelineHash, cacheResolution, maxCacheSize] - ); - - // Clear cache when timeline changes significantly - const invalidateCache = useCallback(() => { - frameCacheRef.current.clear(); - }, []); - - // Get render status for timeline indicator - const getRenderStatus = useCallback( - ( - time: number, - tracks: TimelineTrack[], - mediaFiles: MediaFile[], - activeProject: TProject | null - ): "cached" | "not-cached" => { - return isFrameCached(time, tracks, mediaFiles, activeProject) - ? "cached" - : "not-cached"; - }, - [isFrameCached] - ); - - // Pre-render frames around current time - const preRenderNearbyFrames = useCallback( - async ( - currentTime: number, - tracks: TimelineTrack[], - mediaFiles: MediaFile[], - activeProject: TProject | null, - renderFunction: (time: number) => Promise, - range: number = 3 // seconds - ) => { - const framesToPreRender: number[] = []; - - // Calculate frames to pre-render (around current time) - for ( - let offset = -range; - offset <= range; - offset += 1 / cacheResolution - ) { - const time = currentTime + offset; - if (time < 0) continue; - - if (!isFrameCached(time, tracks, mediaFiles, activeProject)) { - framesToPreRender.push(time); - } - } - - // Expand to full 1-second buckets to avoid fragmented tiny cache regions - const secondsToPreRender = new Set(); - for (const t of framesToPreRender) { - secondsToPreRender.add(Math.floor(t)); - } - - const expandedTimes: number[] = []; - for (const s of secondsToPreRender) { - for (let k = 0; k < cacheResolution; k++) { - const t = s + k / cacheResolution; - if (t < 0) continue; - if (!isFrameCached(t, tracks, mediaFiles, activeProject)) { - expandedTimes.push(t); - } - } - } - - // Sort forward-first near currentTime to improve perceived responsiveness - expandedTimes.sort((a, b) => { - const da = a >= currentTime ? a - currentTime : currentTime - a + 1e6; - const db = b >= currentTime ? b - currentTime : currentTime - b + 1e6; - return da - db; - }); - - // Cap total scheduled renders to avoid jank (e.g., up to 90 frames) - const CAP = Math.max(30, Math.min(90, cacheResolution * 3)); - const toSchedule = expandedTimes.slice(0, CAP); - - // Pre-render during idle time - for (const time of toSchedule) { - requestIdleCallback(async () => { - try { - const imageData = await renderFunction(time); - cacheFrame(time, imageData, tracks, mediaFiles, activeProject); - } catch (error) { - console.warn(`Pre-render failed for time ${time}:`, error); - } - }); - } - }, - [isFrameCached, cacheFrame, cacheResolution] - ); - - return { - isFrameCached, - getCachedFrame, - cacheFrame, - invalidateCache, - getRenderStatus, - preRenderNearbyFrames, - cacheSize: frameCacheRef.current.size, - }; -} +import { useRef, useCallback } from "react"; +import { + TimelineTrack, + TimelineElement, + MediaElement, + TextElement, +} from "@/types/timeline"; +import { MediaFile } from "@/types/media"; +import { TProject } from "@/types/project"; + +interface CachedFrame { + imageData: ImageData; + timelineHash: string; + timestamp: number; +} + +interface FrameCacheOptions { + maxCacheSize?: number; // Maximum number of cached frames + cacheResolution?: number; // Frames per second to cache at +} + +// Shared singleton cache across hook instances (HMR-safe) +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const __frameCacheGlobal: any = globalThis as any; +const __sharedFrameCache: Map = + __frameCacheGlobal.__sharedFrameCache ?? new Map(); +__frameCacheGlobal.__sharedFrameCache = __sharedFrameCache; + +export function useFrameCache(options: FrameCacheOptions = {}) { + const { maxCacheSize = 300, cacheResolution = 30 } = options; // 10 seconds at 30fps + + const frameCacheRef = useRef(__sharedFrameCache); + + // Generate a hash of the timeline state that affects rendering + const getTimelineHash = useCallback( + ( + time: number, + tracks: TimelineTrack[], + mediaFiles: MediaFile[], + activeProject: TProject | null + ): string => { + // Get elements that are active at this time + const activeElements: Array<{ + id: string; + type: string; + startTime: number; + duration: number; + trimStart: number; + trimEnd: number; + mediaId?: string; + // Text-specific properties + content?: string; + fontSize?: number; + fontFamily?: string; + color?: string; + backgroundColor?: string; + x?: number; + y?: number; + rotation?: number; + opacity?: number; + }> = []; + + for (const track of tracks) { + if (track.muted) continue; + + for (const element of track.elements) { + // Check if element has hidden property (some elements might not have it) + const isHidden = "hidden" in element ? element.hidden : false; + if (isHidden) continue; + + const elementStart = element.startTime; + const elementEnd = + element.startTime + + (element.duration - element.trimStart - element.trimEnd); + + if (time >= elementStart && time < elementEnd) { + if (element.type === "media") { + const mediaElement = element as MediaElement; + activeElements.push({ + id: element.id, + type: element.type, + startTime: element.startTime, + duration: element.duration, + trimStart: element.trimStart, + trimEnd: element.trimEnd, + mediaId: mediaElement.mediaId, + }); + } else if (element.type === "text") { + const textElement = element as TextElement; + activeElements.push({ + id: element.id, + type: element.type, + startTime: element.startTime, + duration: element.duration, + trimStart: element.trimStart, + trimEnd: element.trimEnd, + content: textElement.content, + fontSize: textElement.fontSize, + fontFamily: textElement.fontFamily, + color: textElement.color, + backgroundColor: textElement.backgroundColor, + x: textElement.x, + y: textElement.y, + rotation: textElement.rotation, + opacity: textElement.opacity, + }); + } + } + } + } + + // Include project settings that affect rendering + const projectState = { + backgroundColor: activeProject?.backgroundColor, + backgroundType: activeProject?.backgroundType, + blurIntensity: activeProject?.blurIntensity, + canvasSize: activeProject?.canvasSize, + }; + + return JSON.stringify({ + activeElements, + projectState, + time: Math.floor(time * cacheResolution) / cacheResolution, // Quantize time + }); + }, + [cacheResolution] + ); + + // Check if a frame is cached and valid + const isFrameCached = useCallback( + ( + time: number, + tracks: TimelineTrack[], + mediaFiles: MediaFile[], + activeProject: TProject | null + ): boolean => { + const frameKey = Math.floor(time * cacheResolution); + const cached = frameCacheRef.current.get(frameKey); + + if (!cached) return false; + + const currentHash = getTimelineHash( + time, + tracks, + mediaFiles, + activeProject + ); + return cached.timelineHash === currentHash; + }, + [getTimelineHash, cacheResolution] + ); + + // Get cached frame if available and valid + const getCachedFrame = useCallback( + ( + time: number, + tracks: TimelineTrack[], + mediaFiles: MediaFile[], + activeProject: TProject | null + ): ImageData | null => { + const frameKey = Math.floor(time * cacheResolution); + const cached = frameCacheRef.current.get(frameKey); + + if (!cached) return null; + + const currentHash = getTimelineHash( + time, + tracks, + mediaFiles, + activeProject + ); + if (cached.timelineHash !== currentHash) { + // Cache is stale, remove it + frameCacheRef.current.delete(frameKey); + return null; + } + + return cached.imageData; + }, + [getTimelineHash, cacheResolution] + ); + + // Cache a rendered frame + const cacheFrame = useCallback( + ( + time: number, + imageData: ImageData, + tracks: TimelineTrack[], + mediaFiles: MediaFile[], + activeProject: TProject | null + ): void => { + const frameKey = Math.floor(time * cacheResolution); + const timelineHash = getTimelineHash( + time, + tracks, + mediaFiles, + activeProject + ); + + // Enforce cache size limit (LRU eviction) + if (frameCacheRef.current.size >= maxCacheSize) { + // Remove oldest entries + const entries = Array.from(frameCacheRef.current.entries()); + entries.sort((a, b) => a[1].timestamp - b[1].timestamp); + + // Remove oldest 20% of entries + const toRemove = Math.floor(entries.length * 0.2); + for (let i = 0; i < toRemove; i++) { + frameCacheRef.current.delete(entries[i][0]); + } + } + + frameCacheRef.current.set(frameKey, { + imageData, + timelineHash, + timestamp: Date.now(), + }); + }, + [getTimelineHash, cacheResolution, maxCacheSize] + ); + + // Clear cache when timeline changes significantly + const invalidateCache = useCallback(() => { + frameCacheRef.current.clear(); + }, []); + + // Get render status for timeline indicator + const getRenderStatus = useCallback( + ( + time: number, + tracks: TimelineTrack[], + mediaFiles: MediaFile[], + activeProject: TProject | null + ): "cached" | "not-cached" => { + return isFrameCached(time, tracks, mediaFiles, activeProject) + ? "cached" + : "not-cached"; + }, + [isFrameCached] + ); + + // Pre-render frames around current time + const preRenderNearbyFrames = useCallback( + async ( + currentTime: number, + tracks: TimelineTrack[], + mediaFiles: MediaFile[], + activeProject: TProject | null, + renderFunction: (time: number) => Promise, + range: number = 3 // seconds + ) => { + const framesToPreRender: number[] = []; + + // Calculate frames to pre-render (around current time) + for ( + let offset = -range; + offset <= range; + offset += 1 / cacheResolution + ) { + const time = currentTime + offset; + if (time < 0) continue; + + if (!isFrameCached(time, tracks, mediaFiles, activeProject)) { + framesToPreRender.push(time); + } + } + + // Expand to full 1-second buckets to avoid fragmented tiny cache regions + const secondsToPreRender = new Set(); + for (const t of framesToPreRender) { + secondsToPreRender.add(Math.floor(t)); + } + + const expandedTimes: number[] = []; + for (const s of secondsToPreRender) { + for (let k = 0; k < cacheResolution; k++) { + const t = s + k / cacheResolution; + if (t < 0) continue; + if (!isFrameCached(t, tracks, mediaFiles, activeProject)) { + expandedTimes.push(t); + } + } + } + + // Sort forward-first near currentTime to improve perceived responsiveness + expandedTimes.sort((a, b) => { + const da = a >= currentTime ? a - currentTime : currentTime - a + 1e6; + const db = b >= currentTime ? b - currentTime : currentTime - b + 1e6; + return da - db; + }); + + // Cap total scheduled renders to avoid jank (e.g., up to 90 frames) + const CAP = Math.max(30, Math.min(90, cacheResolution * 3)); + const toSchedule = expandedTimes.slice(0, CAP); + + // Pre-render during idle time + for (const time of toSchedule) { + requestIdleCallback(async () => { + try { + const imageData = await renderFunction(time); + cacheFrame(time, imageData, tracks, mediaFiles, activeProject); + } catch (error) { + console.warn(`Pre-render failed for time ${time}:`, error); + } + }); + } + }, + [isFrameCached, cacheFrame, cacheResolution] + ); + + return { + isFrameCached, + getCachedFrame, + cacheFrame, + invalidateCache, + getRenderStatus, + preRenderNearbyFrames, + cacheSize: frameCacheRef.current.size, + }; +} diff --git a/apps/web/src/lib/canvas-gradients.ts b/apps/web/src/lib/canvas-gradients.ts index a3b93c02..7d1cdc05 100644 --- a/apps/web/src/lib/canvas-gradients.ts +++ b/apps/web/src/lib/canvas-gradients.ts @@ -1,169 +1,219 @@ -function splitBackgroundLayers(input: string): string[] { - const layers: string[] = []; - let depth = 0; - let start = 0; - for (let i = 0; i < input.length; i += 1) { - const ch = input[i] as string; - if (ch === "(") depth += 1; - else if (ch === ")") depth -= 1; - else if (ch === "," && depth === 0) { - layers.push(input.slice(start, i).trim()); - start = i + 1; - } - } - layers.push(input.slice(start).trim()); - return layers; -} - -function extractColorFromStop(stop: string): string { - const s = stop.trim(); - const funcs = ["rgba(", "rgb(", "hsla(", "hsl("] as const; - for (const fn of funcs) { - if (s.startsWith(fn)) { - let depth = 0; - for (let i = 0; i < s.length; i += 1) { - const ch = s[i] as string; - if (ch === "(") depth += 1; - else if (ch === ")") { - depth -= 1; - if (depth === 0) { - return s.slice(0, i + 1); - } - } - } - return s; - } - } - const firstToken = s.split(" ")[0] as string; - return firstToken; -} - -function drawLinearGradient( - ctx: CanvasRenderingContext2D, - width: number, - height: number, - layer: string -): void { - const inside = layer.slice(layer.indexOf("(") + 1, layer.lastIndexOf(")")); - const parts = splitBackgroundLayers(inside); - const dir = (parts.shift() || "").trim(); - let x0 = 0, - y0 = 0, - x1 = width, - y1 = 0; - if (dir.endsWith("deg")) { - const deg = parseFloat(dir); - const rad = ((90 - deg) * Math.PI) / 180; - const cx = width / 2; - const cy = height / 2; - const r = Math.hypot(width, height) / 2; - x0 = cx - Math.cos(rad) * r; - y0 = cy - Math.sin(rad) * r; - x1 = cx + Math.cos(rad) * r; - y1 = cy + Math.sin(rad) * r; - } else if (dir.startsWith("to ")) { - const d = dir.slice(3).trim(); - if (d === "right") { - x0 = 0; - y0 = 0; - x1 = width; - y1 = 0; - } else if (d === "left") { - x0 = width; - y0 = 0; - x1 = 0; - y1 = 0; - } else if (d === "bottom") { - x0 = 0; - y0 = 0; - x1 = 0; - y1 = height; - } else if (d === "top") { - x0 = 0; - y0 = height; - x1 = 0; - y1 = 0; - } - } else { - parts.unshift(dir); - } - const grad = ctx.createLinearGradient(x0, y0, x1, y1); - const colorStops = parts; - const n = Math.max(1, colorStops.length - 1); - for (let i = 0; i < colorStops.length; i += 1) { - const color = extractColorFromStop(colorStops[i] as string); - grad.addColorStop(Math.min(1, Math.max(0, i / n)), color); - } - ctx.fillStyle = grad; - ctx.fillRect(0, 0, width, height); -} - -function drawRadialGradient( - ctx: CanvasRenderingContext2D, - width: number, - height: number, - layer: string -): void { - const inside = layer.slice(layer.indexOf("(") + 1, layer.lastIndexOf(")")); - const parts = splitBackgroundLayers(inside); - const first = (parts.shift() || "").trim(); - let cx = width / 2; - let cy = height / 2; - if (first.startsWith("circle at")) { - const pos = first.replace("circle at", "").trim(); - const [px, py] = pos.split(" "); - const parsePos = (p?: string, full?: number): number => { - if (!p) return (full || 0) / 2; - if (p.endsWith("%")) return (parseFloat(p) / 100) * (full || 0); - if (p === "left") return 0; - if (p === "right") return full || 0; - if (p === "top") return 0; - if (p === "bottom") return full || 0; - if (p === "center") return (full || 0) / 2; - return (full || 0) / 2; - }; - if (px && py) { - cx = parsePos(px, width); - cy = parsePos(py, height); - } else if (px) { - cx = parsePos(px, width); - cy = parsePos(px, height); - } - } else { - parts.unshift(first); - } - const r = Math.hypot(width, height); - const grad = ctx.createRadialGradient(cx, cy, 0, cx, cy, r); - const colorStops = parts; - const n = Math.max(1, colorStops.length - 1); - for (let i = 0; i < colorStops.length; i += 1) { - const color = extractColorFromStop(colorStops[i] as string); - grad.addColorStop(Math.min(1, Math.max(0, i / n)), color); - } - ctx.fillStyle = grad; - ctx.fillRect(0, 0, width, height); -} - -export function drawCssBackground( - ctx: CanvasRenderingContext2D, - width: number, - height: number, - css: string -): void { - const layers = splitBackgroundLayers(css).filter(Boolean); - for (let i = layers.length - 1; i >= 0; i -= 1) { - const layer = layers[i] as string; - if (layer.startsWith("linear-gradient(")) { - drawLinearGradient(ctx, width, height, layer); - } else if (layer.startsWith("radial-gradient(")) { - drawRadialGradient(ctx, width, height, layer); - } else if ( - layer.startsWith("#") || - layer.startsWith("rgb") || - layer.startsWith("hsl") - ) { - ctx.fillStyle = layer; - ctx.fillRect(0, 0, width, height); - } - } -} +function splitBackgroundLayers(input: string): string[] { + const layers: string[] = []; + let depth = 0; + let start = 0; + for (let i = 0; i < input.length; i += 1) { + const ch = input[i] as string; + if (ch === "(") depth += 1; + else if (ch === ")") depth -= 1; + else if (ch === "," && depth === 0) { + layers.push(input.slice(start, i).trim()); + start = i + 1; + } + } + layers.push(input.slice(start).trim()); + return layers; +} + +function parseColorStop(stop: string): { color: string; position?: number } { + const s = stop.trim(); + + // Handle functional colors like rgba(), rgb(), hsla(), hsl() + const colorFunctions = ["rgba(", "rgb(", "hsla(", "hsl("]; + let color = ""; + let remaining = ""; + + for (const fn of colorFunctions) { + if (s.startsWith(fn)) { + let depth = 0; + for (let i = 0; i < s.length; i += 1) { + const ch = s[i] as string; + if (ch === "(") depth += 1; + else if (ch === ")") { + depth -= 1; + if (depth === 0) { + color = s.slice(0, i + 1); + remaining = s.slice(i + 1).trim(); + break; + } + } + } + break; + } + } + + if (!color) { + const parts = s.split(/\s+/); + color = parts[0] as string; + remaining = parts.slice(1).join(" "); + } + + // Convert transparent to transparent white for better blending + if (color === "transparent") { + color = "rgba(255, 255, 255, 0)"; + } + + // Parse position if present + let position: number | undefined; + if (remaining) { + const posMatch = remaining.match(/(\d+(?:\.\d+)?)%/); + if (posMatch) { + position = parseFloat(posMatch[1] as string) / 100; + } + } + + return { color, position }; +} + +function parseLinearGradient(layer: string, width: number, height: number) { + const inside = layer.slice(layer.indexOf("(") + 1, layer.lastIndexOf(")")); + const parts = splitBackgroundLayers(inside); + const dir = (parts.shift() || "").trim(); + + let x0 = 0, + y0 = 0, + x1 = width, + y1 = 0; // default: to right + + if (dir.endsWith("deg")) { + const deg = parseFloat(dir); + const rad = (deg * Math.PI) / 180; + const cx = width / 2; + const cy = height / 2; + const r = Math.hypot(width, height) / 2; + x0 = cx - Math.cos(rad) * r; + y0 = cy - Math.sin(rad) * r; + x1 = cx + Math.cos(rad) * r; + y1 = cy + Math.sin(rad) * r; + } else if (dir.startsWith("to ")) { + const d = dir.slice(3).trim(); + if (d === "right") { + x0 = 0; + y0 = 0; + x1 = width; + y1 = 0; + } else if (d === "left") { + x0 = width; + y0 = 0; + x1 = 0; + y1 = 0; + } else if (d === "bottom") { + x0 = 0; + y0 = 0; + x1 = 0; + y1 = height; + } else if (d === "top") { + x0 = 0; + y0 = height; + x1 = 0; + y1 = 0; + } + } else { + // No direction specified, treat as first color + parts.unshift(dir); + } + + return { x0, y0, x1, y1, colors: parts }; +} + +function parseRadialGradient(layer: string, width: number, height: number) { + const inside = layer.slice(layer.indexOf("(") + 1, layer.lastIndexOf(")")); + const parts = splitBackgroundLayers(inside); + const first = (parts.shift() || "").trim(); + + let cx = width / 2; + let cy = height / 2; + + if (first.startsWith("circle at")) { + const pos = first.replace("circle at", "").trim(); + const coords = pos.split(/\s+/); + + for (let i = 0; i < coords.length; i += 1) { + const coord = coords[i] as string; + if (coord.endsWith("%")) { + const val = parseFloat(coord) / 100; + if (i === 0) cx = val * width; + else if (i === 1) cy = val * height; + } else if (coord === "left") cx = 0; + else if (coord === "right") cx = width; + else if (coord === "top") cy = 0; + else if (coord === "bottom") cy = height; + else if (coord === "center") { + if (i === 0) cx = width / 2; + else if (i === 1) cy = height / 2; + } + } + } else { + parts.unshift(first); + } + + // Use farthest-corner for radius + const r = Math.max( + Math.hypot(cx, cy), + Math.hypot(width - cx, cy), + Math.hypot(cx, height - cy), + Math.hypot(width - cx, height - cy) + ); + + return { cx, cy, r, colors: parts }; +} + +export function drawCssBackground( + ctx: CanvasRenderingContext2D, + width: number, + height: number, + css: string +): void { + const layers = splitBackgroundLayers(css).filter(Boolean); + + // Draw layers from last to first (CSS background order) + for (let i = layers.length - 1; i >= 0; i -= 1) { + const layer = layers[i] as string; + + if (layer.startsWith("linear-gradient(")) { + const { x0, y0, x1, y1, colors } = parseLinearGradient( + layer, + width, + height + ); + const grad = ctx.createLinearGradient(x0, y0, x1, y1); + const colorStops = colors.map((c) => parseColorStop(c as string)); + + // Handle positions properly + for (let j = 0; j < colorStops.length; j += 1) { + const stop = colorStops[j] as { color: string; position?: number }; + const pos = stop.position ?? j / Math.max(1, colorStops.length - 1); + grad.addColorStop(Math.max(0, Math.min(1, pos)), stop.color); + } + + ctx.fillStyle = grad; + ctx.fillRect(0, 0, width, height); + } else if (layer.startsWith("radial-gradient(")) { + const { cx, cy, r, colors } = parseRadialGradient(layer, width, height); + const grad = ctx.createRadialGradient(cx, cy, 0, cx, cy, r); + const colorStops = colors.map((c) => parseColorStop(c as string)); + + // Handle positions properly + for (let j = 0; j < colorStops.length; j += 1) { + const stop = colorStops[j] as { color: string; position?: number }; + const pos = stop.position ?? j / Math.max(1, colorStops.length - 1); + grad.addColorStop(Math.max(0, Math.min(1, pos)), stop.color); + } + + ctx.fillStyle = grad; + ctx.fillRect(0, 0, width, height); + } else if ( + layer.startsWith("#") || + layer.startsWith("rgb") || + layer.startsWith("hsl") || + layer === "transparent" || + layer === "white" || + layer === "black" + ) { + if (layer !== "transparent") { + ctx.fillStyle = layer; + ctx.fillRect(0, 0, width, height); + } + } + } +} diff --git a/apps/web/src/lib/timeline-renderer.ts b/apps/web/src/lib/timeline-renderer.ts index 4d00129c..b2650b7c 100644 --- a/apps/web/src/lib/timeline-renderer.ts +++ b/apps/web/src/lib/timeline-renderer.ts @@ -58,13 +58,9 @@ export async function renderTimelineFrame({ ctx.fillRect(0, 0, canvasWidth, canvasHeight); } - // If backgroundColor is a CSS gradient string, draw it using JS (no foreignObject) to avoid CORS tainting + // If backgroundColor is a CSS gradient string, draw it if (backgroundColor && backgroundColor.includes("gradient")) { - try { - drawCssBackground(ctx, canvasWidth, canvasHeight, backgroundColor); - } catch { - // best-effort; ignore failures - } + drawCssBackground(ctx, canvasWidth, canvasHeight, backgroundColor); } const scaleX = projectCanvasSize ? canvasWidth / projectCanvasSize.width : 1; From 8eabbeae7c4c24bf8369deb336eaf38f4967f285 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Sun, 31 Aug 2025 21:51:31 +0200 Subject: [PATCH 11/29] more changes --- .../components/editor/media-panel/tabbar.tsx | 52 ++++++++++++++++++- .../editor/media-panel/views/settings.tsx | 38 ++++++++++++-- .../src/components/editor/panel-base-view.tsx | 2 +- 3 files changed, 84 insertions(+), 8 deletions(-) diff --git a/apps/web/src/components/editor/media-panel/tabbar.tsx b/apps/web/src/components/editor/media-panel/tabbar.tsx index 56437508..e2bfdff7 100644 --- a/apps/web/src/components/editor/media-panel/tabbar.tsx +++ b/apps/web/src/components/editor/media-panel/tabbar.tsx @@ -7,13 +7,45 @@ import { TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; +import { useEffect, useRef, useState } from "react"; export function TabBar() { const { activeTab, setActiveTab } = useMediaPanelStore(); + const scrollRef = useRef(null); + const [showTopFade, setShowTopFade] = useState(false); + const [showBottomFade, setShowBottomFade] = useState(false); + + const checkScrollPosition = () => { + const element = scrollRef.current; + if (!element) return; + + const { scrollTop, scrollHeight, clientHeight } = element; + setShowTopFade(scrollTop > 0); + setShowBottomFade(scrollTop < scrollHeight - clientHeight - 1); + }; + + useEffect(() => { + const element = scrollRef.current; + if (!element) return; + + checkScrollPosition(); + element.addEventListener("scroll", checkScrollPosition); + + const resizeObserver = new ResizeObserver(checkScrollPosition); + resizeObserver.observe(element); + + return () => { + element.removeEventListener("scroll", checkScrollPosition); + resizeObserver.disconnect(); + }; + }, []); return ( -
-
+
+
{(Object.keys(tabs) as Tab[]).map((tabKey) => { const tab = tabs[tabKey]; return ( @@ -46,6 +78,22 @@ export function TabBar() { ); })}
+ + +
); } + +function FadeOverlay({ direction, show }: { direction: "top" | "bottom", show: boolean }) { + return ( +
+ ); +} \ No newline at end of file diff --git a/apps/web/src/components/editor/media-panel/views/settings.tsx b/apps/web/src/components/editor/media-panel/views/settings.tsx index d2d21f36..be54a9c4 100644 --- a/apps/web/src/components/editor/media-panel/views/settings.tsx +++ b/apps/web/src/components/editor/media-panel/views/settings.tsx @@ -23,9 +23,11 @@ import Image from "next/image"; import { cn } from "@/lib/utils"; import { colors } from "@/data/colors/solid"; import { patternCraftGradients } from "@/data/colors/pattern-craft"; -import { PipetteIcon } from "lucide-react"; +import { PipetteIcon, PlusIcon } from "lucide-react"; import { useMemo, memo, useCallback } from "react"; import { syntaxUIGradients } from "@/data/colors/syntax-ui"; +import { Button } from "@/components/ui/button"; +import { Separator } from "@/components/ui/separator"; export function SettingsView() { return ; @@ -39,14 +41,40 @@ function ProjectSettingsTabs() { { value: "project-info", label: "Project info", - content: , + content: ( +
+ +
+ ), }, { value: "background", label: "Background", - content: , + content: ( +
+
+ +
+
+ + +
+ + {/* Another UI, looks so beautiful i don't wanna remove it */} + {/*
+ +
*/} +
+ ), }, ]} + className="flex flex-col justify-between h-full p-0" /> ); } @@ -258,7 +286,7 @@ function BackgroundView() { ); return ( -
+
{blurPreviews}
@@ -278,7 +306,7 @@ function BackgroundView() {
- +
- {tab.content} + {tab.content} ))} From 6e32d4aed56a605c42e77e5f4b931d567dbed7a7 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Sun, 31 Aug 2025 21:54:42 +0200 Subject: [PATCH 12/29] refactor: move timeline toolbar to separate file --- .../src/components/editor/timeline/index.tsx | 381 +--------------- .../editor/timeline/timeline-toolbar.tsx | 413 ++++++++++++++++++ 2 files changed, 414 insertions(+), 380 deletions(-) create mode 100644 apps/web/src/components/editor/timeline/timeline-toolbar.tsx diff --git a/apps/web/src/components/editor/timeline/index.tsx b/apps/web/src/components/editor/timeline/index.tsx index 90a19d61..177de96d 100644 --- a/apps/web/src/components/editor/timeline/index.tsx +++ b/apps/web/src/components/editor/timeline/index.tsx @@ -70,6 +70,7 @@ import { import { Slider } from "@/components/ui/slider"; import { formatTimeCode } from "@/lib/time"; import { EditableTimecode } from "@/components/ui/editable-timecode"; +import { TimelineToolbar } from "./timeline-toolbar"; export function Timeline() { // Timeline shows all tracks (video, audio, effects) and their elements. @@ -922,383 +923,3 @@ function TrackIcon({ track }: { track: TimelineTrack }) { ); } - -function TimelineToolbar({ - zoomLevel, - setZoomLevel, -}: { - zoomLevel: number; - setZoomLevel: (zoom: number) => void; -}) { - const { - tracks, - addTrack, - addElementToTrack, - removeElementFromTrack, - removeElementFromTrackWithRipple, - selectedElements, - clearSelectedElements, - splitElement, - splitAndKeepLeft, - splitAndKeepRight, - separateAudio, - snappingEnabled, - toggleSnapping, - rippleEditingEnabled, - toggleRippleEditing, - } = useTimelineStore(); - const { currentTime, duration, isPlaying, toggle, seek } = usePlaybackStore(); - const { toggleBookmark, isBookmarked, activeProject } = useProjectStore(); - - const handleSplitSelected = () => { - if (selectedElements.length === 0) return; - let splitCount = 0; - selectedElements.forEach(({ trackId, elementId }) => { - const track = tracks.find((t) => t.id === trackId); - const element = track?.elements.find((c) => c.id === elementId); - if (element && track) { - const effectiveStart = element.startTime; - const effectiveEnd = - element.startTime + - (element.duration - element.trimStart - element.trimEnd); - if (currentTime > effectiveStart && currentTime < effectiveEnd) { - const newElementId = splitElement(trackId, elementId, currentTime); - if (newElementId) splitCount++; - } - } - }); - if (splitCount === 0) { - toast.error("Playhead must be within selected elements to split"); - } - }; - - const handleDuplicateSelected = () => { - if (selectedElements.length === 0) return; - const canDuplicate = selectedElements.length === 1; - if (!canDuplicate) return; - - selectedElements.forEach(({ trackId, elementId }) => { - const track = tracks.find((t) => t.id === trackId); - const element = track?.elements.find((el) => el.id === elementId); - if (element) { - const newStartTime = - element.startTime + - (element.duration - element.trimStart - element.trimEnd) + - 0.1; - const { id, ...elementWithoutId } = element; - addElementToTrack(trackId, { - ...elementWithoutId, - startTime: newStartTime, - }); - } - }); - clearSelectedElements(); - }; - - const handleFreezeSelected = () => { - toast.info("Freeze frame functionality coming soon!"); - }; - - const handleSplitAndKeepLeft = () => { - if (selectedElements.length !== 1) { - toast.error("Select exactly one element"); - return; - } - const { trackId, elementId } = selectedElements[0]; - const track = tracks.find((t) => t.id === trackId); - const element = track?.elements.find((c) => c.id === elementId); - if (!element) return; - const effectiveStart = element.startTime; - const effectiveEnd = - element.startTime + - (element.duration - element.trimStart - element.trimEnd); - if (currentTime <= effectiveStart || currentTime >= effectiveEnd) { - toast.error("Playhead must be within selected element"); - return; - } - splitAndKeepLeft(trackId, elementId, currentTime); - }; - - const handleSplitAndKeepRight = () => { - if (selectedElements.length !== 1) { - toast.error("Select exactly one element"); - return; - } - const { trackId, elementId } = selectedElements[0]; - const track = tracks.find((t) => t.id === trackId); - const element = track?.elements.find((c) => c.id === elementId); - if (!element) return; - const effectiveStart = element.startTime; - const effectiveEnd = - element.startTime + - (element.duration - element.trimStart - element.trimEnd); - if (currentTime <= effectiveStart || currentTime >= effectiveEnd) { - toast.error("Playhead must be within selected element"); - return; - } - splitAndKeepRight(trackId, elementId, currentTime); - }; - - const handleSeparateAudio = () => { - if (selectedElements.length !== 1) { - toast.error("Select exactly one media element to separate audio"); - return; - } - const { trackId, elementId } = selectedElements[0]; - const track = tracks.find((t) => t.id === trackId); - if (!track || track.type !== "media") { - toast.error("Select a media element to separate audio"); - return; - } - separateAudio(trackId, elementId); - }; - - const handleDeleteSelected = () => { - if (selectedElements.length === 0) return; - selectedElements.forEach(({ trackId, elementId }) => { - if (rippleEditingEnabled) { - removeElementFromTrackWithRipple(trackId, elementId); - } else { - removeElementFromTrack(trackId, elementId); - } - }); - clearSelectedElements(); - }; - - const handleZoomIn = () => { - setZoomLevel(Math.min(4, zoomLevel + 0.25)); - }; - - const handleZoomOut = () => { - setZoomLevel(Math.max(0.25, zoomLevel - 0.25)); - }; - - const handleZoomSliderChange = (values: number[]) => { - setZoomLevel(values[0]); - }; - - const handleToggleBookmark = async () => { - await toggleBookmark(currentTime); - }; - - const currentBookmarked = isBookmarked(currentTime); - return ( -
-
- - - - - - - {isPlaying ? "Pause (Space)" : "Play (Space)"} - - - - - - - Return to Start (Home / Enter) - -
- {/* Time Display */} -
- -
- / -
-
- {formatTimeCode(duration, "HH:MM:SS:FF")} -
-
- {tracks.length === 0 && ( - <> -
- - - - - Add a test clip to try playback - - - )} -
- - - - - Split element (Ctrl+S) - - - - - - Split and keep left (Ctrl+Q) - - - - - - Split and keep right (Ctrl+W) - - - - - - Separate audio (Ctrl+D) - - - - - - Duplicate element (Ctrl+D) - - - - - - Freeze frame (F) - - - - - - Delete element (Delete) - -
- - - - - - {currentBookmarked ? "Remove bookmark" : "Add bookmark"} - - - -
-
- - - - - - Auto snapping - - - - - - - {rippleEditingEnabled - ? "Disable Ripple Editing" - : "Enable Ripple Editing"} - - - - -
-
- - - -
-
-
- ); -} diff --git a/apps/web/src/components/editor/timeline/timeline-toolbar.tsx b/apps/web/src/components/editor/timeline/timeline-toolbar.tsx new file mode 100644 index 00000000..6548e71b --- /dev/null +++ b/apps/web/src/components/editor/timeline/timeline-toolbar.tsx @@ -0,0 +1,413 @@ +import { usePlaybackStore } from "@/stores/playback-store"; +import { useProjectStore } from "@/stores/project-store"; +import { useTimelineStore } from "@/stores/timeline-store"; +import { toast } from "sonner"; +import { + TooltipProvider, + Tooltip, + TooltipTrigger, + TooltipContent, +} from "@/components/ui/tooltip"; +import { Button } from "@/components/ui/button"; +import { + Pause, + Play, + SkipBack, + Bookmark, + Magnet, + Link, + ZoomOut, + ZoomIn, + Copy, + Trash2, + Snowflake, + ArrowLeftToLine, + ArrowRightToLine, + SplitSquareHorizontal, + Scissors, +} from "lucide-react"; +import { Slider } from "@/components/ui/slider"; +import { DEFAULT_FPS } from "@/stores/project-store"; +import { formatTimeCode } from "@/lib/time"; +import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; +import { EditableTimecode } from "@/components/ui/editable-timecode"; + +export function TimelineToolbar({ + zoomLevel, + setZoomLevel, +}: { + zoomLevel: number; + setZoomLevel: (zoom: number) => void; +}) { + const { + tracks, + addTrack, + addElementToTrack, + removeElementFromTrack, + removeElementFromTrackWithRipple, + selectedElements, + clearSelectedElements, + splitElement, + splitAndKeepLeft, + splitAndKeepRight, + separateAudio, + snappingEnabled, + toggleSnapping, + rippleEditingEnabled, + toggleRippleEditing, + } = useTimelineStore(); + const { currentTime, duration, isPlaying, toggle, seek } = usePlaybackStore(); + const { toggleBookmark, isBookmarked, activeProject } = useProjectStore(); + + const handleSplitSelected = () => { + if (selectedElements.length === 0) return; + let splitCount = 0; + selectedElements.forEach(({ trackId, elementId }) => { + const track = tracks.find((t) => t.id === trackId); + const element = track?.elements.find((c) => c.id === elementId); + if (element && track) { + const effectiveStart = element.startTime; + const effectiveEnd = + element.startTime + + (element.duration - element.trimStart - element.trimEnd); + if (currentTime > effectiveStart && currentTime < effectiveEnd) { + const newElementId = splitElement(trackId, elementId, currentTime); + if (newElementId) splitCount++; + } + } + }); + if (splitCount === 0) { + toast.error("Playhead must be within selected elements to split"); + } + }; + + const handleDuplicateSelected = () => { + if (selectedElements.length === 0) return; + const canDuplicate = selectedElements.length === 1; + if (!canDuplicate) return; + + selectedElements.forEach(({ trackId, elementId }) => { + const track = tracks.find((t) => t.id === trackId); + const element = track?.elements.find((el) => el.id === elementId); + if (element) { + const newStartTime = + element.startTime + + (element.duration - element.trimStart - element.trimEnd) + + 0.1; + const { id, ...elementWithoutId } = element; + addElementToTrack(trackId, { + ...elementWithoutId, + startTime: newStartTime, + }); + } + }); + clearSelectedElements(); + }; + + const handleFreezeSelected = () => { + toast.info("Freeze frame functionality coming soon!"); + }; + + const handleSplitAndKeepLeft = () => { + if (selectedElements.length !== 1) { + toast.error("Select exactly one element"); + return; + } + const { trackId, elementId } = selectedElements[0]; + const track = tracks.find((t) => t.id === trackId); + const element = track?.elements.find((c) => c.id === elementId); + if (!element) return; + const effectiveStart = element.startTime; + const effectiveEnd = + element.startTime + + (element.duration - element.trimStart - element.trimEnd); + if (currentTime <= effectiveStart || currentTime >= effectiveEnd) { + toast.error("Playhead must be within selected element"); + return; + } + splitAndKeepLeft(trackId, elementId, currentTime); + }; + + const handleSplitAndKeepRight = () => { + if (selectedElements.length !== 1) { + toast.error("Select exactly one element"); + return; + } + const { trackId, elementId } = selectedElements[0]; + const track = tracks.find((t) => t.id === trackId); + const element = track?.elements.find((c) => c.id === elementId); + if (!element) return; + const effectiveStart = element.startTime; + const effectiveEnd = + element.startTime + + (element.duration - element.trimStart - element.trimEnd); + if (currentTime <= effectiveStart || currentTime >= effectiveEnd) { + toast.error("Playhead must be within selected element"); + return; + } + splitAndKeepRight(trackId, elementId, currentTime); + }; + + const handleSeparateAudio = () => { + if (selectedElements.length !== 1) { + toast.error("Select exactly one media element to separate audio"); + return; + } + const { trackId, elementId } = selectedElements[0]; + const track = tracks.find((t) => t.id === trackId); + if (!track || track.type !== "media") { + toast.error("Select a media element to separate audio"); + return; + } + separateAudio(trackId, elementId); + }; + + const handleDeleteSelected = () => { + if (selectedElements.length === 0) return; + selectedElements.forEach(({ trackId, elementId }) => { + if (rippleEditingEnabled) { + removeElementFromTrackWithRipple(trackId, elementId); + } else { + removeElementFromTrack(trackId, elementId); + } + }); + clearSelectedElements(); + }; + + const handleZoomIn = () => { + setZoomLevel(Math.min(4, zoomLevel + 0.25)); + }; + + const handleZoomOut = () => { + setZoomLevel(Math.max(0.25, zoomLevel - 0.25)); + }; + + const handleZoomSliderChange = (values: number[]) => { + setZoomLevel(values[0]); + }; + + const handleToggleBookmark = async () => { + await toggleBookmark(currentTime); + }; + + const currentBookmarked = isBookmarked(currentTime); + return ( +
+
+ + + + + + + {isPlaying ? "Pause (Space)" : "Play (Space)"} + + + + + + + Return to Start (Home / Enter) + +
+ {/* Time Display */} +
+ +
+ / +
+
+ {formatTimeCode(duration, "HH:MM:SS:FF")} +
+
+ {tracks.length === 0 && ( + <> +
+ + + + + Add a test clip to try playback + + + )} +
+ + + + + Split element (Ctrl+S) + + + + + + Split and keep left (Ctrl+Q) + + + + + + Split and keep right (Ctrl+W) + + + + + + Separate audio (Ctrl+D) + + + + + + Duplicate element (Ctrl+D) + + + + + + Freeze frame (F) + + + + + + Delete element (Delete) + +
+ + + + + + {currentBookmarked ? "Remove bookmark" : "Add bookmark"} + + + +
+
+ + + + + + Auto snapping + + + + + + + {rippleEditingEnabled + ? "Disable Ripple Editing" + : "Enable Ripple Editing"} + + + + +
+
+ + + +
+
+
+ ); +} From 45d0caf979d09f7aa576a753e8125a6cdc60e1a9 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Sun, 31 Aug 2025 21:55:36 +0200 Subject: [PATCH 13/29] style: spacing --- apps/web/src/components/editor/timeline/timeline-toolbar.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/apps/web/src/components/editor/timeline/timeline-toolbar.tsx b/apps/web/src/components/editor/timeline/timeline-toolbar.tsx index 6548e71b..fd99222b 100644 --- a/apps/web/src/components/editor/timeline/timeline-toolbar.tsx +++ b/apps/web/src/components/editor/timeline/timeline-toolbar.tsx @@ -201,7 +201,6 @@ export function TimelineToolbar({ variant="text" size="icon" onClick={toggle} - className="mr-2" > {isPlaying ? ( @@ -220,7 +219,6 @@ export function TimelineToolbar({ variant="text" size="icon" onClick={() => seek(0)} - className="mr-2" > From e43d79e4c53a2e2ed57cc20ad013fba120b631bf Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Sun, 31 Aug 2025 22:39:20 +0200 Subject: [PATCH 14/29] style: fix timeline toolbar border to extend full width --- .../src/components/editor/timeline/timeline-cache-indicator.tsx | 2 +- apps/web/src/components/editor/timeline/timeline-toolbar.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/editor/timeline/timeline-cache-indicator.tsx b/apps/web/src/components/editor/timeline/timeline-cache-indicator.tsx index f6494738..ef667d67 100644 --- a/apps/web/src/components/editor/timeline/timeline-cache-indicator.tsx +++ b/apps/web/src/components/editor/timeline/timeline-cache-indicator.tsx @@ -97,7 +97,7 @@ export function TimelineCacheIndicator({ key={index} className={cn( "absolute top-0 h-px", - segment.cached ? "bg-primary" : "bg-border" + segment.cached ? "bg-primary" : "bg-transparent" )} style={{ left: `${startX}px`, diff --git a/apps/web/src/components/editor/timeline/timeline-toolbar.tsx b/apps/web/src/components/editor/timeline/timeline-toolbar.tsx index fd99222b..1723dc11 100644 --- a/apps/web/src/components/editor/timeline/timeline-toolbar.tsx +++ b/apps/web/src/components/editor/timeline/timeline-toolbar.tsx @@ -192,7 +192,7 @@ export function TimelineToolbar({ const currentBookmarked = isBookmarked(currentTime); return ( -
+
From 2b700b7474ffc728f5318c178244e035c93716b5 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Sun, 31 Aug 2025 23:25:26 +0200 Subject: [PATCH 15/29] feat: scenes button to timeline toolbar --- .../editor/timeline/timeline-toolbar.tsx | 830 +++++++++--------- apps/web/src/components/ui/split-button.tsx | 90 ++ 2 files changed, 509 insertions(+), 411 deletions(-) create mode 100644 apps/web/src/components/ui/split-button.tsx diff --git a/apps/web/src/components/editor/timeline/timeline-toolbar.tsx b/apps/web/src/components/editor/timeline/timeline-toolbar.tsx index 1723dc11..cc9190e2 100644 --- a/apps/web/src/components/editor/timeline/timeline-toolbar.tsx +++ b/apps/web/src/components/editor/timeline/timeline-toolbar.tsx @@ -1,411 +1,419 @@ -import { usePlaybackStore } from "@/stores/playback-store"; -import { useProjectStore } from "@/stores/project-store"; -import { useTimelineStore } from "@/stores/timeline-store"; -import { toast } from "sonner"; -import { - TooltipProvider, - Tooltip, - TooltipTrigger, - TooltipContent, -} from "@/components/ui/tooltip"; -import { Button } from "@/components/ui/button"; -import { - Pause, - Play, - SkipBack, - Bookmark, - Magnet, - Link, - ZoomOut, - ZoomIn, - Copy, - Trash2, - Snowflake, - ArrowLeftToLine, - ArrowRightToLine, - SplitSquareHorizontal, - Scissors, -} from "lucide-react"; -import { Slider } from "@/components/ui/slider"; -import { DEFAULT_FPS } from "@/stores/project-store"; -import { formatTimeCode } from "@/lib/time"; -import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; -import { EditableTimecode } from "@/components/ui/editable-timecode"; - -export function TimelineToolbar({ - zoomLevel, - setZoomLevel, -}: { - zoomLevel: number; - setZoomLevel: (zoom: number) => void; -}) { - const { - tracks, - addTrack, - addElementToTrack, - removeElementFromTrack, - removeElementFromTrackWithRipple, - selectedElements, - clearSelectedElements, - splitElement, - splitAndKeepLeft, - splitAndKeepRight, - separateAudio, - snappingEnabled, - toggleSnapping, - rippleEditingEnabled, - toggleRippleEditing, - } = useTimelineStore(); - const { currentTime, duration, isPlaying, toggle, seek } = usePlaybackStore(); - const { toggleBookmark, isBookmarked, activeProject } = useProjectStore(); - - const handleSplitSelected = () => { - if (selectedElements.length === 0) return; - let splitCount = 0; - selectedElements.forEach(({ trackId, elementId }) => { - const track = tracks.find((t) => t.id === trackId); - const element = track?.elements.find((c) => c.id === elementId); - if (element && track) { - const effectiveStart = element.startTime; - const effectiveEnd = - element.startTime + - (element.duration - element.trimStart - element.trimEnd); - if (currentTime > effectiveStart && currentTime < effectiveEnd) { - const newElementId = splitElement(trackId, elementId, currentTime); - if (newElementId) splitCount++; - } - } - }); - if (splitCount === 0) { - toast.error("Playhead must be within selected elements to split"); - } - }; - - const handleDuplicateSelected = () => { - if (selectedElements.length === 0) return; - const canDuplicate = selectedElements.length === 1; - if (!canDuplicate) return; - - selectedElements.forEach(({ trackId, elementId }) => { - const track = tracks.find((t) => t.id === trackId); - const element = track?.elements.find((el) => el.id === elementId); - if (element) { - const newStartTime = - element.startTime + - (element.duration - element.trimStart - element.trimEnd) + - 0.1; - const { id, ...elementWithoutId } = element; - addElementToTrack(trackId, { - ...elementWithoutId, - startTime: newStartTime, - }); - } - }); - clearSelectedElements(); - }; - - const handleFreezeSelected = () => { - toast.info("Freeze frame functionality coming soon!"); - }; - - const handleSplitAndKeepLeft = () => { - if (selectedElements.length !== 1) { - toast.error("Select exactly one element"); - return; - } - const { trackId, elementId } = selectedElements[0]; - const track = tracks.find((t) => t.id === trackId); - const element = track?.elements.find((c) => c.id === elementId); - if (!element) return; - const effectiveStart = element.startTime; - const effectiveEnd = - element.startTime + - (element.duration - element.trimStart - element.trimEnd); - if (currentTime <= effectiveStart || currentTime >= effectiveEnd) { - toast.error("Playhead must be within selected element"); - return; - } - splitAndKeepLeft(trackId, elementId, currentTime); - }; - - const handleSplitAndKeepRight = () => { - if (selectedElements.length !== 1) { - toast.error("Select exactly one element"); - return; - } - const { trackId, elementId } = selectedElements[0]; - const track = tracks.find((t) => t.id === trackId); - const element = track?.elements.find((c) => c.id === elementId); - if (!element) return; - const effectiveStart = element.startTime; - const effectiveEnd = - element.startTime + - (element.duration - element.trimStart - element.trimEnd); - if (currentTime <= effectiveStart || currentTime >= effectiveEnd) { - toast.error("Playhead must be within selected element"); - return; - } - splitAndKeepRight(trackId, elementId, currentTime); - }; - - const handleSeparateAudio = () => { - if (selectedElements.length !== 1) { - toast.error("Select exactly one media element to separate audio"); - return; - } - const { trackId, elementId } = selectedElements[0]; - const track = tracks.find((t) => t.id === trackId); - if (!track || track.type !== "media") { - toast.error("Select a media element to separate audio"); - return; - } - separateAudio(trackId, elementId); - }; - - const handleDeleteSelected = () => { - if (selectedElements.length === 0) return; - selectedElements.forEach(({ trackId, elementId }) => { - if (rippleEditingEnabled) { - removeElementFromTrackWithRipple(trackId, elementId); - } else { - removeElementFromTrack(trackId, elementId); - } - }); - clearSelectedElements(); - }; - - const handleZoomIn = () => { - setZoomLevel(Math.min(4, zoomLevel + 0.25)); - }; - - const handleZoomOut = () => { - setZoomLevel(Math.max(0.25, zoomLevel - 0.25)); - }; - - const handleZoomSliderChange = (values: number[]) => { - setZoomLevel(values[0]); - }; - - const handleToggleBookmark = async () => { - await toggleBookmark(currentTime); - }; - - const currentBookmarked = isBookmarked(currentTime); - return ( -
-
- - - - - - - {isPlaying ? "Pause (Space)" : "Play (Space)"} - - - - - - - Return to Start (Home / Enter) - -
- {/* Time Display */} -
- -
- / -
-
- {formatTimeCode(duration, "HH:MM:SS:FF")} -
-
- {tracks.length === 0 && ( - <> -
- - - - - Add a test clip to try playback - - - )} -
- - - - - Split element (Ctrl+S) - - - - - - Split and keep left (Ctrl+Q) - - - - - - Split and keep right (Ctrl+W) - - - - - - Separate audio (Ctrl+D) - - - - - - Duplicate element (Ctrl+D) - - - - - - Freeze frame (F) - - - - - - Delete element (Delete) - -
- - - - - - {currentBookmarked ? "Remove bookmark" : "Add bookmark"} - - - -
-
- - - - - - Auto snapping - - - - - - - {rippleEditingEnabled - ? "Disable Ripple Editing" - : "Enable Ripple Editing"} - - - - -
-
- - - -
-
-
- ); -} +import { usePlaybackStore } from "@/stores/playback-store"; +import { useProjectStore } from "@/stores/project-store"; +import { useTimelineStore } from "@/stores/timeline-store"; +import { toast } from "sonner"; +import { + TooltipProvider, + Tooltip, + TooltipTrigger, + TooltipContent, +} from "@/components/ui/tooltip"; +import { Button } from "@/components/ui/button"; +import { + Pause, + Play, + SkipBack, + Bookmark, + Magnet, + Link, + ZoomOut, + ZoomIn, + Copy, + Trash2, + Snowflake, + ArrowLeftToLine, + ArrowRightToLine, + SplitSquareHorizontal, + Scissors, + LayersIcon, +} from "lucide-react"; +import { + SplitButton, + SplitButtonLeft, + SplitButtonRight, + SplitButtonSeparator, +} from "@/components/ui/split-button"; +import { Slider } from "@/components/ui/slider"; +import { DEFAULT_FPS } from "@/stores/project-store"; +import { formatTimeCode } from "@/lib/time"; +import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; +import { EditableTimecode } from "@/components/ui/editable-timecode"; + +export function TimelineToolbar({ + zoomLevel, + setZoomLevel, +}: { + zoomLevel: number; + setZoomLevel: (zoom: number) => void; +}) { + const { + tracks, + addTrack, + addElementToTrack, + removeElementFromTrack, + removeElementFromTrackWithRipple, + selectedElements, + clearSelectedElements, + splitElement, + splitAndKeepLeft, + splitAndKeepRight, + separateAudio, + snappingEnabled, + toggleSnapping, + rippleEditingEnabled, + toggleRippleEditing, + } = useTimelineStore(); + const { currentTime, duration, isPlaying, toggle, seek } = usePlaybackStore(); + const { toggleBookmark, isBookmarked, activeProject } = useProjectStore(); + + const handleSplitSelected = () => { + if (selectedElements.length === 0) return; + let splitCount = 0; + selectedElements.forEach(({ trackId, elementId }) => { + const track = tracks.find((t) => t.id === trackId); + const element = track?.elements.find((c) => c.id === elementId); + if (element && track) { + const effectiveStart = element.startTime; + const effectiveEnd = + element.startTime + + (element.duration - element.trimStart - element.trimEnd); + if (currentTime > effectiveStart && currentTime < effectiveEnd) { + const newElementId = splitElement(trackId, elementId, currentTime); + if (newElementId) splitCount++; + } + } + }); + if (splitCount === 0) { + toast.error("Playhead must be within selected elements to split"); + } + }; + + const handleDuplicateSelected = () => { + if (selectedElements.length === 0) return; + const canDuplicate = selectedElements.length === 1; + if (!canDuplicate) return; + + selectedElements.forEach(({ trackId, elementId }) => { + const track = tracks.find((t) => t.id === trackId); + const element = track?.elements.find((el) => el.id === elementId); + if (element) { + const newStartTime = + element.startTime + + (element.duration - element.trimStart - element.trimEnd) + + 0.1; + const { id, ...elementWithoutId } = element; + addElementToTrack(trackId, { + ...elementWithoutId, + startTime: newStartTime, + }); + } + }); + clearSelectedElements(); + }; + + const handleFreezeSelected = () => { + toast.info("Freeze frame functionality coming soon!"); + }; + + const handleSplitAndKeepLeft = () => { + if (selectedElements.length !== 1) { + toast.error("Select exactly one element"); + return; + } + const { trackId, elementId } = selectedElements[0]; + const track = tracks.find((t) => t.id === trackId); + const element = track?.elements.find((c) => c.id === elementId); + if (!element) return; + const effectiveStart = element.startTime; + const effectiveEnd = + element.startTime + + (element.duration - element.trimStart - element.trimEnd); + if (currentTime <= effectiveStart || currentTime >= effectiveEnd) { + toast.error("Playhead must be within selected element"); + return; + } + splitAndKeepLeft(trackId, elementId, currentTime); + }; + + const handleSplitAndKeepRight = () => { + if (selectedElements.length !== 1) { + toast.error("Select exactly one element"); + return; + } + const { trackId, elementId } = selectedElements[0]; + const track = tracks.find((t) => t.id === trackId); + const element = track?.elements.find((c) => c.id === elementId); + if (!element) return; + const effectiveStart = element.startTime; + const effectiveEnd = + element.startTime + + (element.duration - element.trimStart - element.trimEnd); + if (currentTime <= effectiveStart || currentTime >= effectiveEnd) { + toast.error("Playhead must be within selected element"); + return; + } + splitAndKeepRight(trackId, elementId, currentTime); + }; + + const handleSeparateAudio = () => { + if (selectedElements.length !== 1) { + toast.error("Select exactly one media element to separate audio"); + return; + } + const { trackId, elementId } = selectedElements[0]; + const track = tracks.find((t) => t.id === trackId); + if (!track || track.type !== "media") { + toast.error("Select a media element to separate audio"); + return; + } + separateAudio(trackId, elementId); + }; + + const handleDeleteSelected = () => { + if (selectedElements.length === 0) return; + selectedElements.forEach(({ trackId, elementId }) => { + if (rippleEditingEnabled) { + removeElementFromTrackWithRipple(trackId, elementId); + } else { + removeElementFromTrack(trackId, elementId); + } + }); + clearSelectedElements(); + }; + + const handleZoomIn = () => { + setZoomLevel(Math.min(4, zoomLevel + 0.25)); + }; + + const handleZoomOut = () => { + setZoomLevel(Math.max(0.25, zoomLevel - 0.25)); + }; + + const handleZoomSliderChange = (values: number[]) => { + setZoomLevel(values[0]); + }; + + const handleToggleBookmark = async () => { + await toggleBookmark(currentTime); + }; + + const currentBookmarked = isBookmarked(currentTime); + return ( +
+
+ + + + + + + {isPlaying ? "Pause (Space)" : "Play (Space)"} + + + + + + + Return to Start (Home / Enter) + +
+ {/* Time Display */} +
+ +
+ / +
+
+ {formatTimeCode(duration, "HH:MM:SS:FF")} +
+
+ {tracks.length === 0 && ( + <> +
+ + + + + Add a test clip to try playback + + + )} +
+ + + + + Split element (Ctrl+S) + + + + + + Split and keep left (Ctrl+Q) + + + + + + Split and keep right (Ctrl+W) + + + + + + Separate audio (Ctrl+D) + + + + + + Duplicate element (Ctrl+D) + + + + + + Freeze frame (F) + + + + + + Delete element (Delete) + +
+ + + + + + {currentBookmarked ? "Remove bookmark" : "Add bookmark"} + + + +
+
+ + Main scene + + {}}> + + + +
+
+ + + + + + Auto snapping + + + + + + + {rippleEditingEnabled + ? "Disable Ripple Editing" + : "Enable Ripple Editing"} + + + + +
+
+ + + +
+
+
+ ); +} diff --git a/apps/web/src/components/ui/split-button.tsx b/apps/web/src/components/ui/split-button.tsx new file mode 100644 index 00000000..1ef29500 --- /dev/null +++ b/apps/web/src/components/ui/split-button.tsx @@ -0,0 +1,90 @@ +import { Button, ButtonProps } from "@/components/ui/button"; +import { Separator } from "@/components/ui/separator"; +import { ReactNode, forwardRef } from "react"; +import { cn } from "@/lib/utils"; + +interface SplitButtonProps { + children: ReactNode; + className?: string; +} + +interface SplitButtonSideProps extends Omit { + children: ReactNode; +} + +const SplitButton = forwardRef( + ({ children, className, ...props }, ref) => { + return ( +
+ {children} +
+ ); + } +); +SplitButton.displayName = "SplitButton"; + +const SplitButtonSide = forwardRef< + HTMLButtonElement, + SplitButtonSideProps & { paddingClass: string } +>(({ children, className, paddingClass, onClick, ...props }, ref) => { + return ( + + ); +}); +SplitButtonSide.displayName = "SplitButtonSide"; + +const SplitButtonLeft = forwardRef( + ({ ...props }, ref) => { + return ; + } +); +SplitButtonLeft.displayName = "SplitButtonLeft"; + +const SplitButtonRight = forwardRef( + ({ ...props }, ref) => { + return ; + } +); +SplitButtonRight.displayName = "SplitButtonRight"; + +const SplitButtonSeparator = forwardRef( + ({ className, ...props }, ref) => { + return ( + + ); + } +); +SplitButtonSeparator.displayName = "SplitButtonSeparator"; + +export { SplitButton, SplitButtonLeft, SplitButtonRight, SplitButtonSeparator }; From 350b55081cde3a4332c982ea5c53b7d359029f6b Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Mon, 1 Sep 2025 16:17:06 +0200 Subject: [PATCH 16/29] refactor(core): switch to scene-based architecture --- apps/web/src/app/layout.tsx | 5 +- .../web/src/components/editor/scenes-view.tsx | 64 +++++ .../editor/timeline/timeline-toolbar.tsx | 13 +- apps/web/src/components/footer.tsx | 17 -- .../providers/migrators/scenes-migrator.tsx | 176 ++++++++++++++ apps/web/src/components/ui/sheet.tsx | 12 +- apps/web/src/lib/fetch-github-stars.ts | 29 --- apps/web/src/lib/storage/storage-service.ts | 159 ++++++++++--- apps/web/src/lib/storage/types.ts | 20 +- apps/web/src/stores/media-store.ts | 8 +- apps/web/src/stores/project-store.ts | 145 +++++++----- apps/web/src/stores/scene-store.ts | 219 ++++++++++++++++++ apps/web/src/stores/sounds-store.ts | 4 +- apps/web/src/stores/timeline-store.ts | 76 +++++- apps/web/src/types/project.ts | 10 + 15 files changed, 793 insertions(+), 164 deletions(-) create mode 100644 apps/web/src/components/editor/scenes-view.tsx create mode 100644 apps/web/src/components/providers/migrators/scenes-migrator.tsx delete mode 100644 apps/web/src/lib/fetch-github-stars.ts create mode 100644 apps/web/src/stores/scene-store.ts diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index ee0bdbd2..7e32ea22 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -5,6 +5,7 @@ import "./globals.css"; import { Toaster } from "../components/ui/sonner"; import { TooltipProvider } from "../components/ui/tooltip"; import { StorageProvider } from "../components/storage-provider"; +import { ScenesMigrator } from "../components/providers/migrators/scenes-migrator"; import { baseMetaData } from "./metadata"; import { defaultFont } from "../lib/font-config"; import { BotIdClient } from "botid/client"; @@ -36,7 +37,9 @@ export default function RootLayout({ - {children} + + {children} +