From 29fdd8568dbb9c67ee19492781f3f03eb2c3bd24 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Sun, 31 Aug 2025 14:10:59 +0200 Subject: refactor: improve types, fix background blur, some other stuff --- apps/web/src/components/editor/media-panel/views/settings.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) (limited to 'apps/web/src/components/editor/media-panel') 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] -- cgit v1.3.1 From 73a156c002b9f97b133d89b1e07f11eec7641576 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Sun, 31 Aug 2025 14:35:24 +0200 Subject: 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 (limited to 'apps/web/src/components/editor/media-panel') 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 -- cgit v1.3.1 From 2c5d5dc23437e288d7e6936e0337583dc8dbc782 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Sun, 31 Aug 2025 19:57:50 +0200 Subject: bunch of small improvements --- apps/web/src/components/editor/export-button.tsx | 291 ++++++---- .../editor/media-panel/views/captions.tsx | 2 +- apps/web/src/components/editor/panel-base-view.tsx | 5 +- apps/web/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(-) (limited to 'apps/web/src/components/editor/media-panel') 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; -- cgit v1.3.1 From 8eabbeae7c4c24bf8369deb336eaf38f4967f285 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Sun, 31 Aug 2025 21:51:31 +0200 Subject: more changes --- .../src/components/editor/media-panel/tabbar.tsx | 52 +++++++++++++++++++++- .../editor/media-panel/views/settings.tsx | 38 +++++++++++++--- apps/web/src/components/editor/panel-base-view.tsx | 2 +- 3 files changed, 84 insertions(+), 8 deletions(-) (limited to 'apps/web/src/components/editor/media-panel') 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} ))} -- cgit v1.3.1 From e2bd8d1e093715da6d5d09b6dcbac6b68262f6ad Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Mon, 1 Sep 2025 16:31:03 +0200 Subject: fix: don't pre-fetch sounds --- apps/web/src/app/editor/[project_id]/layout.tsx | 22 +++--- .../components/editor/media-panel/views/sounds.tsx | 70 ++++++++++++++++++- .../src/components/providers/global-prefetcher.ts | 78 ---------------------- 3 files changed, 77 insertions(+), 93 deletions(-) delete mode 100644 apps/web/src/components/providers/global-prefetcher.ts (limited to 'apps/web/src/components/editor/media-panel') diff --git a/apps/web/src/app/editor/[project_id]/layout.tsx b/apps/web/src/app/editor/[project_id]/layout.tsx index 151f7f94..c37e78b7 100644 --- a/apps/web/src/app/editor/[project_id]/layout.tsx +++ b/apps/web/src/app/editor/[project_id]/layout.tsx @@ -1,13 +1,9 @@ -"use client"; - -import { useGlobalPrefetcher } from "@/components/providers/global-prefetcher"; - -export default function EditorLayout({ - children, -}: { - children: React.ReactNode; -}) { - useGlobalPrefetcher(); - - return
{children}
; -} +"use client"; + +export default function EditorLayout({ + children, +}: { + children: React.ReactNode; +}) { + return
{children}
; +} diff --git a/apps/web/src/components/editor/media-panel/views/sounds.tsx b/apps/web/src/components/editor/media-panel/views/sounds.tsx index 303eb858..700eaea7 100644 --- a/apps/web/src/components/editor/media-panel/views/sounds.tsx +++ b/apps/web/src/components/editor/media-panel/views/sounds.tsx @@ -82,6 +82,14 @@ function SoundEffectsView() { toggleSavedSound, showCommercialOnly, toggleCommercialFilter, + hasLoaded, + setTopSoundEffects, + setLoading, + setError, + setHasLoaded, + setCurrentPage, + setHasNextPage, + setTotalCount, } = useSoundsStore(); const { results: searchResults, @@ -106,6 +114,55 @@ function SoundEffectsView() { useEffect(() => { loadSavedSounds(); + if (!hasLoaded) { + let ignore = false; + + const fetchTopSounds = async () => { + try { + if (!ignore) { + setLoading(true); + setError(null); + } + + const response = await fetch( + "/api/sounds/search?page_size=50&sort=downloads" + ); + + if (!ignore) { + if (!response.ok) { + throw new Error(`Failed to fetch: ${response.status}`); + } + + const data = await response.json(); + setTopSoundEffects(data.results); + setHasLoaded(true); + + setCurrentPage(1); + setHasNextPage(!!data.next); + setTotalCount(data.count); + } + } catch (error) { + if (!ignore) { + console.error("Failed to fetch top sounds:", error); + setError( + error instanceof Error ? error.message : "Failed to load sounds" + ); + } + } finally { + if (!ignore) { + setLoading(false); + } + } + }; + + const timeoutId = setTimeout(fetchTopSounds, 100); + + return () => { + clearTimeout(timeoutId); + ignore = true; + }; + } + if (scrollAreaRef.current && scrollPosition > 0) { const timeoutId = setTimeout(() => { scrollAreaRef.current?.scrollTo({ top: scrollPosition }); @@ -113,14 +170,23 @@ function SoundEffectsView() { return () => clearTimeout(timeoutId); } - }, []); + }, [ + hasLoaded, + setTopSoundEffects, + setLoading, + setError, + setHasLoaded, + setCurrentPage, + setHasNextPage, + setTotalCount, + ]); const handleScrollWithPosition = (event: React.UIEvent) => { const { scrollTop } = event.currentTarget; setScrollPosition(scrollTop); handleScroll(event); }; - + const displayedSounds = useMemo(() => { const sounds = searchQuery ? searchResults : topSoundEffects; return sounds; diff --git a/apps/web/src/components/providers/global-prefetcher.ts b/apps/web/src/components/providers/global-prefetcher.ts deleted file mode 100644 index 2d7c684c..00000000 --- a/apps/web/src/components/providers/global-prefetcher.ts +++ /dev/null @@ -1,78 +0,0 @@ -"use client"; - -import { useEffect } from "react"; -import { useSoundsStore } from "@/stores/sounds-store"; - -export function useGlobalPrefetcher() { - const { - hasLoaded, - setTopSoundEffects, - setLoading, - setError, - setHasLoaded, - setCurrentPage, - setHasNextPage, - setTotalCount, - } = useSoundsStore(); - - useEffect(() => { - if (hasLoaded) return; - - let ignore = false; - - const prefetchTopSounds = async () => { - try { - if (!ignore) { - setLoading(true); - setError(null); - } - - const response = await fetch( - "/api/sounds/search?page_size=50&sort=downloads" - ); - - if (!ignore) { - if (!response.ok) { - throw new Error(`Failed to fetch: ${response.status}`); - } - - const data = await response.json(); - setTopSoundEffects(data.results); - setHasLoaded(true); - - // Set pagination state for top sounds - setCurrentPage(1); - setHasNextPage(!!data.next); - setTotalCount(data.count); - } - } catch (error) { - if (!ignore) { - console.error("Failed to prefetch top sounds:", error); - setError( - error instanceof Error ? error.message : "Failed to load sounds" - ); - } - } finally { - if (!ignore) { - setLoading(false); - } - } - }; - - const timeoutId = setTimeout(prefetchTopSounds, 100); - - return () => { - clearTimeout(timeoutId); - ignore = true; - }; - }, [ - hasLoaded, - setTopSoundEffects, - setLoading, - setError, - setHasLoaded, - setCurrentPage, - setHasNextPage, - setTotalCount, - ]); -} -- cgit v1.3.1 From 3d67b065f57585ced95c1135a50a2c237845d1d7 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Mon, 1 Sep 2025 22:19:55 +0200 Subject: refactor: remove success toast --- apps/web/src/components/editor/media-panel/views/stickers.tsx | 2 -- 1 file changed, 2 deletions(-) (limited to 'apps/web/src/components/editor/media-panel') diff --git a/apps/web/src/components/editor/media-panel/views/stickers.tsx b/apps/web/src/components/editor/media-panel/views/stickers.tsx index 41afe285..c30acee0 100644 --- a/apps/web/src/components/editor/media-panel/views/stickers.tsx +++ b/apps/web/src/components/editor/media-panel/views/stickers.tsx @@ -308,8 +308,6 @@ function StickersContentView({ category }: { category: StickerCategory }) { if (!added) throw new Error("Sticker not in media store"); addElementAtTime(added, currentTime); - - toast.success(`Added "${iconName}" to timeline`); } catch (error) { console.error("Failed to add sticker:", error); toast.error("Failed to add sticker to timeline"); -- cgit v1.3.1 From c5480d307cd6b959c21aee4f1de7a2c3b9010d7d Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Mon, 1 Sep 2025 23:01:15 +0200 Subject: refactor: make stickers use panel base view --- .../editor/media-panel/views/stickers.tsx | 97 +++++++++------------- apps/web/src/components/editor/panel-base-view.tsx | 6 ++ 2 files changed, 43 insertions(+), 60 deletions(-) (limited to 'apps/web/src/components/editor/media-panel') diff --git a/apps/web/src/components/editor/media-panel/views/stickers.tsx b/apps/web/src/components/editor/media-panel/views/stickers.tsx index c30acee0..b6c64bd0 100644 --- a/apps/web/src/components/editor/media-panel/views/stickers.tsx +++ b/apps/web/src/components/editor/media-panel/views/stickers.tsx @@ -20,8 +20,7 @@ import { import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { ScrollArea } from "@/components/ui/scroll-area"; -import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; -import { Separator } from "@/components/ui/separator"; +import { PanelBaseView as BaseView } from "@/components/editor/panel-base-view"; import { Tooltip, TooltipContent, @@ -48,63 +47,41 @@ export function StickersView() { const { selectedCategory, setSelectedCategory } = useStickersStore(); return ( -
- { - if (["all", "general", "brands", "emoji"].includes(v)) { - setSelectedCategory(v as StickerCategory); - } - }} - className="flex flex-col h-full" - > -
- - - - All - - - - Icons - - - - Brands - - - - Emoji - - -
- - - - - - - - - - - - - -
-
+ { + if (["all", "general", "brands", "emoji"].includes(v)) { + setSelectedCategory(v as StickerCategory); + } + }} + tabs={[ + { + value: "all", + label: "All", + icon: , + content: , + }, + { + value: "general", + label: "Icons", + icon: , + content: , + }, + { + value: "brands", + label: "Brands", + icon: , + content: , + }, + { + value: "emoji", + label: "Emoji", + icon: , + content: , + }, + ]} + className="flex flex-col h-full p-0 overflow-hidden" + /> ); } @@ -361,7 +338,7 @@ function StickersContentView({ category }: { category: StickerCategory }) { }, [isInCollection]); return ( -
+
{tabs.map((tab) => ( + {tab.icon ? ( + + {tab.icon} + + ) : null} {tab.label} ))} -- cgit v1.3.1 From 42cf851d635e006c8dda11e09d9b52d07d1afcd2 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Mon, 1 Sep 2025 23:02:57 +0200 Subject: disable back animation --- apps/web/src/components/editor/media-panel/views/stickers.tsx | 1 + apps/web/src/components/ui/input-with-back.tsx | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) (limited to 'apps/web/src/components/editor/media-panel') diff --git a/apps/web/src/components/editor/media-panel/views/stickers.tsx b/apps/web/src/components/editor/media-panel/views/stickers.tsx index b6c64bd0..0d293242 100644 --- a/apps/web/src/components/editor/media-panel/views/stickers.tsx +++ b/apps/web/src/components/editor/media-panel/views/stickers.tsx @@ -358,6 +358,7 @@ function StickersContentView({ category }: { category: StickerCategory }) { } value={localSearchQuery} onChange={setLocalSearchQuery} + disableAnimation={true} />
diff --git a/apps/web/src/components/ui/input-with-back.tsx b/apps/web/src/components/ui/input-with-back.tsx index ea225c14..cae9f8c9 100644 --- a/apps/web/src/components/ui/input-with-back.tsx +++ b/apps/web/src/components/ui/input-with-back.tsx @@ -12,6 +12,7 @@ interface InputWithBackProps { placeholder?: string; value?: string; onChange?: (value: string) => void; + disableAnimation?: boolean; } export function InputWithBack({ @@ -20,12 +21,13 @@ export function InputWithBack({ placeholder = "Search anything", value, onChange, + disableAnimation = false, }: InputWithBackProps) { const [containerRef, setContainerRef] = useState(null); const [buttonOffset, setButtonOffset] = useState(-60); const smoothTransition = { - duration: 0.35, + duration: disableAnimation ? 0 : 0.35, ease: [0.25, 0.1, 0.25, 1] as const, }; -- cgit v1.3.1 From f7ef8d7f38af0f049288a58787bc7b9f931599df Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Mon, 1 Sep 2025 23:17:48 +0200 Subject: move logic to zustand store --- .../editor/media-panel/views/stickers.tsx | 41 ++--------------- apps/web/src/stores/stickers-store.ts | 53 ++++++++++++++++++++++ 2 files changed, 56 insertions(+), 38 deletions(-) (limited to 'apps/web/src/components/editor/media-panel') diff --git a/apps/web/src/components/editor/media-panel/views/stickers.tsx b/apps/web/src/components/editor/media-panel/views/stickers.tsx index 0d293242..f67450cc 100644 --- a/apps/web/src/components/editor/media-panel/views/stickers.tsx +++ b/apps/web/src/components/editor/media-panel/views/stickers.tsx @@ -181,12 +181,12 @@ function StickersContentView({ category }: { category: StickerCategory }) { setSelectedCollection, loadCollections, searchStickers, - downloadSticker, + addStickerToTimeline, clearRecentStickers, setSelectedCategory, + addingSticker, } = useStickersStore(); - const [addingSticker, setAddingSticker] = useState(null); const [localSearchQuery, setLocalSearchQuery] = useState(searchQuery); const [collectionsToShow, setCollectionsToShow] = useState(20); const [showCollectionItems, setShowCollectionItems] = useState(false); @@ -250,46 +250,11 @@ function StickersContentView({ category }: { category: StickerCategory }) { }, [localSearchQuery]); const handleAddSticker = async (iconName: string) => { - if (!activeProject) { - toast.error("No active project"); - return; - } - - setAddingSticker(iconName); - try { - const file = await downloadSticker(iconName); - - if (!file) { - throw new Error("Failed to download sticker"); - } - - const mediaItem: Omit = { - name: iconName.replace(":", "-"), - type: "image", - file, - url: URL.createObjectURL(file), - width: 200, - height: 200, - duration: TIMELINE_CONSTANTS.DEFAULT_IMAGE_DURATION, - ephemeral: false, - }; - - await addMediaFile(activeProject.id, mediaItem); - - const added = useMediaStore - .getState() - .mediaFiles.find( - (m) => m.url === mediaItem.url && m.name === mediaItem.name - ); - if (!added) throw new Error("Sticker not in media store"); - - addElementAtTime(added, currentTime); + await addStickerToTimeline(iconName); } catch (error) { console.error("Failed to add sticker:", error); toast.error("Failed to add sticker to timeline"); - } finally { - setAddingSticker(null); } }; diff --git a/apps/web/src/stores/stickers-store.ts b/apps/web/src/stores/stickers-store.ts index da8b926b..c7c9ef98 100644 --- a/apps/web/src/stores/stickers-store.ts +++ b/apps/web/src/stores/stickers-store.ts @@ -9,6 +9,12 @@ import { type CollectionInfo, type IconSearchResult, } from "@/lib/iconify-api"; +import { useProjectStore } from "@/stores/project-store"; +import { useMediaStore } from "@/stores/media-store"; +import { useTimelineStore } from "@/stores/timeline-store"; +import { usePlaybackStore } from "@/stores/playback-store"; +import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; +import type { MediaFile } from "@/types/media"; export type StickerCategory = "all" | "general" | "brands" | "emoji"; @@ -26,6 +32,7 @@ interface StickersStore { isLoadingCollection: boolean; isSearching: boolean; isDownloading: boolean; + addingSticker: string | null; setSearchQuery: (query: string) => void; setSelectedCategory: (category: StickerCategory) => void; @@ -36,6 +43,7 @@ interface StickersStore { loadCollection: (prefix: string) => Promise; searchStickers: (query: string) => Promise; downloadSticker: (iconName: string) => Promise; + addStickerToTimeline: (iconName: string) => Promise; addToRecentStickers: (iconName: string) => void; clearRecentStickers: () => void; @@ -58,6 +66,7 @@ export const useStickersStore = create((set, get) => ({ isLoadingCollection: false, isSearching: false, isDownloading: false, + addingSticker: null, setSearchQuery: (query) => set({ searchQuery: query }), @@ -162,6 +171,50 @@ export const useStickersStore = create((set, get) => ({ } }, + addStickerToTimeline: async (iconName: string) => { + set({ addingSticker: iconName }); + try { + const { activeProject } = useProjectStore.getState(); + if (!activeProject) { + throw new Error("No active project"); + } + + const file = await get().downloadSticker(iconName); + if (!file) { + throw new Error("Failed to download sticker"); + } + + const mediaItem: Omit = { + name: iconName.replace(":", "-"), + type: "image", + file, + url: URL.createObjectURL(file), + width: 200, + height: 200, + duration: TIMELINE_CONSTANTS.DEFAULT_IMAGE_DURATION, + ephemeral: false, + }; + + const { addMediaFile } = useMediaStore.getState(); + await addMediaFile(activeProject.id, mediaItem); + + const added = useMediaStore + .getState() + .mediaFiles.find( + (m) => m.url === mediaItem.url && m.name === mediaItem.name + ); + if (!added) { + throw new Error("Sticker not in media store"); + } + + const { currentTime } = usePlaybackStore.getState(); + const { addElementAtTime } = useTimelineStore.getState(); + addElementAtTime(added, currentTime); + } finally { + set({ addingSticker: null }); + } + }, + addToRecentStickers: (iconName: string) => { set((state) => { const recent = [ -- cgit v1.3.1