From f750c457651e80ae96a6828f94f24ab98c66a36b Mon Sep 17 00:00:00 2001 From: David <48634587+DavidHDev@users.noreply.github.com> Date: Wed, 20 Aug 2025 00:23:19 +0300 Subject: fix: adjust padding in HeaderBase for landing page (#563) --- apps/web/src/components/header.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'apps') diff --git a/apps/web/src/components/header.tsx b/apps/web/src/components/header.tsx index 4d68efb2..2bfc82b7 100644 --- a/apps/web/src/components/header.tsx +++ b/apps/web/src/components/header.tsx @@ -46,7 +46,7 @@ export function Header() { return (
-- cgit v1.3.1 From ffc1d7526ee0309a2f071ca9f5b7469be4299a0e Mon Sep 17 00:00:00 2001 From: Megh Bari <142393952+megh-bari@users.noreply.github.com> Date: Wed, 20 Aug 2025 02:54:09 +0530 Subject: feat(rename-modal): enhance input visibility in dark theme with border (#561) --- apps/web/src/components/rename-project-dialog.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'apps') diff --git a/apps/web/src/components/rename-project-dialog.tsx b/apps/web/src/components/rename-project-dialog.tsx index c50f9f71..dd8dfcc4 100644 --- a/apps/web/src/components/rename-project-dialog.tsx +++ b/apps/web/src/components/rename-project-dialog.tsx @@ -51,7 +51,7 @@ export function RenameProjectDialog({ } }} placeholder="Enter a new name" - className="mt-4" + className="mt-4 bg-background border-2 border-border" /> -- cgit v1.3.1 From a4f2273e25995be234f20fb1c40d410620706e20 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Sat, 23 Aug 2025 11:00:59 +0200 Subject: feat: mediabunny + preview canvas refactor --- .../editor/media-panel/views/captions.tsx | 2 +- apps/web/src/components/editor/preview-panel.tsx | 526 +++++++++++++-------- apps/web/src/lib/ffmpeg-utils.ts | 468 ------------------ apps/web/src/lib/media-processing.ts | 25 +- apps/web/src/lib/mediabunny-utils.ts | 311 ++++++++++++ apps/web/src/lib/timeline-renderer.ts | 179 +++++++ bun.lock | 11 +- package.json | 1 + 8 files changed, 827 insertions(+), 696 deletions(-) delete mode 100644 apps/web/src/lib/ffmpeg-utils.ts create mode 100644 apps/web/src/lib/mediabunny-utils.ts create mode 100644 apps/web/src/lib/timeline-renderer.ts (limited to 'apps') 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 4fe2cbd7..6e18781c 100644 --- a/apps/web/src/components/editor/media-panel/views/captions.tsx +++ b/apps/web/src/components/editor/media-panel/views/captions.tsx @@ -3,7 +3,7 @@ import { PropertyGroup } from "../../properties-panel/property-item"; import { BaseView } from "./base-view"; import { Language, LanguageSelect } from "@/components/language-select"; import { useState, useRef, useEffect } from "react"; -import { extractTimelineAudio } from "@/lib/ffmpeg-utils"; +import { extractTimelineAudio } from "@/lib/mediabunny-utils"; import { encryptWithRandomKey, arrayBufferToBase64 } from "@/lib/zk-encryption"; import { useTimelineStore } from "@/stores/timeline-store"; import { Loader2, Shield, Trash2, Upload } from "lucide-react"; diff --git a/apps/web/src/components/editor/preview-panel.tsx b/apps/web/src/components/editor/preview-panel.tsx index 42c4becc..99c4edef 100644 --- a/apps/web/src/components/editor/preview-panel.tsx +++ b/apps/web/src/components/editor/preview-panel.tsx @@ -5,16 +5,19 @@ import { TimelineElement, TimelineTrack } from "@/types/timeline"; import { useMediaStore, type MediaItem } from "@/stores/media-store"; import { usePlaybackStore } from "@/stores/playback-store"; import { useEditorStore } from "@/stores/editor-store"; -import { VideoPlayer } from "@/components/ui/video-player"; -import { AudioPlayer } from "@/components/ui/audio-player"; import { Button } from "@/components/ui/button"; import { Play, Pause, Expand, SkipBack, SkipForward } from "lucide-react"; import { useState, useRef, useEffect, useCallback } from "react"; +import { VideoSampleSink } from "mediabunny"; +import { renderTimelineFrame } from "@/lib/timeline-renderer"; import { cn } from "@/lib/utils"; import { formatTimeCode } from "@/lib/time"; import { EditableTimecode } from "@/components/ui/editable-timecode"; -import { FONT_CLASS_MAP } from "@/lib/font-config"; -import { DEFAULT_CANVAS_SIZE, DEFAULT_FPS, useProjectStore } from "@/stores/project-store"; +import { + DEFAULT_CANVAS_SIZE, + DEFAULT_FPS, + useProjectStore, +} from "@/stores/project-store"; import { TextElementDragState } from "@/types/editor"; import { Popover, @@ -37,8 +40,20 @@ export function PreviewPanel() { const { tracks, getTotalDuration, updateTextElement } = useTimelineStore(); const { mediaItems } = useMediaStore(); const { currentTime, toggle, setCurrentTime } = usePlaybackStore(); + const { isPlaying, volume, muted } = usePlaybackStore(); const { activeProject } = useProjectStore(); const previewRef = useRef(null); + const canvasRef = useRef(null); + const lastFrameTimeRef = useRef(0); + const renderSeqRef = useRef(0); + const offscreenCanvasRef = useRef( + null + ); + + const audioContextRef = useRef(null); + const audioGainRef = useRef(null); + const audioBuffersRef = useRef>(new Map()); + const playingSourcesRef = useRef>(new Set()); const containerRef = useRef(null); const [previewDimensions, setPreviewDimensions] = useState({ width: 0, @@ -262,227 +277,315 @@ export function PreviewPanel() { const activeElements = getActiveElements(); - // Get media elements for blur background (video/image only) - const getBlurBackgroundElements = (): ActiveElement[] => { - return activeElements.filter( - ({ element, mediaItem }) => - element.type === "media" && - mediaItem && - (mediaItem.type === "video" || mediaItem.type === "image") && - element.mediaId !== "test" // Exclude test elements - ); - }; - - const blurBackgroundElements = getBlurBackgroundElements(); - - // Render blur background layer - const renderBlurBackground = () => { - if ( - !activeProject?.backgroundType || - activeProject.backgroundType !== "blur" || - blurBackgroundElements.length === 0 - ) { - return null; - } - - // Use the first media element for background (could be enhanced to use primary/focused element) - const backgroundElement = blurBackgroundElements[0]; - const { element, mediaItem } = backgroundElement; - - if (!mediaItem) return null; - - const blurIntensity = activeProject.blurIntensity || 8; + // Ensure first frame after mount/seek renders immediately + useEffect(() => { + const onSeek = () => { + lastFrameTimeRef.current = -Infinity; + renderSeqRef.current++; + }; + window.addEventListener("playback-seek", onSeek as EventListener); + lastFrameTimeRef.current = -Infinity; + return () => { + window.removeEventListener("playback-seek", onSeek as EventListener); + }; + }, []); - if (mediaItem.type === "video") { - return ( -
- -
- ); - } + // Web Audio: schedule only on play/pause/seek/volume/mute changes + useEffect(() => { + const stopAll = () => { + for (const src of playingSourcesRef.current) { + try { + src.stop(); + } catch {} + } + playingSourcesRef.current.clear(); + }; - if (mediaItem.type === "image") { - return ( -
- {mediaItem.name} -
+ type WebAudioWindow = Window & { + AudioContext?: typeof AudioContext; + webkitAudioContext?: typeof AudioContext; + }; + const ensureAudioGraph = async () => { + if (!audioContextRef.current) { + const win = window as WebAudioWindow; + const Ctx = win.AudioContext ?? win.webkitAudioContext; + if (!Ctx) return; + audioContextRef.current = new Ctx(); + } + if (!audioGainRef.current) { + audioGainRef.current = audioContextRef.current!.createGain(); + audioGainRef.current.connect(audioContextRef.current!.destination); + } + if (audioContextRef.current!.state === "suspended") { + try { + await audioContextRef.current!.resume(); + } catch {} + } + const gainValue = muted ? 0 : Math.max(0, Math.min(1, volume)); + audioGainRef.current!.gain.setValueAtTime( + gainValue, + audioContextRef.current!.currentTime ); - } + }; - return null; - }; + const scheduleNow = async () => { + await ensureAudioGraph(); + const audioCtx = audioContextRef.current!; + const gain = audioGainRef.current!; + + const tracksSnapshot = useTimelineStore.getState().tracks; + const mediaList = useMediaStore.getState().mediaItems; + const idToMedia = new Map(mediaList.map((m) => [m.id, m] as const)); + const playbackNow = usePlaybackStore.getState().currentTime; + + const audible: Array<{ + id: string; + elementStart: number; + trimStart: number; + trimEnd: number; + duration: number; + muted: boolean; + trackMuted: boolean; + }> = []; + const uniqueIds = new Set(); + for (const track of tracksSnapshot) { + for (const element of track.elements) { + if (element.type !== "media") continue; + const media = idToMedia.get(element.mediaId); + if (!media || media.type !== "audio") continue; + const visibleDuration = + element.duration - element.trimStart - element.trimEnd; + if (visibleDuration <= 0) continue; + const localTime = playbackNow - element.startTime + element.trimStart; + if (localTime < 0 || localTime >= visibleDuration) continue; + audible.push({ + id: media.id, + elementStart: element.startTime, + trimStart: element.trimStart, + trimEnd: element.trimEnd, + duration: element.duration, + muted: !!element.muted, + trackMuted: !!track.muted, + }); + uniqueIds.add(media.id); + } + } - // Render an element - const renderElement = (elementData: ActiveElement, index: number) => { - const { element, mediaItem } = elementData; + if (audible.length === 0) return; + + // Decode buffers as needed + const decodePromises: Array> = []; + for (const id of uniqueIds) { + if (!audioBuffersRef.current.has(id)) { + const mediaItem = idToMedia.get(id); + if (!mediaItem) continue; + const p = (async () => { + const arr = await mediaItem.file.arrayBuffer(); + const buf = await audioCtx.decodeAudioData(arr.slice(0)); + audioBuffersRef.current.set(id, buf); + })(); + decodePromises.push(p); + } + } + await Promise.all(decodePromises); + + const startAt = audioCtx.currentTime + 0.02; + for (const entry of audible) { + if (entry.muted || entry.trackMuted) continue; + const buffer = audioBuffersRef.current.get(entry.id); + if (!buffer) continue; + const visibleDuration = + entry.duration - entry.trimStart - entry.trimEnd; + const localTime = Math.max( + 0, + playbackNow - entry.elementStart + entry.trimStart + ); + const playDuration = Math.max(0, visibleDuration - localTime); + if (playDuration <= 0) continue; + const src = audioCtx.createBufferSource(); + src.buffer = buffer; + src.connect(gain); + try { + src.start(startAt, localTime, playDuration); + playingSourcesRef.current.add(src); + } catch {} + } + }; - // Text elements - if (element.type === "text") { - const fontClassName = - FONT_CLASS_MAP[element.fontFamily as keyof typeof FONT_CLASS_MAP] || ""; + const onSeek = () => { + if (!isPlaying) return; + for (const src of playingSourcesRef.current) { + try { + src.stop(); + } catch {} + } + playingSourcesRef.current.clear(); + void scheduleNow(); + }; - const scaleRatio = previewDimensions.width / canvasSize.width; + // Apply volume/mute changes immediately + void ensureAudioGraph(); - return ( -
- handleTextMouseDown(e, element, elementData.track.id) - } - style={{ - left: `${ - 50 + - ((dragState.isDragging && dragState.elementId === element.id - ? dragState.currentX - : element.x) / - canvasSize.width) * - 100 - }%`, - top: `${ - 50 + - ((dragState.isDragging && dragState.elementId === element.id - ? dragState.currentY - : element.y) / - canvasSize.height) * - 100 - }%`, - transform: `translate(-50%, -50%) rotate(${element.rotation}deg)`, - opacity: element.opacity, - zIndex: 100 + index, // Text elements on top - }} - > -
- {element.content} -
-
- ); + // Start/stop on play state changes + for (const src of playingSourcesRef.current) { + try { + src.stop(); + } catch {} + } + playingSourcesRef.current.clear(); + if (isPlaying) { + void scheduleNow(); } - // Media elements - if (element.type === "media") { - // Test elements - if (!mediaItem || element.mediaId === "test") { - return ( -
-
-
🎬
-

{element.name}

-
-
- ); + window.addEventListener("playback-seek", onSeek as EventListener); + return () => { + window.removeEventListener("playback-seek", onSeek as EventListener); + for (const src of playingSourcesRef.current) { + try { + src.stop(); + } catch {} } + playingSourcesRef.current.clear(); + }; + }, [isPlaying, volume, muted, mediaItems]); - // Video elements - if (mediaItem.type === "video") { - return ( -
- -
- ); + // Canvas: draw current frame for visible elements using offscreen compositing + useEffect(() => { + const draw = async () => { + const canvas = canvasRef.current; + if (!canvas) return; + const mainCtx = canvas.getContext("2d"); + if (!mainCtx) return; + + // Set canvas internal resolution to avoid blurry scaling + const displayWidth = Math.max(1, Math.floor(previewDimensions.width)); + const displayHeight = Math.max(1, Math.floor(previewDimensions.height)); + if (canvas.width !== displayWidth || canvas.height !== displayHeight) { + canvas.width = displayWidth; + canvas.height = displayHeight; } - // Image elements - if (mediaItem.type === "image") { - return ( -
- {mediaItem.name} -
- ); + // Throttle rendering to project FPS during playback only + const fps = activeProject?.fps || DEFAULT_FPS; + const minDelta = 1 / fps; + if (isPlaying) { + if (currentTime - lastFrameTimeRef.current < minDelta) { + return; + } + lastFrameTimeRef.current = currentTime; } - // Audio elements (no visual representation) - if (mediaItem.type === "audio") { - return ( -
- -
+ // Invalidate older async renders when user scrubs rapidly + const mySeq = (renderSeqRef.current += 1); + + // Offscreen buffer to avoid flicker (reuse canvas) + if (!offscreenCanvasRef.current) { + const hasOffscreen = + typeof (globalThis as unknown as { OffscreenCanvas?: unknown }) + .OffscreenCanvas !== "undefined"; + if (hasOffscreen) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + offscreenCanvasRef.current = new (globalThis as any).OffscreenCanvas( + displayWidth, + displayHeight + ) as OffscreenCanvas; + } else { + const c = document.createElement("canvas"); + c.width = displayWidth; + c.height = displayHeight; + offscreenCanvasRef.current = c; + } + } + // Ensure size matches + if ( + offscreenCanvasRef.current && + (offscreenCanvasRef.current as HTMLCanvasElement).getContext + ) { + const c = offscreenCanvasRef.current as HTMLCanvasElement; + if (c.width !== displayWidth || c.height !== displayHeight) { + c.width = displayWidth; + c.height = displayHeight; + } + } else { + const c = offscreenCanvasRef.current as OffscreenCanvas; + // @ts-ignore width/height exist on OffscreenCanvas in modern browsers + if ( + (c as unknown as { width: number }).width !== displayWidth || + (c as unknown as { height: number }).height !== displayHeight + ) { + // @ts-ignore + (c as unknown as { width: number }).width = displayWidth; + // @ts-ignore + (c as unknown as { height: number }).height = displayHeight; + } + } + const offscreenCanvas = offscreenCanvasRef.current as + | HTMLCanvasElement + | OffscreenCanvas; + const offCtx = (offscreenCanvas as HTMLCanvasElement).getContext + ? (offscreenCanvas as HTMLCanvasElement).getContext("2d") + : (offscreenCanvas as OffscreenCanvas).getContext("2d"); + if (!offCtx) return; + + await renderTimelineFrame({ + ctx: offCtx as CanvasRenderingContext2D, + time: currentTime, + canvasWidth: displayWidth, + canvasHeight: displayHeight, + tracks, + mediaItems, + backgroundColor: + activeProject?.backgroundType === "blur" + ? "transparent" + : activeProject?.backgroundColor || "#000000", + useCache: false, + fps: activeProject?.fps || DEFAULT_FPS, + }); + + // Blit offscreen to visible canvas + mainCtx.clearRect(0, 0, displayWidth, displayHeight); + if ((offscreenCanvas as HTMLCanvasElement).getContext) { + mainCtx.drawImage(offscreenCanvas as HTMLCanvasElement, 0, 0); + } else { + mainCtx.drawImage( + offscreenCanvas as unknown as CanvasImageSource, + 0, + 0 ); } - } + }; + + void draw(); + }, [ + activeElements, + currentTime, + previewDimensions.width, + previewDimensions.height, + canvasSize.width, + canvasSize.height, + activeProject?.backgroundType, + activeProject?.backgroundColor, + ]); - return null; + // Get media elements for blur background (video/image only) + const getBlurBackgroundElements = (): ActiveElement[] => { + return activeElements.filter( + ({ element, mediaItem }) => + element.type === "media" && + mediaItem && + (mediaItem.type === "video" || mediaItem.type === "image") && + element.mediaId !== "test" // Exclude test elements + ); }; + const blurBackgroundElements = getBlurBackgroundElements(); + + // Render blur background layer (handled by canvas now) + const renderBlurBackground = () => null; + + // Render an element (canvas handles visuals now). Audio playback to be implemented via Web Audio. + const renderElement = (_elementData: ActiveElement) => null; + return ( <>
@@ -505,14 +608,23 @@ export function PreviewPanel() { }} > {renderBlurBackground()} + {activeElements.length === 0 ? (
No elements at current time
) : ( - activeElements.map((elementData, index) => - renderElement(elementData, index) - ) + activeElements.map((elementData) => renderElement(elementData)) )}
diff --git a/apps/web/src/lib/ffmpeg-utils.ts b/apps/web/src/lib/ffmpeg-utils.ts deleted file mode 100644 index 0643ecb9..00000000 --- a/apps/web/src/lib/ffmpeg-utils.ts +++ /dev/null @@ -1,468 +0,0 @@ -import { FFmpeg } from "@ffmpeg/ffmpeg"; -import { toBlobURL } from "@ffmpeg/util"; -import { useTimelineStore } from "@/stores/timeline-store"; -import { useMediaStore } from "@/stores/media-store"; - -let ffmpeg: FFmpeg | null = null; - -export const initFFmpeg = async (): Promise => { - if (ffmpeg) return ffmpeg; - - ffmpeg = new FFmpeg(); - await ffmpeg.load(); // Use default config - - return ffmpeg; -}; - -export const generateThumbnail = async ( - videoFile: File, - timeInSeconds = 1 -): Promise => { - const ffmpeg = await initFFmpeg(); - - const inputName = "input.mp4"; - const outputName = "thumbnail.jpg"; - - // Write input file - await ffmpeg.writeFile( - inputName, - new Uint8Array(await videoFile.arrayBuffer()) - ); - - // Generate thumbnail at specific time - await ffmpeg.exec([ - "-i", - inputName, - "-ss", - timeInSeconds.toString(), - "-vframes", - "1", - "-vf", - "scale=320:240", - "-q:v", - "2", - outputName, - ]); - - // Read output file - const data = await ffmpeg.readFile(outputName); - const blob = new Blob([data], { type: "image/jpeg" }); - - // Cleanup - await ffmpeg.deleteFile(inputName); - await ffmpeg.deleteFile(outputName); - - return URL.createObjectURL(blob); -}; - -export const trimVideo = async ( - videoFile: File, - startTime: number, - endTime: number, - onProgress?: (progress: number) => void -): Promise => { - const ffmpeg = await initFFmpeg(); - - const inputName = "input.mp4"; - const outputName = "output.mp4"; - - // Set up progress callback - if (onProgress) { - ffmpeg.on("progress", ({ progress }) => { - onProgress(progress * 100); - }); - } - - // Write input file - await ffmpeg.writeFile( - inputName, - new Uint8Array(await videoFile.arrayBuffer()) - ); - - const duration = endTime - startTime; - - // Trim video - await ffmpeg.exec([ - "-i", - inputName, - "-ss", - startTime.toString(), - "-t", - duration.toString(), - "-c", - "copy", // Use stream copy for faster processing - outputName, - ]); - - // Read output file - const data = await ffmpeg.readFile(outputName); - const blob = new Blob([data], { type: "video/mp4" }); - - // Cleanup - await ffmpeg.deleteFile(inputName); - await ffmpeg.deleteFile(outputName); - - return blob; -}; - -export const getVideoInfo = async ( - videoFile: File -): Promise<{ - duration: number; - width: number; - height: number; - fps: number; -}> => { - const ffmpeg = await initFFmpeg(); - - const inputName = "input.mp4"; - - // Write input file - await ffmpeg.writeFile( - inputName, - new Uint8Array(await videoFile.arrayBuffer()) - ); - - // Capture FFmpeg stderr output with a one-time listener pattern - let ffmpegOutput = ""; - let listening = true; - const listener = (data: string) => { - if (listening) ffmpegOutput += data; - }; - ffmpeg.on("log", ({ message }) => listener(message)); - - // Run ffmpeg to get info (stderr will contain the info) - try { - await ffmpeg.exec(["-i", inputName, "-f", "null", "-"]); - } catch (error) { - listening = false; - await ffmpeg.deleteFile(inputName); - console.error("FFmpeg execution failed:", error); - throw new Error( - "Failed to extract video info. The file may be corrupted or in an unsupported format." - ); - } - - // Disable listener after exec completes - listening = false; - - // Cleanup - await ffmpeg.deleteFile(inputName); - - // Parse output for duration, resolution, and fps - // Example: Duration: 00:00:10.00, start: 0.000000, bitrate: 1234 kb/s - // Example: Stream #0:0: Video: h264 (High), yuv420p(progressive), 1920x1080 [SAR 1:1 DAR 16:9], 30 fps, 30 tbr, 90k tbn, 60 tbc - - const durationMatch = ffmpegOutput.match(/Duration: (\d+):(\d+):([\d.]+)/); - let duration = 0; - if (durationMatch) { - const [, h, m, s] = durationMatch; - duration = parseInt(h) * 3600 + parseInt(m) * 60 + parseFloat(s); - } - - const videoStreamMatch = ffmpegOutput.match( - /Video:.* (\d+)x(\d+)[^,]*, ([\d.]+) fps/ - ); - let width = 0, - height = 0, - fps = 0; - if (videoStreamMatch) { - width = parseInt(videoStreamMatch[1]); - height = parseInt(videoStreamMatch[2]); - fps = parseFloat(videoStreamMatch[3]); - } - - return { - duration, - width, - height, - fps, - }; -}; - -export const convertToWebM = async ( - videoFile: File, - onProgress?: (progress: number) => void -): Promise => { - const ffmpeg = await initFFmpeg(); - - const inputName = "input.mp4"; - const outputName = "output.webm"; - - // Set up progress callback - if (onProgress) { - ffmpeg.on("progress", ({ progress }) => { - onProgress(progress * 100); - }); - } - - // Write input file - await ffmpeg.writeFile( - inputName, - new Uint8Array(await videoFile.arrayBuffer()) - ); - - // Convert to WebM - await ffmpeg.exec([ - "-i", - inputName, - "-c:v", - "libvpx-vp9", - "-crf", - "30", - "-b:v", - "0", - "-c:a", - "libopus", - outputName, - ]); - - // Read output file - const data = await ffmpeg.readFile(outputName); - const blob = new Blob([data], { type: "video/webm" }); - - // Cleanup - await ffmpeg.deleteFile(inputName); - await ffmpeg.deleteFile(outputName); - - return blob; -}; - -export const extractAudio = async ( - videoFile: File, - format: "mp3" | "wav" = "mp3" -): Promise => { - const ffmpeg = await initFFmpeg(); - - const inputName = "input.mp4"; - const outputName = `output.${format}`; - - // Write input file - await ffmpeg.writeFile( - inputName, - new Uint8Array(await videoFile.arrayBuffer()) - ); - - // Extract audio - await ffmpeg.exec([ - "-i", - inputName, - "-vn", // Disable video - "-acodec", - format === "mp3" ? "libmp3lame" : "pcm_s16le", - outputName, - ]); - - // Read output file - const data = await ffmpeg.readFile(outputName); - const blob = new Blob([data], { type: `audio/${format}` }); - - // Cleanup - await ffmpeg.deleteFile(inputName); - await ffmpeg.deleteFile(outputName); - - return blob; -}; - -export const extractTimelineAudio = async ( - onProgress?: (progress: number) => void -): Promise => { - // Create fresh FFmpeg instance for this operation - const ffmpeg = new FFmpeg(); - - try { - await ffmpeg.load(); - } catch (error) { - console.error("Failed to load fresh FFmpeg instance:", error); - throw new Error("Unable to initialize audio processing. Please try again."); - } - - const timeline = useTimelineStore.getState(); - const mediaStore = useMediaStore.getState(); - - const tracks = timeline.tracks; - const totalDuration = timeline.getTotalDuration(); - - if (totalDuration === 0) { - const emptyAudioData = new ArrayBuffer(44); - return new Blob([emptyAudioData], { type: "audio/wav" }); - } - - if (onProgress) { - ffmpeg.on("progress", ({ progress }) => { - onProgress(progress * 100); - }); - } - - const audioElements: Array<{ - file: File; - startTime: number; - duration: number; - trimStart: number; - trimEnd: number; - trackMuted: boolean; - }> = []; - - for (const track of tracks) { - if (track.muted) continue; - - for (const element of track.elements) { - if (element.type === "media") { - const mediaItem = mediaStore.mediaItems.find( - (m) => m.id === element.mediaId - ); - if (!mediaItem) continue; - - if (mediaItem.type === "video" || mediaItem.type === "audio") { - audioElements.push({ - file: mediaItem.file, - startTime: element.startTime, - duration: element.duration, - trimStart: element.trimStart, - trimEnd: element.trimEnd, - trackMuted: track.muted || false, - }); - } - } - } - } - - if (audioElements.length === 0) { - // Return silent audio if no audio elements - const silentDuration = Math.max(1, totalDuration); // At least 1 second - try { - const silentAudio = await generateSilentAudio(silentDuration); - return silentAudio; - } catch (error) { - console.error("Failed to generate silent audio:", error); - throw new Error("Unable to generate audio for empty timeline."); - } - } - - // Create a complex filter to mix all audio sources - const inputFiles: string[] = []; - const filterInputs: string[] = []; - - try { - for (let i = 0; i < audioElements.length; i++) { - const element = audioElements[i]; - const inputName = `input_${i}.${element.file.name.split(".").pop()}`; - inputFiles.push(inputName); - - try { - await ffmpeg.writeFile( - inputName, - new Uint8Array(await element.file.arrayBuffer()) - ); - } catch (error) { - console.error(`Failed to write file ${element.file.name}:`, error); - throw new Error( - `Unable to process file: ${element.file.name}. The file may be corrupted or in an unsupported format.` - ); - } - - const actualStart = element.trimStart; - const actualDuration = - element.duration - element.trimStart - element.trimEnd; - - const filterName = `audio_${i}`; - filterInputs.push( - `[${i}:a]atrim=start=${actualStart}:duration=${actualDuration},asetpts=PTS-STARTPTS,adelay=${element.startTime * 1000}|${element.startTime * 1000}[${filterName}]` - ); - } - - const mixFilter = - audioElements.length === 1 - ? `[audio_0]aresample=44100,aformat=sample_fmts=s16:channel_layouts=stereo[out]` - : `${filterInputs.map((_, i) => `[audio_${i}]`).join("")}amix=inputs=${audioElements.length}:duration=longest:dropout_transition=2,aresample=44100,aformat=sample_fmts=s16:channel_layouts=stereo[out]`; - - const complexFilter = [...filterInputs, mixFilter].join(";"); - const outputName = "timeline_audio.wav"; - - const ffmpegArgs = [ - ...inputFiles.flatMap((name) => ["-i", name]), - "-filter_complex", - complexFilter, - "-map", - "[out]", - "-t", - totalDuration.toString(), - "-c:a", - "pcm_s16le", - "-ar", - "44100", - outputName, - ]; - - try { - await ffmpeg.exec(ffmpegArgs); - } catch (error) { - console.error("FFmpeg execution failed:", error); - throw new Error( - "Audio processing failed. Some audio files may be corrupted or incompatible." - ); - } - - const data = await ffmpeg.readFile(outputName); - const blob = new Blob([data], { type: "audio/wav" }); - - return blob; - } catch (error) { - for (const inputFile of inputFiles) { - try { - await ffmpeg.deleteFile(inputFile); - } catch (cleanupError) { - console.warn(`Failed to cleanup file ${inputFile}:`, cleanupError); - } - } - try { - await ffmpeg.deleteFile("timeline_audio.wav"); - } catch (cleanupError) { - console.warn("Failed to cleanup output file:", cleanupError); - } - - throw error; - } finally { - for (const inputFile of inputFiles) { - try { - await ffmpeg.deleteFile(inputFile); - } catch (cleanupError) {} - } - try { - await ffmpeg.deleteFile("timeline_audio.wav"); - } catch (cleanupError) {} - } -}; - -const generateSilentAudio = async (durationSeconds: number): Promise => { - const ffmpeg = await initFFmpeg(); - const outputName = "silent.wav"; - - try { - await ffmpeg.exec([ - "-f", - "lavfi", - "-i", - `anullsrc=channel_layout=stereo:sample_rate=44100`, - "-t", - durationSeconds.toString(), - "-c:a", - "pcm_s16le", - outputName, - ]); - - const data = await ffmpeg.readFile(outputName); - const blob = new Blob([data], { type: "audio/wav" }); - - return blob; - } catch (error) { - console.error("Failed to generate silent audio:", error); - throw error; - } finally { - try { - await ffmpeg.deleteFile(outputName); - } catch (cleanupError) { - // Silent cleanup - } - } -}; diff --git a/apps/web/src/lib/media-processing.ts b/apps/web/src/lib/media-processing.ts index 353c3113..15c71626 100644 --- a/apps/web/src/lib/media-processing.ts +++ b/apps/web/src/lib/media-processing.ts @@ -6,7 +6,7 @@ import { getImageDimensions, type MediaItem, } from "@/stores/media-store"; -import { generateThumbnail, getVideoInfo } from "./ffmpeg-utils"; +import { generateThumbnail, getVideoInfo } from "./mediabunny-utils"; export interface ProcessedMediaItem extends Omit {} @@ -37,33 +37,23 @@ export async function processMediaFiles( try { if (fileType === "image") { - // Get image dimensions const dimensions = await getImageDimensions(file); width = dimensions.width; height = dimensions.height; } else if (fileType === "video") { try { - // Use FFmpeg for comprehensive video info extraction - const videoInfo = await getVideoInfo(file); + const videoInfo = await getVideoInfo({ videoFile: file }); duration = videoInfo.duration; width = videoInfo.width; height = videoInfo.height; fps = videoInfo.fps; - // Generate thumbnail using FFmpeg - thumbnailUrl = await generateThumbnail(file, 1); + thumbnailUrl = await generateThumbnail({ + videoFile: file, + timeInSeconds: 1, + }); } catch (error) { - console.warn( - "FFmpeg processing failed, falling back to basic processing:", - error - ); - // Fallback to basic processing - const videoResult = await generateVideoThumbnail(file); - thumbnailUrl = videoResult.thumbnailUrl; - width = videoResult.width; - height = videoResult.height; - duration = await getMediaDuration(file); - // FPS will remain undefined for fallback + console.warn("Video processing failed", error); } } else if (fileType === "audio") { // For audio, we don't set width/height/fps (they'll be undefined) @@ -82,7 +72,6 @@ export async function processMediaFiles( fps, }); - // Yield back to the event loop to keep the UI responsive await new Promise((resolve) => setTimeout(resolve, 0)); completed += 1; diff --git a/apps/web/src/lib/mediabunny-utils.ts b/apps/web/src/lib/mediabunny-utils.ts new file mode 100644 index 00000000..28e6b97f --- /dev/null +++ b/apps/web/src/lib/mediabunny-utils.ts @@ -0,0 +1,311 @@ +import { FFmpeg } from "@ffmpeg/ffmpeg"; +import { useTimelineStore } from "@/stores/timeline-store"; +import { useMediaStore } from "@/stores/media-store"; +import { Input, ALL_FORMATS, BlobSource, VideoSampleSink } from "mediabunny"; + +let ffmpeg: FFmpeg | null = null; + +export const initFFmpeg = async (): Promise => { + if (ffmpeg) return ffmpeg; + + ffmpeg = new FFmpeg(); + await ffmpeg.load(); // Use default config + + return ffmpeg; +}; + +export async function generateThumbnail({ + videoFile, + timeInSeconds, +}: { + videoFile: File; + timeInSeconds: number; +}): Promise { + const input = new Input({ + source: new BlobSource(videoFile), + formats: ALL_FORMATS, + }); + + const videoTrack = await input.getPrimaryVideoTrack(); + if (!videoTrack) { + throw new Error("No video track found in the file"); + } + + // Check if we can decode this video + const canDecode = await videoTrack.canDecode(); + if (!canDecode) { + throw new Error("Video codec not supported for decoding"); + } + + const sink = new VideoSampleSink(videoTrack); + + const frame = await sink.getSample(timeInSeconds); + + if (!frame) { + throw new Error("Could not get frame at specified time"); + } + + const canvas = document.createElement("canvas"); + canvas.width = 320; + canvas.height = 240; + const ctx = canvas.getContext("2d"); + + if (!ctx) { + throw new Error("Could not get canvas context"); + } + + frame.draw(ctx, 0, 0, 320, 240); + + return new Promise((resolve, reject) => { + canvas.toBlob( + (blob) => { + if (blob) { + resolve(URL.createObjectURL(blob)); + } else { + reject(new Error("Failed to create thumbnail blob")); + } + }, + "image/jpeg", + 0.8 + ); + }); +} + +export async function getVideoInfo({ + videoFile, +}: { + videoFile: File; +}): Promise<{ + duration: number; + width: number; + height: number; + fps: number; +}> { + const input = new Input({ + source: new BlobSource(videoFile), + formats: ALL_FORMATS, + }); + + const duration = await input.computeDuration(); + const videoTrack = await input.getPrimaryVideoTrack(); + + if (!videoTrack) { + throw new Error("No video track found in the file"); + } + + // Get frame rate from packet statistics + const packetStats = await videoTrack.computePacketStats(100); + const fps = packetStats.averagePacketRate; + + return { + duration, + width: videoTrack.displayWidth, + height: videoTrack.displayHeight, + fps, + }; +} + +// Audio mixing for timeline - keeping FFmpeg for now due to complexity +// TODO: Replace with Mediabunny audio processing when implementing canvas preview +export const extractTimelineAudio = async ( + onProgress?: (progress: number) => void +): Promise => { + // Create fresh FFmpeg instance for this operation + const ffmpeg = new FFmpeg(); + + try { + await ffmpeg.load(); + } catch (error) { + console.error("Failed to load fresh FFmpeg instance:", error); + throw new Error("Unable to initialize audio processing. Please try again."); + } + + const timeline = useTimelineStore.getState(); + const mediaStore = useMediaStore.getState(); + + const tracks = timeline.tracks; + const totalDuration = timeline.getTotalDuration(); + + if (totalDuration === 0) { + const emptyAudioData = new ArrayBuffer(44); + return new Blob([emptyAudioData], { type: "audio/wav" }); + } + + if (onProgress) { + ffmpeg.on("progress", ({ progress }) => { + onProgress(progress * 100); + }); + } + + const audioElements: Array<{ + file: File; + startTime: number; + duration: number; + trimStart: number; + trimEnd: number; + trackMuted: boolean; + }> = []; + + for (const track of tracks) { + if (track.muted) continue; + + for (const element of track.elements) { + if (element.type === "media") { + const mediaItem = mediaStore.mediaItems.find( + (m) => m.id === element.mediaId + ); + if (!mediaItem) continue; + + if (mediaItem.type === "video" || mediaItem.type === "audio") { + audioElements.push({ + file: mediaItem.file, + startTime: element.startTime, + duration: element.duration, + trimStart: element.trimStart, + trimEnd: element.trimEnd, + trackMuted: track.muted || false, + }); + } + } + } + } + + if (audioElements.length === 0) { + // Return silent audio if no audio elements + const silentDuration = Math.max(1, totalDuration); // At least 1 second + try { + const silentAudio = await generateSilentAudio(silentDuration); + return silentAudio; + } catch (error) { + console.error("Failed to generate silent audio:", error); + throw new Error("Unable to generate audio for empty timeline."); + } + } + + // Create a complex filter to mix all audio sources + const inputFiles: string[] = []; + const filterInputs: string[] = []; + + try { + for (let i = 0; i < audioElements.length; i++) { + const element = audioElements[i]; + const inputName = `input_${i}.${element.file.name.split(".").pop()}`; + inputFiles.push(inputName); + + try { + await ffmpeg.writeFile( + inputName, + new Uint8Array(await element.file.arrayBuffer()) + ); + } catch (error) { + console.error(`Failed to write file ${element.file.name}:`, error); + throw new Error( + `Unable to process file: ${element.file.name}. The file may be corrupted or in an unsupported format.` + ); + } + + const actualStart = element.trimStart; + const actualDuration = + element.duration - element.trimStart - element.trimEnd; + + const filterName = `audio_${i}`; + filterInputs.push( + `[${i}:a]atrim=start=${actualStart}:duration=${actualDuration},asetpts=PTS-STARTPTS,adelay=${element.startTime * 1000}|${element.startTime * 1000}[${filterName}]` + ); + } + + const mixFilter = + audioElements.length === 1 + ? `[audio_0]aresample=44100,aformat=sample_fmts=s16:channel_layouts=stereo[out]` + : `${filterInputs.map((_, i) => `[audio_${i}]`).join("")}amix=inputs=${audioElements.length}:duration=longest:dropout_transition=2,aresample=44100,aformat=sample_fmts=s16:channel_layouts=stereo[out]`; + + const complexFilter = [...filterInputs, mixFilter].join(";"); + const outputName = "timeline_audio.wav"; + + const ffmpegArgs = [ + ...inputFiles.flatMap((name) => ["-i", name]), + "-filter_complex", + complexFilter, + "-map", + "[out]", + "-t", + totalDuration.toString(), + "-c:a", + "pcm_s16le", + "-ar", + "44100", + outputName, + ]; + + try { + await ffmpeg.exec(ffmpegArgs); + } catch (error) { + console.error("FFmpeg execution failed:", error); + throw new Error( + "Audio processing failed. Some audio files may be corrupted or incompatible." + ); + } + + const data = await ffmpeg.readFile(outputName); + const blob = new Blob([data], { type: "audio/wav" }); + + return blob; + } catch (error) { + for (const inputFile of inputFiles) { + try { + await ffmpeg.deleteFile(inputFile); + } catch (cleanupError) { + console.warn(`Failed to cleanup file ${inputFile}:`, cleanupError); + } + } + try { + await ffmpeg.deleteFile("timeline_audio.wav"); + } catch (cleanupError) { + console.warn("Failed to cleanup output file:", cleanupError); + } + + throw error; + } finally { + for (const inputFile of inputFiles) { + try { + await ffmpeg.deleteFile(inputFile); + } catch (cleanupError) {} + } + try { + await ffmpeg.deleteFile("timeline_audio.wav"); + } catch (cleanupError) {} + } +}; + +const generateSilentAudio = async (durationSeconds: number): Promise => { + const ffmpeg = await initFFmpeg(); + const outputName = "silent.wav"; + + try { + await ffmpeg.exec([ + "-f", + "lavfi", + "-i", + `anullsrc=channel_layout=stereo:sample_rate=44100`, + "-t", + durationSeconds.toString(), + "-c:a", + "pcm_s16le", + outputName, + ]); + + const data = await ffmpeg.readFile(outputName); + const blob = new Blob([data], { type: "audio/wav" }); + + return blob; + } catch (error) { + console.error("Failed to generate silent audio:", error); + throw error; + } finally { + try { + await ffmpeg.deleteFile(outputName); + } catch (cleanupError) { + // Silent cleanup + } + } +}; diff --git a/apps/web/src/lib/timeline-renderer.ts b/apps/web/src/lib/timeline-renderer.ts new file mode 100644 index 00000000..1c1eafcd --- /dev/null +++ b/apps/web/src/lib/timeline-renderer.ts @@ -0,0 +1,179 @@ +import { Input, ALL_FORMATS, BlobSource, VideoSampleSink } from "mediabunny"; +import type { TimelineTrack } from "@/types/timeline"; +import type { MediaItem } from "@/stores/media-store"; + +export interface RenderContext { + ctx: CanvasRenderingContext2D; + time: number; + canvasWidth: number; + canvasHeight: number; + tracks: TimelineTrack[]; + mediaItems: MediaItem[]; + backgroundColor?: string; + useCache?: boolean; + cache?: Map< + string, + Map< + number, + { + draw: ( + c: CanvasRenderingContext2D, + x: number, + y: number, + w: number, + h: number + ) => void; + } + > + >; + fps: number; +} + +export async function renderTimelineFrame({ + ctx, + time, + canvasWidth, + canvasHeight, + tracks, + mediaItems, + backgroundColor, + useCache = true, + cache, + fps, +}: RenderContext): Promise { + // Background + ctx.clearRect(0, 0, canvasWidth, canvasHeight); + if (backgroundColor && backgroundColor !== "transparent") { + ctx.fillStyle = backgroundColor; + ctx.fillRect(0, 0, canvasWidth, canvasHeight); + } + + const scaleX = 1; + const scaleY = 1; + const idToMedia = new Map(mediaItems.map((m) => [m.id, m] as const)); + const active: Array<{ + track: TimelineTrack; + element: TimelineTrack["elements"][number]; + mediaItem: MediaItem | null; + }> = []; + + 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; + const elementStart = element.startTime; + const elementEnd = + element.startTime + + (element.duration - element.trimStart - element.trimEnd); + if (time >= elementStart && time < elementEnd) { + let mediaItem: MediaItem | null = null; + if (element.type === "media") { + mediaItem = + element.mediaId === "test" + ? null + : idToMedia.get(element.mediaId) || null; + } + active.push({ track, element, mediaItem }); + } + } + } + + 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); + + const localTime = time - element.startTime + element.trimStart; + const sample = await sink.getSample(localTime); + if (!sample) 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); + } + if (mediaItem.type === "image") { + 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); + }); + const mediaW = Math.max( + 1, + mediaItem.width || img.naturalWidth || canvasWidth + ); + const mediaH = Math.max( + 1, + mediaItem.height || img.naturalHeight || 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(img, drawX, drawY, drawW, drawH); + } + } + if (element.type === "text") { + const posX = canvasWidth / 2 + (element as any).x * scaleX; + const posY = canvasHeight / 2 + (element as any).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.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 textW = metrics.width; + const textH = ascent + descent; + const padX = 8 * scaleX; + const padY = 4 * scaleX; + if ((element as any).backgroundColor) { + ctx.save(); + ctx.fillStyle = (element as any).backgroundColor; + let bgLeft = -textW / 2; + if (ctx.textAlign === "left") bgLeft = 0; + if (ctx.textAlign === "right") bgLeft = -textW; + ctx.fillRect( + bgLeft - padX, + -textH / 2 - padY, + textW + padX * 2, + textH + padY * 2 + ); + ctx.restore(); + } + ctx.fillText((element as any).content, 0, 0); + ctx.restore(); + } + } +} diff --git a/bun.lock b/bun.lock index ea7c8c9a..95f7c874 100644 --- a/bun.lock +++ b/bun.lock @@ -4,6 +4,7 @@ "": { "name": "opencut", "dependencies": { + "mediabunny": "^1.9.3", "next": "^15.3.4", "react-country-flag": "^3.1.0", "wavesurfer.js": "^7.9.8", @@ -558,6 +559,10 @@ "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], + "@types/dom-mediacapture-transform": ["@types/dom-mediacapture-transform@0.1.11", "", { "dependencies": { "@types/dom-webcodecs": "*" } }, "sha512-Y2p+nGf1bF2XMttBnsVPHUWzRRZzqUoJAKmiP10b5umnO6DDrWI0BrGDJy1pOHoOULVmGSfFNkQrAlC5dcj6nQ=="], + + "@types/dom-webcodecs": ["@types/dom-webcodecs@0.1.13", "", {}, "sha512-O5hkiFIcjjszPIYyUSyvScyvrBoV3NOEEZx/pMlsu44TKzWNkLVBBxnxJz42in5n3QIolYOcBYFCPZZ0h8SkwQ=="], + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], "@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="], @@ -896,6 +901,8 @@ "mdast-util-to-string": ["mdast-util-to-string@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="], + "mediabunny": ["mediabunny@1.9.3", "", { "dependencies": { "@types/dom-mediacapture-transform": "^0.1.11", "@types/dom-webcodecs": "0.1.13" } }, "sha512-uVspdzrpwvW8NxtGspHPOJvdNSY0dNFEmOQngSheX9PYX9oXijZENBjfYWOgm9c9IgSSlN2bdJF9iyPyY3f+fQ=="], + "micromark": ["micromark@4.0.2", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="], "micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="], @@ -1320,10 +1327,10 @@ "motion/framer-motion/motion-utils": ["motion-utils@12.23.6", "", {}, "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ=="], - "opencut/@types/node/undici-types": ["undici-types@7.10.0", "", {}, "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag=="], - "next/postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + "opencut/@types/node/undici-types": ["undici-types@7.10.0", "", {}, "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag=="], + "opencut/next/@next/env": ["@next/env@15.4.5", "", {}, "sha512-ruM+q2SCOVCepUiERoxOmZY9ZVoecR3gcXNwCYZRvQQWRjhOiPJGmQ2fAiLR6YKWXcSAh7G79KEFxN3rwhs4LQ=="], "opencut/next/@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@15.4.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-84dAN4fkfdC7nX6udDLz9GzQlMUwEMKD7zsseXrl7FTeIItF8vpk1lhLEnsotiiDt+QFu3O1FVWnqwcRD2U3KA=="], diff --git a/package.json b/package.json index 45a98282..616c97f7 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "format": "npx ultracite@latest format" }, "dependencies": { + "mediabunny": "^1.9.3", "next": "^15.3.4", "react-country-flag": "^3.1.0", "wavesurfer.js": "^7.9.8" -- cgit v1.3.1 From 03e68e77a98512ad66ea91aedfa7ece7fad60683 Mon Sep 17 00:00:00 2001 From: "Danilo Arantes F." <33551518+DaniloArantesF@users.noreply.github.com> Date: Mon, 25 Aug 2025 06:37:33 -0300 Subject: fix(editor): Don't log expected errors in `AudioWaveform` during unmount (#565) * Update README.md * fix(editor): Don't log expected errors in `AudioWaveform` during unmount --------- Co-authored-by: Maze <167211895+mazeincoding@users.noreply.github.com> Co-authored-by: Maze Winther --- apps/web/src/components/editor/audio-waveform.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) (limited to 'apps') diff --git a/apps/web/src/components/editor/audio-waveform.tsx b/apps/web/src/components/editor/audio-waveform.tsx index 79bf401d..60406f61 100644 --- a/apps/web/src/components/editor/audio-waveform.tsx +++ b/apps/web/src/components/editor/audio-waveform.tsx @@ -67,8 +67,8 @@ const AudioWaveform: React.FC = ({ }); newWaveSurfer.on("error", (err) => { - console.error("WaveSurfer error:", err); if (mounted) { + console.error("WaveSurfer error:", err); setError(true); setIsLoading(false); } @@ -76,8 +76,8 @@ const AudioWaveform: React.FC = ({ await newWaveSurfer.load(audioUrl); } catch (err) { - console.error("Failed to initialize WaveSurfer:", err); if (mounted) { + console.error("Failed to initialize WaveSurfer:", err); setError(true); setIsLoading(false); } -- cgit v1.3.1 From 7434f909491ca81e880bb85908d021d343136b12 Mon Sep 17 00:00:00 2001 From: Taesu <166604494+bytaesu@users.noreply.github.com> Date: Tue, 26 Aug 2025 04:10:48 +0900 Subject: fix: use svh to prevent hero mobile layout shift (#572) * Update README.md * fix: use svh to prevent mobile layout shift --------- Co-authored-by: Maze <167211895+mazeincoding@users.noreply.github.com> Co-authored-by: Maze Winther --- apps/web/src/components/landing/hero.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'apps') diff --git a/apps/web/src/components/landing/hero.tsx b/apps/web/src/components/landing/hero.tsx index 5c629433..1b557412 100644 --- a/apps/web/src/components/landing/hero.tsx +++ b/apps/web/src/components/landing/hero.tsx @@ -12,7 +12,7 @@ import Link from "next/link"; export function Hero() { return ( -
+
Date: Tue, 26 Aug 2025 11:44:00 +0200 Subject: style: sonner position --- apps/web/src/components/ui/sonner.tsx | 4 ++++ 1 file changed, 4 insertions(+) (limited to 'apps') diff --git a/apps/web/src/components/ui/sonner.tsx b/apps/web/src/components/ui/sonner.tsx index 549cf841..13694b43 100644 --- a/apps/web/src/components/ui/sonner.tsx +++ b/apps/web/src/components/ui/sonner.tsx @@ -12,6 +12,8 @@ const Toaster = ({ ...props }: ToasterProps) => { { "group-[.toast]:bg-muted group-[.toast]:text-muted-foreground", }, }} + expand={false} + richColors {...props} /> ); -- cgit v1.3.1 From 2f7cd11ca123884969f90a94e59593d355518839 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Tue, 26 Aug 2025 11:51:08 +0200 Subject: refactor: type-safety --- .../components/editor/media-panel/views/stickers.tsx | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) (limited to 'apps') 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 7d3e073e..7e05e889 100644 --- a/apps/web/src/components/editor/media-panel/views/stickers.tsx +++ b/apps/web/src/components/editor/media-panel/views/stickers.tsx @@ -38,6 +38,7 @@ import { import { cn, generateUUID } from "@/lib/utils"; import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; import Image from "next/image"; +import type { MediaItem } from "@/stores/media-store"; import { DraggableMediaItem } from "@/components/ui/draggable-item"; import { InputWithBack } from "@/components/ui/input-with-back"; import { StickerCategory } from "@/stores/stickers-store"; @@ -286,9 +287,9 @@ function StickersContentView({ category }: { category: StickerCategory }) { throw new Error("Failed to download sticker"); } - const mediaItem = { + const mediaItem: Omit = { name: iconName.replace(":", "-"), - type: "image" as const, + type: "image", file, url: URL.createObjectURL(file), width: 200, @@ -565,7 +566,12 @@ interface StickerItemProps { capSize?: boolean; } -function StickerItem({ iconName, onAdd, isAdding, capSize = false }: StickerItemProps) { +function StickerItem({ + iconName, + onAdd, + isAdding, + capSize = false, +}: StickerItemProps) { const [imageError, setImageError] = useState(false); const [hostIndex, setHostIndex] = useState(0); @@ -601,7 +607,10 @@ function StickerItem({ iconName, onAdd, isAdding, capSize = false }: StickerItem className="w-full h-full object-contain" style={ capSize - ? { maxWidth: "var(--sticker-max, 160px)", maxHeight: "var(--sticker-max, 160px)" } + ? { + maxWidth: "var(--sticker-max, 160px)", + maxHeight: "var(--sticker-max, 160px)", + } : undefined } onError={() => { -- cgit v1.3.1 From b459b46802a760a4a81ce91fdf24390e07375fb0 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Tue, 26 Aug 2025 15:04:25 +0200 Subject: refactor: type-safety & separate concerns --- .../editor/media-panel/views/captions.tsx | 18 +- .../components/editor/media-panel/views/media.tsx | 64 ++-- .../editor/media-panel/views/stickers.tsx | 20 +- .../components/editor/media-panel/views/text.tsx | 42 +-- apps/web/src/components/editor/preview-panel.tsx | 18 +- .../components/editor/properties-panel/index.tsx | 8 +- apps/web/src/components/editor/timeline/index.tsx | 30 +- .../editor/timeline/timeline-element.tsx | 10 +- .../components/editor/timeline/timeline-track.tsx | 36 +-- apps/web/src/components/ui/draggable-item.tsx | 3 +- apps/web/src/constants/text-constants.ts | 27 ++ apps/web/src/hooks/use-aspect-ratio.ts | 12 +- apps/web/src/hooks/use-timeline-element-resize.ts | 21 +- apps/web/src/lib/media-processing.ts | 5 +- apps/web/src/lib/mediabunny-utils.ts | 8 +- apps/web/src/lib/storage/storage-service.ts | 18 +- apps/web/src/lib/timeline-renderer.ts | 338 ++++++++++----------- apps/web/src/stores/media-store.ts | 78 ++--- apps/web/src/stores/sounds-store.ts | 6 +- apps/web/src/stores/timeline-store.ts | 189 +++++------- apps/web/src/types/media.ts | 17 ++ apps/web/src/types/timeline.ts | 2 +- 22 files changed, 429 insertions(+), 541 deletions(-) create mode 100644 apps/web/src/constants/text-constants.ts create mode 100644 apps/web/src/types/media.ts (limited to 'apps') 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 6e18781c..393481a5 100644 --- a/apps/web/src/components/editor/media-panel/views/captions.tsx +++ b/apps/web/src/components/editor/media-panel/views/captions.tsx @@ -6,6 +6,7 @@ import { useState, useRef, useEffect } from "react"; import { extractTimelineAudio } from "@/lib/mediabunny-utils"; import { encryptWithRandomKey, arrayBufferToBase64 } from "@/lib/zk-encryption"; import { useTimelineStore } from "@/stores/timeline-store"; +import { DEFAULT_TEXT_ELEMENT } from "@/constants/text-constants"; import { Loader2, Shield, Trash2, Upload } from "lucide-react"; import { Dialog, @@ -162,24 +163,13 @@ export function Captions() { // Add all caption elements to the same track shortCaptions.forEach((caption, index) => { addElementToTrack(captionTrackId, { - type: "text", + ...DEFAULT_TEXT_ELEMENT, name: `Caption ${index + 1}`, content: caption.text, duration: caption.duration, startTime: caption.startTime, - trimStart: 0, - trimEnd: 0, - fontSize: 65, - fontFamily: "Arial", - color: "#ffffff", - textAlign: "center", - fontWeight: "bold", - fontStyle: "normal", - textDecoration: "none", - x: 0, - y: 0, - rotation: 0, - opacity: 1, + fontSize: 65, // Larger for captions + fontWeight: "bold", // Bold for captions } as TextElement); }); diff --git a/apps/web/src/components/editor/media-panel/views/media.tsx b/apps/web/src/components/editor/media-panel/views/media.tsx index c84dde70..07a44a7c 100644 --- a/apps/web/src/components/editor/media-panel/views/media.tsx +++ b/apps/web/src/components/editor/media-panel/views/media.tsx @@ -2,7 +2,8 @@ import { useDragDrop } from "@/hooks/use-drag-drop"; import { processMediaFiles } from "@/lib/media-processing"; -import { useMediaStore, type MediaItem } from "@/stores/media-store"; +import { useMediaStore } from "@/stores/media-store"; +import { MediaFile } from "@/types/media"; import { ArrowDown01, CloudUpload, @@ -13,7 +14,7 @@ import { Music, Video, } from "lucide-react"; -import { useRef, useState, useMemo, useEffect } from "react"; +import { useRef, useState, useMemo } from "react"; import { useHighlightScroll } from "@/hooks/use-highlight-scroll"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; @@ -47,7 +48,7 @@ function MediaItemWithContextMenu({ children, onRemove, }: { - item: MediaItem; + item: MediaFile; children: React.ReactNode; onRemove: (e: React.MouseEvent, id: string) => Promise; }) { @@ -68,14 +69,12 @@ function MediaItemWithContextMenu({ } export function MediaView() { - const { mediaItems, addMediaItem, removeMediaItem } = useMediaStore(); + const { mediaFiles, addMediaFile, removeMediaFile } = useMediaStore(); const { activeProject } = useProjectStore(); const { mediaViewMode, setMediaViewMode } = usePanelStore(); const fileInputRef = useRef(null); const [isProcessing, setIsProcessing] = useState(false); const [progress, setProgress] = useState(0); - const [searchQuery, setSearchQuery] = useState(""); - const [mediaFilter, setMediaFilter] = useState("all"); const [sortBy, setSortBy] = useState<"name" | "type" | "duration" | "size">( "name" ); @@ -96,16 +95,13 @@ export function MediaView() { setIsProcessing(true); setProgress(0); try { - // Process files (extract metadata, generate thumbnails, etc.) const processedItems = await processMediaFiles(files, (p) => setProgress(p) ); - // Add each processed media item to the store for (const item of processedItems) { - await addMediaItem(activeProject.id, item); + await addMediaFile(activeProject.id, item); } } catch (error) { - // Show error toast if processing fails console.error("Error processing files:", error); toast.error("Failed to process files"); } finally { @@ -122,13 +118,11 @@ export function MediaView() { const handleFileSelect = () => fileInputRef.current?.click(); // Open file picker const handleFileChange = (e: React.ChangeEvent) => { - // When files are selected via file picker, process them if (e.target.files) processFiles(e.target.files); e.target.value = ""; // Reset input }; const handleRemove = async (e: React.MouseEvent, id: string) => { - // Remove a media item from the store e.stopPropagation(); if (!activeProject) { @@ -136,8 +130,7 @@ export function MediaView() { return; } - // Media store now handles cascade deletion automatically - await removeMediaItem(activeProject.id, id); + await removeMediaFile(activeProject.id, id); }; const formatDuration = (duration: number) => { @@ -147,28 +140,15 @@ export function MediaView() { return `${min}:${sec.toString().padStart(2, "0")}`; }; - const [filteredMediaItems, setFilteredMediaItems] = useState(mediaItems); - - useEffect(() => { - let filtered = mediaItems.filter((item) => { + const filteredMediaItems = useMemo(() => { + let filtered = mediaFiles.filter((item) => { if (item.ephemeral) return false; - if (mediaFilter && mediaFilter !== "all" && item.type !== mediaFilter) { - return false; - } - - if ( - searchQuery && - !item.name.toLowerCase().includes(searchQuery.toLowerCase()) - ) { - return false; - } - return true; }); filtered.sort((a, b) => { - let valueA: any; - let valueB: any; + let valueA: string | number; + let valueB: string | number; switch (sortBy) { case "name": @@ -196,8 +176,8 @@ export function MediaView() { return 0; }); - setFilteredMediaItems(filtered); - }, [mediaItems, mediaFilter, searchQuery, sortBy, sortOrder]); + return filtered; + }, [mediaFiles, sortBy, sortOrder]); const previewComponents = useMemo(() => { const previews = new Map(); @@ -276,7 +256,7 @@ export function MediaView() { return previews; }, [filteredMediaItems]); - const renderPreview = (item: MediaItem) => previewComponents.get(item.id); + const renderPreview = (item: MediaFile) => previewComponents.get(item.id); return ( <> @@ -481,12 +461,14 @@ function GridView({ highlightedId, registerElement, }: { - filteredMediaItems: MediaItem[]; - renderPreview: (item: MediaItem) => React.ReactNode; + filteredMediaItems: MediaFile[]; + renderPreview: (item: MediaFile) => React.ReactNode; handleRemove: (e: React.MouseEvent, id: string) => Promise; highlightedId: string | null; registerElement: (id: string, element: HTMLElement | null) => void; }) { + const { addElementAtTime } = useTimelineStore(); + return (
- useTimelineStore.getState().addMediaAtTime(item, currentTime) + addElementAtTime(item, currentTime) } rounded={false} variant="card" @@ -527,12 +509,14 @@ function ListView({ highlightedId, registerElement, }: { - filteredMediaItems: MediaItem[]; - renderPreview: (item: MediaItem) => React.ReactNode; + filteredMediaItems: MediaFile[]; + renderPreview: (item: MediaFile) => React.ReactNode; handleRemove: (e: React.MouseEvent, id: string) => Promise; highlightedId: string | null; registerElement: (id: string, element: HTMLElement | null) => void; }) { + const { addElementAtTime } = useTimelineStore(); + return (
{filteredMediaItems.map((item) => ( @@ -548,7 +532,7 @@ function ListView({ }} showPlusOnDrag={false} onAddToTimeline={(currentTime) => - useTimelineStore.getState().addMediaAtTime(item, currentTime) + addElementAtTime(item, currentTime) } variant="compact" isHighlighted={highlightedId === item.id} 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 7e05e889..41afe285 100644 --- a/apps/web/src/components/editor/media-panel/views/stickers.tsx +++ b/apps/web/src/components/editor/media-panel/views/stickers.tsx @@ -35,10 +35,10 @@ import { ICONIFY_HOSTS, POPULAR_COLLECTIONS, } from "@/lib/iconify-api"; -import { cn, generateUUID } from "@/lib/utils"; +import { cn } from "@/lib/utils"; import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; import Image from "next/image"; -import type { MediaItem } from "@/stores/media-store"; +import type { MediaFile } from "@/types/media"; import { DraggableMediaItem } from "@/components/ui/draggable-item"; import { InputWithBack } from "@/components/ui/input-with-back"; import { StickerCategory } from "@/stores/stickers-store"; @@ -186,9 +186,9 @@ function EmptyView({ message }: { message: string }) { function StickersContentView({ category }: { category: StickerCategory }) { const { activeProject } = useProjectStore(); - const { addMediaAtTime } = useTimelineStore(); + const { addElementAtTime } = useTimelineStore(); const { currentTime } = usePlaybackStore(); - const { addMediaItem } = useMediaStore(); + const { addMediaFile } = useMediaStore(); const { searchQuery, selectedCollection, @@ -287,7 +287,7 @@ function StickersContentView({ category }: { category: StickerCategory }) { throw new Error("Failed to download sticker"); } - const mediaItem: Omit = { + const mediaItem: Omit = { name: iconName.replace(":", "-"), type: "image", file, @@ -298,16 +298,16 @@ function StickersContentView({ category }: { category: StickerCategory }) { ephemeral: false, }; - await addMediaItem(activeProject.id, mediaItem); + await addMediaFile(activeProject.id, mediaItem); const added = useMediaStore .getState() - .mediaItems.find( + .mediaFiles.find( (m) => m.url === mediaItem.url && m.name === mediaItem.name ); if (!added) throw new Error("Sticker not in media store"); - addMediaAtTime(added, currentTime); + addElementAtTime(added, currentTime); toast.success(`Added "${iconName}" to timeline`); } catch (error) { @@ -640,8 +640,8 @@ function StickerItem({ name={displayName} preview={preview} dragData={{ - type: "sticker", - iconName: iconName, + id: "sticker-placeholder", + type: "image", name: displayName, }} onAddToTimeline={() => onAdd(iconName)} diff --git a/apps/web/src/components/editor/media-panel/views/text.tsx b/apps/web/src/components/editor/media-panel/views/text.tsx index 49eacbc7..5d522c84 100644 --- a/apps/web/src/components/editor/media-panel/views/text.tsx +++ b/apps/web/src/components/editor/media-panel/views/text.tsx @@ -1,31 +1,7 @@ import { DraggableMediaItem } from "@/components/ui/draggable-item"; import { BaseView } from "./base-view"; -import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; import { useTimelineStore } from "@/stores/timeline-store"; -import { type TextElement } from "@/types/timeline"; - -const textData: TextElement = { - id: "default-text", - type: "text", - name: "Default text", - content: "Default text", - fontSize: 48, - fontFamily: "Arial", - color: "#ffffff", - backgroundColor: "transparent", - textAlign: "center" as const, - fontWeight: "normal" as const, - fontStyle: "normal" as const, - textDecoration: "none" as const, - x: 0, - y: 0, - rotation: 0, - opacity: 1, - duration: TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION, - startTime: 0, - trimStart: 0, - trimEnd: 0, -}; +import { DEFAULT_TEXT_ELEMENT } from "@/constants/text-constants"; export function TextView() { return ( @@ -38,14 +14,20 @@ export function TextView() {
} dragData={{ - id: textData.id, - type: textData.type, - name: textData.name, - content: textData.content, + id: "temp-text-id", + type: DEFAULT_TEXT_ELEMENT.type, + name: DEFAULT_TEXT_ELEMENT.name, + content: DEFAULT_TEXT_ELEMENT.content, }} aspectRatio={1} onAddToTimeline={(currentTime) => - useTimelineStore.getState().addTextAtTime(textData, currentTime) + useTimelineStore.getState().addElementAtTime( + { + ...DEFAULT_TEXT_ELEMENT, + id: "temp-text-id", + }, + currentTime + ) } showLabel={false} /> diff --git a/apps/web/src/components/editor/preview-panel.tsx b/apps/web/src/components/editor/preview-panel.tsx index 99c4edef..d988e7fb 100644 --- a/apps/web/src/components/editor/preview-panel.tsx +++ b/apps/web/src/components/editor/preview-panel.tsx @@ -2,13 +2,13 @@ import { useTimelineStore } from "@/stores/timeline-store"; import { TimelineElement, TimelineTrack } from "@/types/timeline"; -import { useMediaStore, type MediaItem } from "@/stores/media-store"; +import { useMediaStore } from "@/stores/media-store"; +import { MediaFile } from "@/types/media"; import { usePlaybackStore } from "@/stores/playback-store"; import { useEditorStore } from "@/stores/editor-store"; import { Button } from "@/components/ui/button"; import { Play, Pause, Expand, SkipBack, SkipForward } from "lucide-react"; import { useState, useRef, useEffect, useCallback } from "react"; -import { VideoSampleSink } from "mediabunny"; import { renderTimelineFrame } from "@/lib/timeline-renderer"; import { cn } from "@/lib/utils"; import { formatTimeCode } from "@/lib/time"; @@ -33,12 +33,12 @@ import { PLATFORM_LAYOUTS, type PlatformLayout } from "@/stores/editor-store"; interface ActiveElement { element: TimelineElement; track: TimelineTrack; - mediaItem: MediaItem | null; + mediaItem: MediaFile | null; } export function PreviewPanel() { const { tracks, getTotalDuration, updateTextElement } = useTimelineStore(); - const { mediaItems } = useMediaStore(); + const { mediaFiles } = useMediaStore(); const { currentTime, toggle, setCurrentTime } = usePlaybackStore(); const { isPlaying, volume, muted } = usePlaybackStore(); const { activeProject } = useProjectStore(); @@ -264,7 +264,7 @@ export function PreviewPanel() { mediaItem = element.mediaId === "test" ? null - : mediaItems.find((item) => item.id === element.mediaId) || + : mediaFiles.find((item) => item.id === element.mediaId) || null; } activeElements.push({ element, track, mediaItem }); @@ -334,7 +334,7 @@ export function PreviewPanel() { const gain = audioGainRef.current!; const tracksSnapshot = useTimelineStore.getState().tracks; - const mediaList = useMediaStore.getState().mediaItems; + const mediaList = mediaFiles; const idToMedia = new Map(mediaList.map((m) => [m.id, m] as const)); const playbackNow = usePlaybackStore.getState().currentTime; @@ -447,7 +447,7 @@ export function PreviewPanel() { } playingSourcesRef.current.clear(); }; - }, [isPlaying, volume, muted, mediaItems]); + }, [isPlaying, volume, muted, mediaFiles]); // Canvas: draw current frame for visible elements using offscreen compositing useEffect(() => { @@ -533,13 +533,11 @@ export function PreviewPanel() { canvasWidth: displayWidth, canvasHeight: displayHeight, tracks, - mediaItems, + mediaFiles, backgroundColor: activeProject?.backgroundType === "blur" ? "transparent" : activeProject?.backgroundColor || "#000000", - useCache: false, - fps: activeProject?.fps || DEFAULT_FPS, }); // Blit offscreen to visible canvas diff --git a/apps/web/src/components/editor/properties-panel/index.tsx b/apps/web/src/components/editor/properties-panel/index.tsx index 6c79327e..150f9512 100644 --- a/apps/web/src/components/editor/properties-panel/index.tsx +++ b/apps/web/src/components/editor/properties-panel/index.tsx @@ -10,7 +10,7 @@ import { SquareSlashIcon } from "lucide-react"; export function PropertiesPanel() { const { selectedElements, tracks } = useTimelineStore(); - const { mediaItems } = useMediaStore(); + const { mediaFiles } = useMediaStore(); return ( <> @@ -28,11 +28,11 @@ export function PropertiesPanel() { ); } if (element?.type === "media") { - const mediaItem = mediaItems.find( - (item) => item.id === element.mediaId + const mediaFile = mediaFiles.find( + (file) => file.id === element.mediaId ); - if (mediaItem?.type === "audio") { + if (mediaFile?.type === "audio") { return ; } diff --git a/apps/web/src/components/editor/timeline/index.tsx b/apps/web/src/components/editor/timeline/index.tsx index 284384eb..49769989 100644 --- a/apps/web/src/components/editor/timeline/index.tsx +++ b/apps/web/src/components/editor/timeline/index.tsx @@ -82,13 +82,11 @@ export function Timeline() { toggleTrackMute, dragState, } = useTimelineStore(); - const { mediaItems, addMediaItem } = useMediaStore(); + const { mediaFiles, addMediaFile } = useMediaStore(); const { activeProject } = useProjectStore(); - const { currentTime, duration, seek, setDuration, isPlaying, toggle } = - usePlaybackStore(); + const { currentTime, duration, seek, setDuration } = usePlaybackStore(); const [isDragOver, setIsDragOver] = useState(false); - const [isProcessing, setIsProcessing] = useState(false); - const [progress, setProgress] = useState(0); + const { addElementToNewTrack } = useTimelineStore(); const dragCounterRef = useRef(0); const timelineRef = useRef(null); const rulerRef = useRef(null); @@ -456,10 +454,10 @@ export function Timeline() { if (dragData.type === "text") { // Always create new text track to avoid overlaps - useTimelineStore.getState().addTextToNewTrack(dragData); + addElementToNewTrack(dragData); } else { // Handle media items - const mediaItem = mediaItems.find( + const mediaItem = mediaFiles.find( (item: any) => item.id === dragData.id ); if (!mediaItem) { @@ -467,7 +465,7 @@ export function Timeline() { return; } - useTimelineStore.getState().addMediaToNewTrack(mediaItem); + addElementToNewTrack(mediaItem); } } catch (error) { console.error("Error parsing dropped item data:", error); @@ -480,17 +478,12 @@ export function Timeline() { return; } - setIsProcessing(true); - setProgress(0); try { - const processedItems = await processMediaFiles( - e.dataTransfer.files, - (p) => setProgress(p) - ); + const processedItems = await processMediaFiles(e.dataTransfer.files); for (const processedItem of processedItems) { - await addMediaItem(activeProject.id, processedItem); - const currentMediaItems = useMediaStore.getState().mediaItems; - const addedItem = currentMediaItems.find( + await addMediaFile(activeProject.id, processedItem); + const currentMediaFiles = mediaFiles; + const addedItem = currentMediaFiles.find( (item) => item.name === processedItem.name && item.url === processedItem.url ); @@ -516,9 +509,6 @@ export function Timeline() { // Show error if file processing fails console.error("Error processing external files:", error); toast.error("Failed to process dropped files"); - } finally { - setIsProcessing(false); - setProgress(0); } } }; diff --git a/apps/web/src/components/editor/timeline/timeline-element.tsx b/apps/web/src/components/editor/timeline/timeline-element.tsx index a1a33837..300c4c7b 100644 --- a/apps/web/src/components/editor/timeline/timeline-element.tsx +++ b/apps/web/src/components/editor/timeline/timeline-element.tsx @@ -40,10 +40,8 @@ export function TimelineElement({ onElementMouseDown, onElementClick, }: TimelineElementProps) { - const { mediaItems } = useMediaStore(); + const { mediaFiles } = useMediaStore(); const { - updateElementTrim, - updateElementDuration, removeElementFromTrack, removeElementFromTrackWithRipple, dragState, @@ -58,7 +56,7 @@ export function TimelineElement({ const mediaItem = element.type === "media" - ? mediaItems.find((item) => item.id === element.mediaId) + ? mediaFiles.find((file) => file.id === element.mediaId) : null; const hasAudio = mediaItem?.type === "audio" || mediaItem?.type === "video"; @@ -67,8 +65,6 @@ export function TimelineElement({ element, track, zoomLevel, - onUpdateTrim: updateElementTrim, - onUpdateDuration: updateElementDuration, }); const { requestRevealMedia } = useMediaPanelStore.getState(); @@ -190,7 +186,7 @@ export function TimelineElement({ } // Render media element -> - const mediaItem = mediaItems.find((item) => item.id === element.mediaId); + const mediaItem = mediaFiles.find((file) => file.id === element.mediaId); if (!mediaItem) { return ( diff --git a/apps/web/src/components/editor/timeline/timeline-track.tsx b/apps/web/src/components/editor/timeline/timeline-track.tsx index 5a037710..ad3ca9b7 100644 --- a/apps/web/src/components/editor/timeline/timeline-track.tsx +++ b/apps/web/src/components/editor/timeline/timeline-track.tsx @@ -12,6 +12,7 @@ import { canElementGoOnTrack, } from "@/types/timeline"; import { usePlaybackStore } from "@/stores/playback-store"; +import { DEFAULT_TEXT_ELEMENT } from "@/constants/text-constants"; import type { TimelineElement as TimelineElementType, DragData, @@ -33,7 +34,7 @@ export function TimelineTrackContent({ zoomLevel: number; onSnapPointChange?: (snapPoint: SnapPoint | null) => void; }) { - const { mediaItems } = useMediaStore(); + const { mediaFiles } = useMediaStore(); const { tracks, addTrack, @@ -537,7 +538,7 @@ export function TimelineTrackContent({ }); } else { // Media elements - const mediaItem = mediaItems.find( + const mediaItem = mediaFiles.find( (item) => item.id === dragData.id ); if (mediaItem) { @@ -890,29 +891,14 @@ export function TimelineTrackContent({ } addElementToTrack(targetTrackId, { - type: "text", - name: dragData.name || "Text", - content: dragData.content || "Default Text", - duration: TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION, + ...DEFAULT_TEXT_ELEMENT, + name: dragData.name || DEFAULT_TEXT_ELEMENT.name, + content: dragData.content || DEFAULT_TEXT_ELEMENT.content, startTime: textSnappedTime, - trimStart: 0, - trimEnd: 0, - fontSize: 48, - fontFamily: "Arial", - color: "#ffffff", - backgroundColor: "transparent", - textAlign: "center", - fontWeight: "normal", - fontStyle: "normal", - textDecoration: "none", - x: 0, - y: 0, - rotation: 0, - opacity: 1, }); } else { // Handle media items - const mediaItem = mediaItems.find((item) => item.id === dragData.id); + const mediaItem = mediaFiles.find((item) => item.id === dragData.id); if (!mediaItem) { toast.error("Media item not found"); @@ -1054,7 +1040,7 @@ export function TimelineTrackContent({ } else if (hasFiles) { // External file drops const { activeProject } = useProjectStore.getState(); - const { addMediaItem } = useMediaStore.getState(); + const { addMediaFile } = useMediaStore.getState(); const { addElementToTrack } = useTimelineStore.getState(); if (!activeProject) { @@ -1066,9 +1052,9 @@ export function TimelineTrackContent({ processMediaFiles(e.dataTransfer.files) .then(async (processedItems) => { for (const processedItem of processedItems) { - await addMediaItem(activeProject.id, processedItem); - const currentMediaItems = useMediaStore.getState().mediaItems; - const addedItem = currentMediaItems.find( + await addMediaFile(activeProject.id, processedItem); + const currentMediaFiles = mediaFiles; + const addedItem = currentMediaFiles.find( (item) => item.name === processedItem.name && item.url === processedItem.url diff --git a/apps/web/src/components/ui/draggable-item.tsx b/apps/web/src/components/ui/draggable-item.tsx index 9e29c83b..c238b33c 100644 --- a/apps/web/src/components/ui/draggable-item.tsx +++ b/apps/web/src/components/ui/draggable-item.tsx @@ -12,11 +12,12 @@ import { createPortal } from "react-dom"; import { Plus } from "lucide-react"; import { cn } from "@/lib/utils"; import { usePlaybackStore } from "@/stores/playback-store"; +import { DragData } from "@/types/timeline"; export interface DraggableMediaItemProps { name: string; preview: ReactNode; - dragData: Record; + dragData: DragData; onDragStart?: (e: React.DragEvent) => void; onAddToTimeline?: (currentTime: number) => void; aspectRatio?: number; diff --git a/apps/web/src/constants/text-constants.ts b/apps/web/src/constants/text-constants.ts new file mode 100644 index 00000000..e3fcce38 --- /dev/null +++ b/apps/web/src/constants/text-constants.ts @@ -0,0 +1,27 @@ +import { TextElement } from "@/types/timeline"; +import { TIMELINE_CONSTANTS } from "./timeline-constants"; + +export const DEFAULT_TEXT_ELEMENT: Omit< + TextElement, + "id" +> = { + type: "text", + name: "Text", + content: "Default Text", + fontSize: 48, + fontFamily: "Arial", + color: "#ffffff", + backgroundColor: "transparent", + textAlign: "center", + fontWeight: "normal", + fontStyle: "normal", + textDecoration: "none", + x: 0, + y: 0, + rotation: 0, + opacity: 1, + duration: TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION, + startTime: 0, + trimStart: 0, + trimEnd: 0, +}; diff --git a/apps/web/src/hooks/use-aspect-ratio.ts b/apps/web/src/hooks/use-aspect-ratio.ts index c3fe20c6..b721b74e 100644 --- a/apps/web/src/hooks/use-aspect-ratio.ts +++ b/apps/web/src/hooks/use-aspect-ratio.ts @@ -6,7 +6,7 @@ import { DEFAULT_CANVAS_SIZE, useProjectStore } from "@/stores/project-store"; export function useAspectRatio() { const { canvasPresets } = useEditorStore(); const { activeProject } = useProjectStore(); - const { mediaItems } = useMediaStore(); + const { mediaFiles } = useMediaStore(); const { tracks } = useTimelineStore(); const canvasSize = activeProject?.canvasSize || DEFAULT_CANVAS_SIZE; @@ -24,14 +24,14 @@ export function useAspectRatio() { for (const track of tracks) { for (const element of track.elements) { if (element.type === "media") { - const mediaItem = mediaItems.find( - (item) => item.id === element.mediaId + const mediaFile = mediaFiles.find( + (file) => file.id === element.mediaId ); if ( - mediaItem && - (mediaItem.type === "video" || mediaItem.type === "image") + mediaFile && + (mediaFile.type === "video" || mediaFile.type === "image") ) { - return getMediaAspectRatio(mediaItem); + return getMediaAspectRatio(mediaFile); } } } diff --git a/apps/web/src/hooks/use-timeline-element-resize.ts b/apps/web/src/hooks/use-timeline-element-resize.ts index 2875185c..4812459a 100644 --- a/apps/web/src/hooks/use-timeline-element-resize.ts +++ b/apps/web/src/hooks/use-timeline-element-resize.ts @@ -9,28 +9,15 @@ interface UseTimelineElementResizeProps { element: TimelineElement; track: TimelineTrack; zoomLevel: number; - onUpdateTrim: ( - trackId: string, - elementId: string, - trimStart: number, - trimEnd: number - ) => void; - onUpdateDuration: ( - trackId: string, - elementId: string, - duration: number - ) => void; } export function useTimelineElementResize({ element, track, zoomLevel, - onUpdateTrim, - onUpdateDuration, }: UseTimelineElementResizeProps) { const [resizing, setResizing] = useState(null); - const { mediaItems } = useMediaStore(); + const { mediaFiles } = useMediaStore(); const { updateElementStartTime, updateElementTrim, @@ -88,11 +75,11 @@ export function useTimelineElementResize({ // Media elements - check the media type if (element.type === "media") { - const mediaItem = mediaItems.find((item) => item.id === element.mediaId); - if (!mediaItem) return false; + const mediaFile = mediaFiles.find((file) => file.id === element.mediaId); + if (!mediaFile) return false; // Images can be extended (static content) - if (mediaItem.type === "image") { + if (mediaFile.type === "image") { return true; } diff --git a/apps/web/src/lib/media-processing.ts b/apps/web/src/lib/media-processing.ts index 15c71626..7a31466f 100644 --- a/apps/web/src/lib/media-processing.ts +++ b/apps/web/src/lib/media-processing.ts @@ -1,14 +1,13 @@ import { toast } from "sonner"; import { getFileType, - generateVideoThumbnail, getMediaDuration, getImageDimensions, - type MediaItem, } from "@/stores/media-store"; +import { MediaFile } from "@/types/media"; import { generateThumbnail, getVideoInfo } from "./mediabunny-utils"; -export interface ProcessedMediaItem extends Omit {} +export interface ProcessedMediaItem extends Omit {} export async function processMediaFiles( files: FileList | File[], diff --git a/apps/web/src/lib/mediabunny-utils.ts b/apps/web/src/lib/mediabunny-utils.ts index 28e6b97f..fcb3da0f 100644 --- a/apps/web/src/lib/mediabunny-utils.ts +++ b/apps/web/src/lib/mediabunny-utils.ts @@ -151,14 +151,14 @@ export const extractTimelineAudio = async ( for (const element of track.elements) { if (element.type === "media") { - const mediaItem = mediaStore.mediaItems.find( + const mediaFile = mediaStore.mediaFiles.find( (m) => m.id === element.mediaId ); - if (!mediaItem) continue; + if (!mediaFile) continue; - if (mediaItem.type === "video" || mediaItem.type === "audio") { + if (mediaFile.type === "video" || mediaFile.type === "audio") { audioElements.push({ - file: mediaItem.file, + file: mediaFile.file, startTime: element.startTime, duration: element.duration, trimStart: element.trimStart, diff --git a/apps/web/src/lib/storage/storage-service.ts b/apps/web/src/lib/storage/storage-service.ts index b3376a5f..3aadf883 100644 --- a/apps/web/src/lib/storage/storage-service.ts +++ b/apps/web/src/lib/storage/storage-service.ts @@ -1,5 +1,5 @@ import { TProject } from "@/types/project"; -import { MediaItem } from "@/stores/media-store"; +import { MediaFile } from "@/types/media"; import { IndexedDBAdapter } from "./indexeddb-adapter"; import { OPFSAdapter } from "./opfs-adapter"; import { @@ -124,8 +124,8 @@ class StorageService { await this.projectsAdapter.remove(id); } - // Media operations - now project-specific - async saveMediaItem(projectId: string, mediaItem: MediaItem): Promise { + // Media operations + async saveMediaFile(projectId: string, mediaItem: MediaFile): Promise { const { mediaMetadataAdapter, mediaFilesAdapter } = this.getProjectMediaAdapters(projectId); @@ -148,10 +148,10 @@ class StorageService { await mediaMetadataAdapter.set(mediaItem.id, metadata); } - async loadMediaItem( + async loadMediaFile( projectId: string, id: string - ): Promise { + ): Promise { const { mediaMetadataAdapter, mediaFilesAdapter } = this.getProjectMediaAdapters(projectId); @@ -192,14 +192,14 @@ class StorageService { }; } - async loadAllMediaItems(projectId: string): Promise { + async loadAllMediaFiles(projectId: string): Promise { const { mediaMetadataAdapter } = this.getProjectMediaAdapters(projectId); const mediaIds = await mediaMetadataAdapter.list(); - const mediaItems: MediaItem[] = []; + const mediaItems: MediaFile[] = []; for (const id of mediaIds) { - const item = await this.loadMediaItem(projectId, id); + const item = await this.loadMediaFile(projectId, id); if (item) { mediaItems.push(item); } @@ -208,7 +208,7 @@ class StorageService { return mediaItems; } - async deleteMediaItem(projectId: string, id: string): Promise { + async deleteMediaFile(projectId: string, id: string): Promise { const { mediaMetadataAdapter, mediaFilesAdapter } = this.getProjectMediaAdapters(projectId); diff --git a/apps/web/src/lib/timeline-renderer.ts b/apps/web/src/lib/timeline-renderer.ts index 1c1eafcd..06825896 100644 --- a/apps/web/src/lib/timeline-renderer.ts +++ b/apps/web/src/lib/timeline-renderer.ts @@ -1,179 +1,159 @@ -import { Input, ALL_FORMATS, BlobSource, VideoSampleSink } from "mediabunny"; -import type { TimelineTrack } from "@/types/timeline"; -import type { MediaItem } from "@/stores/media-store"; - -export interface RenderContext { - ctx: CanvasRenderingContext2D; - time: number; - canvasWidth: number; - canvasHeight: number; - tracks: TimelineTrack[]; - mediaItems: MediaItem[]; - backgroundColor?: string; - useCache?: boolean; - cache?: Map< - string, - Map< - number, - { - draw: ( - c: CanvasRenderingContext2D, - x: number, - y: number, - w: number, - h: number - ) => void; - } - > - >; - fps: number; -} - -export async function renderTimelineFrame({ - ctx, - time, - canvasWidth, - canvasHeight, - tracks, - mediaItems, - backgroundColor, - useCache = true, - cache, - fps, -}: RenderContext): Promise { - // Background - ctx.clearRect(0, 0, canvasWidth, canvasHeight); - if (backgroundColor && backgroundColor !== "transparent") { - ctx.fillStyle = backgroundColor; - ctx.fillRect(0, 0, canvasWidth, canvasHeight); - } - - const scaleX = 1; - const scaleY = 1; - const idToMedia = new Map(mediaItems.map((m) => [m.id, m] as const)); - const active: Array<{ - track: TimelineTrack; - element: TimelineTrack["elements"][number]; - mediaItem: MediaItem | null; - }> = []; - - 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; - const elementStart = element.startTime; - const elementEnd = - element.startTime + - (element.duration - element.trimStart - element.trimEnd); - if (time >= elementStart && time < elementEnd) { - let mediaItem: MediaItem | null = null; - if (element.type === "media") { - mediaItem = - element.mediaId === "test" - ? null - : idToMedia.get(element.mediaId) || null; - } - active.push({ track, element, mediaItem }); - } - } - } - - 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); - - const localTime = time - element.startTime + element.trimStart; - const sample = await sink.getSample(localTime); - if (!sample) 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); - } - if (mediaItem.type === "image") { - 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); - }); - const mediaW = Math.max( - 1, - mediaItem.width || img.naturalWidth || canvasWidth - ); - const mediaH = Math.max( - 1, - mediaItem.height || img.naturalHeight || 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(img, drawX, drawY, drawW, drawH); - } - } - if (element.type === "text") { - const posX = canvasWidth / 2 + (element as any).x * scaleX; - const posY = canvasHeight / 2 + (element as any).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.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 textW = metrics.width; - const textH = ascent + descent; - const padX = 8 * scaleX; - const padY = 4 * scaleX; - if ((element as any).backgroundColor) { - ctx.save(); - ctx.fillStyle = (element as any).backgroundColor; - let bgLeft = -textW / 2; - if (ctx.textAlign === "left") bgLeft = 0; - if (ctx.textAlign === "right") bgLeft = -textW; - ctx.fillRect( - bgLeft - padX, - -textH / 2 - padY, - textW + padX * 2, - textH + padY * 2 - ); - ctx.restore(); - } - ctx.fillText((element as any).content, 0, 0); - ctx.restore(); - } - } -} +import { Input, ALL_FORMATS, BlobSource, VideoSampleSink } from "mediabunny"; +import type { TimelineTrack } from "@/types/timeline"; +import type { MediaFile } from "@/types/media"; + +export interface RenderContext { + ctx: CanvasRenderingContext2D; + time: number; + canvasWidth: number; + canvasHeight: number; + tracks: TimelineTrack[]; + mediaFiles: MediaFile[]; + backgroundColor?: string; +} + +export async function renderTimelineFrame({ + ctx, + time, + canvasWidth, + canvasHeight, + tracks, + mediaFiles, + backgroundColor, +}: RenderContext): Promise { + // Background + ctx.clearRect(0, 0, canvasWidth, canvasHeight); + if (backgroundColor && backgroundColor !== "transparent") { + ctx.fillStyle = backgroundColor; + ctx.fillRect(0, 0, canvasWidth, canvasHeight); + } + + const scaleX = 1; + const scaleY = 1; + const idToMedia = new Map(mediaFiles.map((m) => [m.id, m] as const)); + const active: Array<{ + track: TimelineTrack; + element: TimelineTrack["elements"][number]; + mediaItem: MediaFile | null; + }> = []; + + 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; + const elementStart = element.startTime; + const elementEnd = + element.startTime + + (element.duration - element.trimStart - element.trimEnd); + if (time >= elementStart && time < elementEnd) { + let mediaItem: MediaFile | null = null; + if (element.type === "media") { + mediaItem = + element.mediaId === "test" + ? null + : idToMedia.get(element.mediaId) || null; + } + active.push({ track, element, mediaItem }); + } + } + } + + 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); + + const localTime = time - element.startTime + element.trimStart; + const sample = await sink.getSample(localTime); + if (!sample) 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); + } + if (mediaItem.type === "image") { + 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); + }); + const mediaW = Math.max( + 1, + mediaItem.width || img.naturalWidth || canvasWidth + ); + const mediaH = Math.max( + 1, + mediaItem.height || img.naturalHeight || 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(img, drawX, drawY, drawW, drawH); + } + } + if (element.type === "text") { + const posX = canvasWidth / 2 + (element as any).x * scaleX; + const posY = canvasHeight / 2 + (element as any).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.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 textW = metrics.width; + const textH = ascent + descent; + const padX = 8 * scaleX; + const padY = 4 * scaleX; + if ((element as any).backgroundColor) { + ctx.save(); + ctx.fillStyle = (element as any).backgroundColor; + let bgLeft = -textW / 2; + if (ctx.textAlign === "left") bgLeft = 0; + if (ctx.textAlign === "right") bgLeft = -textW; + ctx.fillRect( + bgLeft - padX, + -textH / 2 - padY, + textW + padX * 2, + textH + padY * 2 + ); + ctx.restore(); + } + ctx.fillText((element as any).content, 0, 0); + ctx.restore(); + } + } +} diff --git a/apps/web/src/stores/media-store.ts b/apps/web/src/stores/media-store.ts index 95676d75..2b7cb62e 100644 --- a/apps/web/src/stores/media-store.ts +++ b/apps/web/src/stores/media-store.ts @@ -2,44 +2,21 @@ import { create } from "zustand"; import { storageService } from "@/lib/storage/storage-service"; import { useTimelineStore } from "./timeline-store"; import { generateUUID } from "@/lib/utils"; - -export type MediaType = "image" | "video" | "audio"; - -export interface MediaItem { - id: string; - name: string; - type: MediaType; - file: File; - url?: string; // Object URL for preview - thumbnailUrl?: string; // For video thumbnails - duration?: number; // For video/audio duration - width?: number; // For video/image width - height?: number; // For video/image height - fps?: number; // For video frame rate - // Ephemeral items are used by timeline directly and should not appear in the media library or be persisted - ephemeral?: boolean; - // Text-specific properties - content?: string; // Text content - fontSize?: number; // Font size - fontFamily?: string; // Font family - color?: string; // Text color - backgroundColor?: string; // Background color - textAlign?: "left" | "center" | "right"; // Text alignment -} +import { MediaType, MediaFile } from "@/types/media"; interface MediaStore { - mediaItems: MediaItem[]; + mediaFiles: MediaFile[]; isLoading: boolean; - // Actions - now require projectId - addMediaItem: ( + // Actions + addMediaFile: ( projectId: string, - item: Omit + file: Omit ) => Promise; - removeMediaItem: (projectId: string, id: string) => Promise; + removeMediaFile: (projectId: string, id: string) => Promise; loadProjectMedia: (projectId: string) => Promise; clearProjectMedia: (projectId: string) => Promise; - clearAllMedia: () => void; // Clear local state only + clearAllMedia: () => void; } // Helper function to determine file type @@ -150,8 +127,7 @@ export const getMediaDuration = (file: File): Promise => { }); }; -// Helper to get aspect ratio from MediaItem -export const getMediaAspectRatio = (item: MediaItem): number => { +export const getMediaAspectRatio = (item: MediaFile): number => { if (item.width && item.height) { return item.width / item.height; } @@ -159,35 +135,35 @@ export const getMediaAspectRatio = (item: MediaItem): number => { }; export const useMediaStore = create((set, get) => ({ - mediaItems: [], + mediaFiles: [], isLoading: false, - addMediaItem: async (projectId, item) => { - const newItem: MediaItem = { - ...item, + addMediaFile: async (projectId, file) => { + const newItem: MediaFile = { + ...file, id: generateUUID(), }; // Add to local state immediately for UI responsiveness set((state) => ({ - mediaItems: [...state.mediaItems, newItem], + mediaFiles: [...state.mediaFiles, newItem], })); // Save to persistent storage in background try { - await storageService.saveMediaItem(projectId, newItem); + await storageService.saveMediaFile(projectId, newItem); } catch (error) { console.error("Failed to save media item:", error); // Remove from local state if save failed set((state) => ({ - mediaItems: state.mediaItems.filter((media) => media.id !== newItem.id), + mediaFiles: state.mediaFiles.filter((media) => media.id !== newItem.id), })); } }, - removeMediaItem: async (projectId: string, id: string) => { + removeMediaFile: async (projectId: string, id: string) => { const state = get(); - const item = state.mediaItems.find((media) => media.id === id); + const item = state.mediaFiles.find((media) => media.id === id); // Cleanup object URLs to prevent memory leaks if (item?.url) { @@ -199,7 +175,7 @@ export const useMediaStore = create((set, get) => ({ // 1) Remove from local state immediately set((state) => ({ - mediaItems: state.mediaItems.filter((media) => media.id !== id), + mediaFiles: state.mediaFiles.filter((media) => media.id !== id), })); // 2) Cascade into the timeline: remove any elements using this media ID @@ -238,7 +214,7 @@ export const useMediaStore = create((set, get) => ({ // 3) Remove from persistent storage try { - await storageService.deleteMediaItem(projectId, id); + await storageService.deleteMediaFile(projectId, id); } catch (error) { console.error("Failed to delete media item:", error); } @@ -248,7 +224,7 @@ export const useMediaStore = create((set, get) => ({ set({ isLoading: true }); try { - const mediaItems = await storageService.loadAllMediaItems(projectId); + const mediaItems = await storageService.loadAllMediaFiles(projectId); // Regenerate thumbnails for video items const updatedMediaItems = await Promise.all( @@ -275,7 +251,7 @@ export const useMediaStore = create((set, get) => ({ }) ); - set({ mediaItems: updatedMediaItems }); + set({ mediaFiles: updatedMediaItems }); } catch (error) { console.error("Failed to load media items:", error); } finally { @@ -287,7 +263,7 @@ export const useMediaStore = create((set, get) => ({ const state = get(); // Cleanup all object URLs - state.mediaItems.forEach((item) => { + state.mediaFiles.forEach((item) => { if (item.url) { URL.revokeObjectURL(item.url); } @@ -297,13 +273,13 @@ export const useMediaStore = create((set, get) => ({ }); // Clear local state - set({ mediaItems: [] }); + set({ mediaFiles: [] }); // Clear persistent storage try { - const mediaIds = state.mediaItems.map((item) => item.id); + const mediaIds = state.mediaFiles.map((item) => item.id); await Promise.all( - mediaIds.map((id) => storageService.deleteMediaItem(projectId, id)) + mediaIds.map((id) => storageService.deleteMediaFile(projectId, id)) ); } catch (error) { console.error("Failed to clear media items from storage:", error); @@ -314,7 +290,7 @@ export const useMediaStore = create((set, get) => ({ const state = get(); // Cleanup all object URLs - state.mediaItems.forEach((item) => { + state.mediaFiles.forEach((item) => { if (item.url) { URL.revokeObjectURL(item.url); } @@ -324,6 +300,6 @@ export const useMediaStore = create((set, get) => ({ }); // Clear local state - set({ mediaItems: [] }); + set({ mediaFiles: [] }); }, })); diff --git a/apps/web/src/stores/sounds-store.ts b/apps/web/src/stores/sounds-store.ts index 215253fb..6dffa198 100644 --- a/apps/web/src/stores/sounds-store.ts +++ b/apps/web/src/stores/sounds-store.ts @@ -247,7 +247,7 @@ export const useSoundsStore = create((set, get) => ({ type: "audio/mpeg", }); - await useMediaStore.getState().addMediaItem(activeProject.id, { + await useMediaStore.getState().addMediaFile(activeProject.id, { name: sound.name, type: "audio", file, @@ -257,12 +257,12 @@ export const useSoundsStore = create((set, get) => ({ const mediaItem = useMediaStore .getState() - .mediaItems.find((item) => item.file === file); + .mediaFiles.find((item) => item.file === file); if (!mediaItem) throw new Error("Failed to create media item"); const success = useTimelineStore .getState() - .addMediaAtTime(mediaItem, usePlaybackStore.getState().currentTime); + .addElementAtTime(mediaItem, usePlaybackStore.getState().currentTime); if (success) { return true; diff --git a/apps/web/src/stores/timeline-store.ts b/apps/web/src/stores/timeline-store.ts index 3eada6de..884db23e 100644 --- a/apps/web/src/stores/timeline-store.ts +++ b/apps/web/src/stores/timeline-store.ts @@ -10,17 +10,15 @@ import { ensureMainTrack, validateElementTrackCompatibility, } from "@/types/timeline"; -import { - useMediaStore, - getMediaAspectRatio, - type MediaItem, -} from "./media-store"; +import { useMediaStore, getMediaAspectRatio } from "./media-store"; +import { MediaFile } from "@/types/media"; import { findBestCanvasPreset } from "@/lib/editor-utils"; import { storageService } from "@/lib/storage/storage-service"; import { useProjectStore } from "./project-store"; import { generateUUID } from "@/lib/utils"; import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; import { checkElementOverlaps, resolveElementOverlaps } from "@/lib/timeline"; +import { DEFAULT_TEXT_ELEMENT } from "@/constants/text-constants"; // Helper function to manage element naming with suffixes const getElementNameWithSuffix = ( @@ -207,10 +205,11 @@ interface TimelineStore { excludeElementId?: string ) => boolean; findOrCreateTrack: (trackType: TrackType) => string; - addMediaAtTime: (item: MediaItem, currentTime?: number) => boolean; - addTextAtTime: (item: TextElement, currentTime?: number) => boolean; - addMediaToNewTrack: (item: MediaItem) => boolean; - addTextToNewTrack: (item: TextElement | DragData) => boolean; + addElementAtTime: ( + item: MediaFile | TextElement, + currentTime?: number + ) => boolean; + addElementToNewTrack: (item: MediaFile | TextElement | DragData) => boolean; } export const useTimelineStore = create((set, get) => { @@ -502,7 +501,7 @@ export const useTimelineStore = create((set, get) => { if (isFirstElement && newElement.type === "media") { const mediaStore = useMediaStore.getState(); - const mediaItem = mediaStore.mediaItems.find( + const mediaItem = mediaStore.mediaFiles.find( (item) => item.id === newElement.mediaId ); @@ -1144,7 +1143,7 @@ export const useTimelineStore = create((set, get) => { } try { - await mediaStore.addMediaItem( + await mediaStore.addMediaFile( projectStore.activeProject.id, mediaData ); @@ -1155,7 +1154,7 @@ export const useTimelineStore = create((set, get) => { }; } - const newMediaItem = mediaStore.mediaItems.find( + const newMediaItem = mediaStore.mediaFiles.find( (item) => item.file === newFile ); @@ -1219,7 +1218,7 @@ export const useTimelineStore = create((set, get) => { getProjectThumbnail: async (projectId) => { try { const tracks = await storageService.loadTimeline(projectId); - const mediaItems = await storageService.loadAllMediaItems(projectId); + const mediaItems = await storageService.loadAllMediaFiles(projectId); if (!tracks || !mediaItems.length) return null; @@ -1230,20 +1229,20 @@ export const useTimelineStore = create((set, get) => { if (!firstMediaElement) return null; - const mediaItem = mediaItems.find( + const mediaFile = mediaItems.find( (item) => item.id === firstMediaElement.mediaId ); - if (!mediaItem) return null; + if (!mediaFile) return null; - if (mediaItem.type === "video" && mediaItem.file) { + if (mediaFile.type === "video" && mediaFile.file) { const { generateVideoThumbnail } = await import( "@/stores/media-store" ); - const { thumbnailUrl } = await generateVideoThumbnail(mediaItem.file); + const { thumbnailUrl } = await generateVideoThumbnail(mediaFile.file); return thumbnailUrl; } - if (mediaItem.type === "image" && mediaItem.url) { - return mediaItem.url; + if (mediaFile.type === "image" && mediaFile.url) { + return mediaFile.url; } return null; @@ -1401,30 +1400,24 @@ export const useTimelineStore = create((set, get) => { return get().addTrack(trackType); }, - addMediaAtTime: (item, currentTime = 0) => { - const trackType = item.type === "audio" ? "audio" : "media"; - const duration = - item.duration || TIMELINE_CONSTANTS.DEFAULT_IMAGE_DURATION; - - const tracks = get()._tracks.filter((t) => t.type === trackType); - - let targetTrackId = null; - for (const track of tracks) { - if (!get().checkElementOverlap(track.id, currentTime, duration)) { - targetTrackId = track.id; - break; - } - } - - if (!targetTrackId) { - targetTrackId = get().addTrack(trackType); + addElementAtTime: (item: MediaFile | TextElement, currentTime = 0) => { + if (item.type === "text") { + const targetTrackId = get().insertTrackAt("text", 0); + get().addElementToTrack( + targetTrackId, + buildTextElement(item, currentTime) + ); + return true; } + const media = item as MediaFile; + const trackType = media.type === "audio" ? "audio" : "media"; + const targetTrackId = get().insertTrackAt(trackType, 0); get().addElementToTrack(targetTrackId, { type: "media", - mediaId: item.id, - name: item.name, - duration, + mediaId: media.id, + name: media.name, + duration: media.duration || TIMELINE_CONSTANTS.DEFAULT_IMAGE_DURATION, startTime: currentTime, trimStart: 0, trimEnd: 0, @@ -1433,42 +1426,24 @@ export const useTimelineStore = create((set, get) => { return true; }, - addTextAtTime: (item, currentTime = 0) => { - const targetTrackId = get().insertTrackAt("text", 0); - - get().addElementToTrack(targetTrackId, { - type: "text", - name: item.name || "Text", - content: item.content || "Default Text", - duration: item.duration || TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION, - startTime: currentTime, - trimStart: 0, - trimEnd: 0, - fontSize: item.fontSize || 48, - fontFamily: item.fontFamily || "Arial", - color: item.color || "#ffffff", - backgroundColor: item.backgroundColor || "transparent", - textAlign: item.textAlign || "center", - fontWeight: item.fontWeight || "normal", - fontStyle: item.fontStyle || "normal", - textDecoration: item.textDecoration || "none", - x: item.x || 0, - y: item.y || 0, - rotation: item.rotation || 0, - opacity: item.opacity !== undefined ? item.opacity : 1, - }); - return true; - }, - - addMediaToNewTrack: (item) => { - const trackType = item.type === "audio" ? "audio" : "media"; - const targetTrackId = get().findOrCreateTrack(trackType); + addElementToNewTrack: (item) => { + if (item.type === "text") { + const targetTrackId = get().insertTrackAt("text", 0); + get().addElementToTrack( + targetTrackId, + buildTextElement(item as TextElement | DragData, 0) + ); + return true; + } + const media = item as MediaFile; + const trackType = media.type === "audio" ? "audio" : "media"; + const targetTrackId = get().insertTrackAt(trackType, 0); get().addElementToTrack(targetTrackId, { type: "media", - mediaId: item.id, - name: item.name, - duration: item.duration || TIMELINE_CONSTANTS.DEFAULT_IMAGE_DURATION, + mediaId: media.id, + name: media.name, + duration: media.duration || TIMELINE_CONSTANTS.DEFAULT_IMAGE_DURATION, startTime: 0, trimStart: 0, trimEnd: 0, @@ -1476,41 +1451,41 @@ export const useTimelineStore = create((set, get) => { }); return true; }, + }; +}); - addTextToNewTrack: (item) => { - const targetTrackId = get().insertTrackAt("text", 0); +function buildTextElement( + raw: TextElement | DragData, + startTime: number +): CreateTimelineElement { + const t = raw as Partial; - get().addElementToTrack(targetTrackId, { - type: "text", - name: item.name || "Text", - content: - ("content" in item ? item.content : "Default Text") || "Default Text", - duration: TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION, - startTime: 0, - trimStart: 0, - trimEnd: 0, - fontSize: ("fontSize" in item ? item.fontSize : 48) || 48, - fontFamily: - ("fontFamily" in item ? item.fontFamily : "Arial") || "Arial", - color: ("color" in item ? item.color : "#ffffff") || "#ffffff", - backgroundColor: - ("backgroundColor" in item ? item.backgroundColor : "transparent") || - "transparent", - textAlign: - ("textAlign" in item ? item.textAlign : "center") || "center", - fontWeight: - ("fontWeight" in item ? item.fontWeight : "normal") || "normal", - fontStyle: - ("fontStyle" in item ? item.fontStyle : "normal") || "normal", - textDecoration: - ("textDecoration" in item ? item.textDecoration : "none") || "none", - x: ("x" in item ? item.x : 0) || 0, - y: ("y" in item ? item.y : 0) || 0, - rotation: ("rotation" in item ? item.rotation : 0) || 0, - opacity: - "opacity" in item && item.opacity !== undefined ? item.opacity : 1, - }); - return true; - }, + return { + type: "text", + name: t.name ?? DEFAULT_TEXT_ELEMENT.name, + content: t.content ?? DEFAULT_TEXT_ELEMENT.content, + duration: t.duration ?? TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION, + startTime, + trimStart: 0, + trimEnd: 0, + fontSize: + typeof t.fontSize === "number" + ? t.fontSize + : DEFAULT_TEXT_ELEMENT.fontSize, + fontFamily: t.fontFamily ?? DEFAULT_TEXT_ELEMENT.fontFamily, + color: t.color ?? DEFAULT_TEXT_ELEMENT.color, + backgroundColor: t.backgroundColor ?? DEFAULT_TEXT_ELEMENT.backgroundColor, + textAlign: t.textAlign ?? DEFAULT_TEXT_ELEMENT.textAlign, + fontWeight: t.fontWeight ?? DEFAULT_TEXT_ELEMENT.fontWeight, + fontStyle: t.fontStyle ?? DEFAULT_TEXT_ELEMENT.fontStyle, + textDecoration: t.textDecoration ?? DEFAULT_TEXT_ELEMENT.textDecoration, + x: typeof t.x === "number" ? t.x : DEFAULT_TEXT_ELEMENT.x, + y: typeof t.y === "number" ? t.y : DEFAULT_TEXT_ELEMENT.y, + rotation: + typeof t.rotation === "number" + ? t.rotation + : DEFAULT_TEXT_ELEMENT.rotation, + opacity: + typeof t.opacity === "number" ? t.opacity : DEFAULT_TEXT_ELEMENT.opacity, }; -}); +} diff --git a/apps/web/src/types/media.ts b/apps/web/src/types/media.ts new file mode 100644 index 00000000..6e94fcff --- /dev/null +++ b/apps/web/src/types/media.ts @@ -0,0 +1,17 @@ +export type MediaType = "image" | "video" | "audio"; + +// What's stored in media library +export interface MediaFile { + id: string; + name: string; + type: MediaType; + file: File; + url?: string; // Object URL for preview + thumbnailUrl?: string; // For video thumbnails + duration?: number; // For video/audio duration + width?: number; // For video/image width + height?: number; // For video/image height + fps?: number; // For video frame rate + // Ephemeral items are used by timeline directly and should not appear in the media library or be persisted + ephemeral?: boolean; +} diff --git a/apps/web/src/types/timeline.ts b/apps/web/src/types/timeline.ts index cf946453..61ba6af0 100644 --- a/apps/web/src/types/timeline.ts +++ b/apps/web/src/types/timeline.ts @@ -1,4 +1,4 @@ -import { MediaType } from "@/stores/media-store"; +import { MediaType } from "@/types/media"; import { generateUUID } from "@/lib/utils"; export type TrackType = "media" | "text" | "audio"; -- cgit v1.3.1 From adc8cb37fcec21afac848b1e5dd724394c344050 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Wed, 27 Aug 2025 00:52:39 +0200 Subject: another huge refactor --- apps/web/src/app/editor/[project_id]/page.tsx | 16 +- apps/web/src/components/background-settings.tsx | 184 --------- apps/web/src/components/editor-header.tsx | 139 ------- apps/web/src/components/editor-provider.tsx | 53 --- apps/web/src/components/editor/editor-header.tsx | 139 +++++++ apps/web/src/components/editor/export-button.tsx | 246 ++++++++++++ .../editor/media-panel/views/base-view.tsx | 67 ---- .../editor/media-panel/views/captions.tsx | 2 +- .../editor/media-panel/views/settings.tsx | 2 +- .../components/editor/media-panel/views/text.tsx | 2 +- apps/web/src/components/editor/onboarding.tsx | 138 +++++++ apps/web/src/components/editor/panel-base-view.tsx | 78 ++++ .../components/editor/panel-preset-selector.tsx | 91 +++++ apps/web/src/components/editor/preview-panel.tsx | 1 + .../editor/properties-panel/property-item.tsx | 4 +- .../editor/properties-panel/text-properties.tsx | 412 +++++++++++---------- apps/web/src/components/export-button.tsx | 185 --------- apps/web/src/components/onboarding.tsx | 138 ------- apps/web/src/components/panel-preset-selector.tsx | 91 ----- .../src/components/providers/editor-provider.tsx | 53 +++ apps/web/src/components/ui/progress.tsx | 4 +- apps/web/src/lib/export.ts | 147 ++++++++ apps/web/src/lib/timeline-renderer.ts | 8 +- apps/web/src/stores/text-properties-store.ts | 33 ++ apps/web/src/types/export.ts | 17 + 25 files changed, 1191 insertions(+), 1059 deletions(-) delete mode 100644 apps/web/src/components/background-settings.tsx delete mode 100644 apps/web/src/components/editor-header.tsx delete mode 100644 apps/web/src/components/editor-provider.tsx create mode 100644 apps/web/src/components/editor/editor-header.tsx create mode 100644 apps/web/src/components/editor/export-button.tsx delete mode 100644 apps/web/src/components/editor/media-panel/views/base-view.tsx create mode 100644 apps/web/src/components/editor/onboarding.tsx create mode 100644 apps/web/src/components/editor/panel-base-view.tsx create mode 100644 apps/web/src/components/editor/panel-preset-selector.tsx delete mode 100644 apps/web/src/components/export-button.tsx delete mode 100644 apps/web/src/components/onboarding.tsx delete mode 100644 apps/web/src/components/panel-preset-selector.tsx create mode 100644 apps/web/src/components/providers/editor-provider.tsx create mode 100644 apps/web/src/lib/export.ts create mode 100644 apps/web/src/stores/text-properties-store.ts create mode 100644 apps/web/src/types/export.ts (limited to 'apps') diff --git a/apps/web/src/app/editor/[project_id]/page.tsx b/apps/web/src/app/editor/[project_id]/page.tsx index 4d5a27fc..1b53b6b5 100644 --- a/apps/web/src/app/editor/[project_id]/page.tsx +++ b/apps/web/src/app/editor/[project_id]/page.tsx @@ -6,17 +6,17 @@ import { ResizablePanelGroup, ResizablePanel, ResizableHandle, -} from "../../../components/ui/resizable"; -import { MediaPanel } from "../../../components/editor/media-panel"; -import { PropertiesPanel } from "../../../components/editor/properties-panel"; -import { Timeline } from "../../../components/editor/timeline"; -import { PreviewPanel } from "../../../components/editor/preview-panel"; -import { EditorHeader } from "@/components/editor-header"; +} from "@/components/ui/resizable"; +import { MediaPanel } from "@/components/editor/media-panel"; +import { PropertiesPanel } from "@/components/editor/properties-panel"; +import { Timeline } from "@/components/editor/timeline"; +import { PreviewPanel } from "@/components/editor/preview-panel"; +import { EditorHeader } from "@/components/editor/editor-header"; import { usePanelStore } from "@/stores/panel-store"; import { useProjectStore } from "@/stores/project-store"; -import { EditorProvider } from "@/components/editor-provider"; +import { EditorProvider } from "@/components/providers/editor-provider"; import { usePlaybackControls } from "@/hooks/use-playback-controls"; -import { Onboarding } from "@/components/onboarding"; +import { Onboarding } from "@/components/editor/onboarding"; export default function Editor() { const { diff --git a/apps/web/src/components/background-settings.tsx b/apps/web/src/components/background-settings.tsx deleted file mode 100644 index 0457cb05..00000000 --- a/apps/web/src/components/background-settings.tsx +++ /dev/null @@ -1,184 +0,0 @@ -import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover"; -import { Button } from "./ui/button"; -import { BackgroundIcon } from "./icons"; -import { cn } from "@/lib/utils"; -import Image from "next/image"; -import { colors } from "@/data/colors/solid"; -import { useProjectStore } from "@/stores/project-store"; -import { PipetteIcon } from "lucide-react"; - -type BackgroundTab = "color" | "blur"; - -export function BackgroundSettings() { - const { activeProject, updateBackgroundType } = useProjectStore(); - - // ✅ Good: derive activeTab from activeProject during rendering - const activeTab = activeProject?.backgroundType || "color"; - - const handleColorSelect = (color: string) => { - updateBackgroundType("color", { backgroundColor: color }); - }; - - const handleBlurSelect = (blurIntensity: number) => { - updateBackgroundType("blur", { blurIntensity }); - }; - - const tabs = [ - { - label: "Color", - value: "color", - }, - { - label: "Blur", - value: "blur", - }, - ]; - - return ( - - - - - -
-

Background

-
- {tabs.map((tab) => ( - { - // Switch to the background type when clicking tabs - if (tab.value === "color") { - updateBackgroundType("color", { - backgroundColor: - activeProject?.backgroundColor || "#000000", - }); - } else { - updateBackgroundType("blur", { - blurIntensity: activeProject?.blurIntensity || 8, - }); - } - }} - className={cn( - "text-muted-foreground cursor-pointer", - activeTab === tab.value && "text-foreground" - )} - > - {tab.label} - - ))} -
-
- {activeTab === "color" ? ( - - ) : ( - - )} -
-
- ); -} - -function ColorView({ - selectedColor, - onColorSelect, -}: { - selectedColor: string; - onColorSelect: (color: string) => void; -}) { - return ( -
-
-
-
- -
- {colors.map((color) => ( - onColorSelect(color)} - /> - ))} -
-
- ); -} - -function ColorItem({ - color, - isSelected, - onClick, -}: { - color: string; - isSelected: boolean; - onClick: () => void; -}) { - return ( -
- ); -} - -function BlurView({ - selectedBlur, - onBlurSelect, -}: { - selectedBlur: number; - onBlurSelect: (blurIntensity: number) => void; -}) { - const blurLevels = [ - { label: "Light", value: 4 }, - { label: "Medium", value: 8 }, - { label: "Heavy", value: 18 }, - ]; - const blurImage = - "https://images.unsplash.com/photo-1501785888041-af3ef285b470?q=80&w=1470&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"; - - return ( -
- {blurLevels.map((blur) => ( -
onBlurSelect(blur.value)} - > - {`Blur -
- - {blur.label} - -
-
- ))} -
- ); -} diff --git a/apps/web/src/components/editor-header.tsx b/apps/web/src/components/editor-header.tsx deleted file mode 100644 index ee5dfc17..00000000 --- a/apps/web/src/components/editor-header.tsx +++ /dev/null @@ -1,139 +0,0 @@ -"use client"; - -import { Button } from "./ui/button"; -import { ChevronDown, ArrowLeft, SquarePen, Trash, Sun } from "lucide-react"; -import { HeaderBase } from "./header-base"; -import { useProjectStore } from "@/stores/project-store"; -import { KeyboardShortcutsHelp } from "./keyboard-shortcuts-help"; -import { useState } from "react"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "./ui/dropdown-menu"; -import Link from "next/link"; -import { RenameProjectDialog } from "./rename-project-dialog"; -import { DeleteProjectDialog } from "./delete-project-dialog"; -import { useRouter } from "next/navigation"; -import { FaDiscord } from "react-icons/fa6"; -import { useTheme } from "next-themes"; -import { PanelPresetSelector } from "./panel-preset-selector"; -import { ExportButton } from "./export-button"; - -export function EditorHeader() { - const { activeProject, renameProject, deleteProject } = useProjectStore(); - const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); - const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false); - const router = useRouter(); - const { theme, setTheme } = useTheme(); - - const handleNameSave = async (newName: string) => { - console.log("handleNameSave", newName); - if (activeProject && newName.trim() && newName !== activeProject.name) { - try { - await renameProject(activeProject.id, newName.trim()); - setIsRenameDialogOpen(false); - } catch (error) { - console.error("Failed to rename project:", error); - } - } - }; - - const handleDelete = () => { - if (activeProject) { - deleteProject(activeProject.id); - setIsDeleteDialogOpen(false); - router.push("/projects"); - } - }; - - const leftContent = ( -
- - - - - - - - - Projects - - - setIsRenameDialogOpen(true)} - > - - Rename project - - setIsDeleteDialogOpen(true)} - > - - Delete Project - - - - - - Discord - - - - - - -
- ); - - const rightContent = ( - - ); - - return ( - - ); -} diff --git a/apps/web/src/components/editor-provider.tsx b/apps/web/src/components/editor-provider.tsx deleted file mode 100644 index b8e9ae1d..00000000 --- a/apps/web/src/components/editor-provider.tsx +++ /dev/null @@ -1,53 +0,0 @@ -"use client"; - -import { useEffect } from "react"; -import { Loader2 } from "lucide-react"; -import { useEditorStore } from "@/stores/editor-store"; -import { - useKeybindingsListener, - useKeybindingDisabler, -} from "@/hooks/use-keybindings"; -import { useEditorActions } from "@/hooks/use-editor-actions"; - -interface EditorProviderProps { - children: React.ReactNode; -} - -export function EditorProvider({ children }: EditorProviderProps) { - const { isInitializing, isPanelsReady, initializeApp } = useEditorStore(); - const { disableKeybindings, enableKeybindings } = useKeybindingDisabler(); - - // Set up action handlers - useEditorActions(); - - // Set up keybinding listener - useKeybindingsListener(); - - // Disable keybindings when initializing - useEffect(() => { - if (isInitializing || !isPanelsReady) { - disableKeybindings(); - } else { - enableKeybindings(); - } - }, [isInitializing, isPanelsReady, disableKeybindings, enableKeybindings]); - - useEffect(() => { - initializeApp(); - }, [initializeApp]); - - // Show loading screen while initializing - if (isInitializing || !isPanelsReady) { - return ( -
-
- -

Loading editor...

-
-
- ); - } - - // App is ready, render children - return <>{children}; -} diff --git a/apps/web/src/components/editor/editor-header.tsx b/apps/web/src/components/editor/editor-header.tsx new file mode 100644 index 00000000..ad2a5b5a --- /dev/null +++ b/apps/web/src/components/editor/editor-header.tsx @@ -0,0 +1,139 @@ +"use client"; + +import { Button } from "../ui/button"; +import { ChevronDown, ArrowLeft, SquarePen, Trash, Sun } from "lucide-react"; +import { HeaderBase } from "../header-base"; +import { useProjectStore } from "@/stores/project-store"; +import { KeyboardShortcutsHelp } from "../keyboard-shortcuts-help"; +import { useState } from "react"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "../ui/dropdown-menu"; +import Link from "next/link"; +import { RenameProjectDialog } from "../rename-project-dialog"; +import { DeleteProjectDialog } from "../delete-project-dialog"; +import { useRouter } from "next/navigation"; +import { FaDiscord } from "react-icons/fa6"; +import { useTheme } from "next-themes"; +import { PanelPresetSelector } from "./panel-preset-selector"; +import { ExportButton } from "./export-button"; + +export function EditorHeader() { + const { activeProject, renameProject, deleteProject } = useProjectStore(); + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false); + const router = useRouter(); + const { theme, setTheme } = useTheme(); + + const handleNameSave = async (newName: string) => { + console.log("handleNameSave", newName); + if (activeProject && newName.trim() && newName !== activeProject.name) { + try { + await renameProject(activeProject.id, newName.trim()); + setIsRenameDialogOpen(false); + } catch (error) { + console.error("Failed to rename project:", error); + } + } + }; + + const handleDelete = () => { + if (activeProject) { + deleteProject(activeProject.id); + setIsDeleteDialogOpen(false); + router.push("/projects"); + } + }; + + const leftContent = ( +
+ + + + + + + + + Projects + + + setIsRenameDialogOpen(true)} + > + + Rename project + + setIsDeleteDialogOpen(true)} + > + + Delete Project + + + + + + Discord + + + + + + +
+ ); + + const rightContent = ( + + ); + + return ( + + ); +} diff --git a/apps/web/src/components/editor/export-button.tsx b/apps/web/src/components/editor/export-button.tsx new file mode 100644 index 00000000..e24d20df --- /dev/null +++ b/apps/web/src/components/editor/export-button.tsx @@ -0,0 +1,246 @@ +"use client"; + +import { useState } from "react"; +import { TransitionUpIcon } from "../icons"; +import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"; +import { Button } from "../ui/button"; +import { Label } from "../ui/label"; +import { RadioGroup, RadioGroupItem } from "../ui/radio-group"; +import { Progress } from "../ui/progress"; +import { cn } from "@/lib/utils"; +import { + exportProject, + getExportMimeType, + getExportFileExtension, + DEFAULT_EXPORT_OPTIONS, +} from "@/lib/export"; +import { useProjectStore } from "@/stores/project-store"; +import { Download, X } from "lucide-react"; +import { ExportFormat, ExportQuality, ExportResult } from "@/types/export"; +import { PropertyGroup } from "./properties-panel/property-item"; + +export function ExportButton() { + const [isExportPopoverOpen, setIsExportPopoverOpen] = useState(false); + const { activeProject } = useProjectStore(); + + const handleExport = () => { + setIsExportPopoverOpen(true); + }; + + const hasProject = !!activeProject; + + return ( + + + + + {hasProject && } + + ); +} + +function ExportPopover({ + onOpenChange, +}: { + onOpenChange: (open: boolean) => void; +}) { + const { activeProject } = useProjectStore(); + const [format, setFormat] = useState( + DEFAULT_EXPORT_OPTIONS.format + ); + const [quality, setQuality] = useState( + DEFAULT_EXPORT_OPTIONS.quality + ); + const [isExporting, setIsExporting] = useState(false); + const [progress, setProgress] = useState(0); + const [exportResult, setExportResult] = useState(null); + + const handleExport = async () => { + if (!activeProject) return; + + setIsExporting(true); + setProgress(0); + setExportResult(null); + + const result = await exportProject({ + format, + quality, + fps: activeProject.fps, + onProgress: setProgress, + onCancel: () => false, // TODO: Add cancel functionality + }); + + setIsExporting(false); + setExportResult(result); + + if (result.success && result.buffer) { + // Download the file + const mimeType = getExportMimeType(format); + const extension = getExportFileExtension(format); + const blob = new Blob([result.buffer], { type: mimeType }); + const url = URL.createObjectURL(blob); + + const a = document.createElement("a"); + a.href = url; + a.download = `${activeProject.name}${extension}`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + + onOpenChange(false); + setExportResult(null); + setProgress(0); + } + }; + + const handleClose = () => { + if (!isExporting) { + onOpenChange(false); + setExportResult(null); + setProgress(0); + } + }; + + return ( + + <> +
+

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

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

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

+

100%

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

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

+ +
+ )} +
+ +
+ ); +} diff --git a/apps/web/src/components/editor/media-panel/views/base-view.tsx b/apps/web/src/components/editor/media-panel/views/base-view.tsx deleted file mode 100644 index 2cd33535..00000000 --- a/apps/web/src/components/editor/media-panel/views/base-view.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import { ScrollArea } from "@/components/ui/scroll-area"; -import { Separator } from "@/components/ui/separator"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; - -interface BaseViewProps { - children?: React.ReactNode; - defaultTab?: string; - tabs?: { - value: string; - label: string; - content: React.ReactNode; - }[]; - className?: string; - ref?: React.RefObject; -} - -function ViewContent({ - children, - className, -}: { - children: React.ReactNode; - className?: string; -}) { - return ( - -
{children}
-
- ); -} - -export function BaseView({ - children, - defaultTab, - tabs, - className = "", - ref, -}: BaseViewProps) { - return ( -
- {!tabs || tabs.length === 0 ? ( - {children} - ) : ( - -
- - {tabs.map((tab) => ( - - {tab.label} - - ))} - -
- - {tabs.map((tab) => ( - - {tab.content} - - ))} -
- )} -
- ); -} 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 393481a5..59403a8d 100644 --- a/apps/web/src/components/editor/media-panel/views/captions.tsx +++ b/apps/web/src/components/editor/media-panel/views/captions.tsx @@ -1,6 +1,6 @@ import { Button } from "@/components/ui/button"; import { PropertyGroup } from "../../properties-panel/property-item"; -import { BaseView } from "./base-view"; +import { PanelBaseView as BaseView } from "@/components/editor/panel-base-view"; import { Language, LanguageSelect } from "@/components/language-select"; import { useState, useRef, useEffect } from "react"; import { extractTimelineAudio } from "@/lib/mediabunny-utils"; 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 d33b4cce..df1ec54f 100644 --- a/apps/web/src/components/editor/media-panel/views/settings.tsx +++ b/apps/web/src/components/editor/media-panel/views/settings.tsx @@ -7,7 +7,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { BaseView } from "./base-view"; +import { PanelBaseView as BaseView } from "@/components/editor/panel-base-view"; import { PropertyItem, PropertyItemLabel, diff --git a/apps/web/src/components/editor/media-panel/views/text.tsx b/apps/web/src/components/editor/media-panel/views/text.tsx index 5d522c84..22fc2413 100644 --- a/apps/web/src/components/editor/media-panel/views/text.tsx +++ b/apps/web/src/components/editor/media-panel/views/text.tsx @@ -1,5 +1,5 @@ import { DraggableMediaItem } from "@/components/ui/draggable-item"; -import { BaseView } from "./base-view"; +import { PanelBaseView as BaseView } from "@/components/editor/panel-base-view"; import { useTimelineStore } from "@/stores/timeline-store"; import { DEFAULT_TEXT_ELEMENT } from "@/constants/text-constants"; diff --git a/apps/web/src/components/editor/onboarding.tsx b/apps/web/src/components/editor/onboarding.tsx new file mode 100644 index 00000000..8ee60690 --- /dev/null +++ b/apps/web/src/components/editor/onboarding.tsx @@ -0,0 +1,138 @@ +"use client"; + +import { Dialog, DialogContent, DialogTitle } from "../ui/dialog"; +import { Button } from "../ui/button"; +import { ArrowRightIcon } from "lucide-react"; +import { useState, useEffect } from "react"; +import ReactMarkdown from "react-markdown"; + +export function Onboarding() { + const [step, setStep] = useState(0); + const [isOpen, setIsOpen] = useState(false); + + useEffect(() => { + const hasSeenOnboarding = localStorage.getItem("hasSeenOnboarding"); + if (!hasSeenOnboarding) { + setIsOpen(true); + } + }, []); + + const handleNext = () => { + setStep(step + 1); + }; + + const handleClose = () => { + setIsOpen(false); + localStorage.setItem("hasSeenOnboarding", "true"); + }; + + const getStepTitle = () => { + switch (step) { + case 0: + return "Welcome to OpenCut Beta! 🎉"; + case 1: + return "⚠️ This is a super early beta!"; + case 2: + return "🦋 Have fun testing!"; + default: + return "OpenCut Onboarding"; + } + }; + + const renderStepContent = () => { + switch (step) { + case 0: + return ( +
+
+ + <Description description="You're among the first to try OpenCut - the fully open source CapCut alternative." /> + </div> + <NextButton onClick={handleNext}>Next</NextButton> + </div> + ); + case 1: + return ( + <div className="space-y-5"> + <div className="space-y-3"> + <Title title={getStepTitle()} /> + <Description description="OpenCut started just one month ago. There's still a ton of things to do to make this editor amazing." /> + <Description description="A lot of features are still missing, like the export functionality. We're working hard to build them out!" /> + <Description description="If you're curious, check out our roadmap [here](https://opencut.app/roadmap)" /> + </div> + <NextButton onClick={handleNext}>Next</NextButton> + </div> + ); + case 2: + return ( + <div className="space-y-5"> + <div className="space-y-3"> + <Title title={getStepTitle()} /> + <Description description="Join our [Discord](https://discord.gg/zmR9N35cjK), chat with cool people and share feedback to help make OpenCut the best editor ever." /> + </div> + <NextButton onClick={handleClose}>Finish</NextButton> + </div> + ); + default: + return null; + } + }; + + return ( + <Dialog open={isOpen} onOpenChange={handleClose}> + <DialogContent className="sm:max-w-[425px] !outline-none pt-2"> + <DialogTitle> + <span className="sr-only">{getStepTitle()}</span> + </DialogTitle> + {renderStepContent()} + </DialogContent> + </Dialog> + ); +} + +function Title({ title }: { title: string }) { + return <h2 className="text-lg md:text-xl font-bold">{title}</h2>; +} + +function Subtitle({ subtitle }: { subtitle: string }) { + return <h3 className="text-lg font-medium">{subtitle}</h3>; +} + +function Description({ description }: { description: string }) { + return ( + <div className="text-muted-foreground"> + <ReactMarkdown + components={{ + p: ({ children }) => <p className="mb-0">{children}</p>, + a: ({ href, children }) => ( + <a + href={href} + target="_blank" + rel="noopener noreferrer" + className="text-foreground hover:text-foreground/80 underline" + > + {children} + </a> + ), + }} + > + {description} + </ReactMarkdown> + </div> + ); +} + +function NextButton({ + children, + onClick, +}: { + children: React.ReactNode; + onClick: () => void; +}) { + return ( + <Button onClick={onClick} variant="default" className="w-full"> + {children} + <ArrowRightIcon className="w-4 h-4" /> + </Button> + ); +} diff --git a/apps/web/src/components/editor/panel-base-view.tsx b/apps/web/src/components/editor/panel-base-view.tsx new file mode 100644 index 00000000..0cfb8f40 --- /dev/null +++ b/apps/web/src/components/editor/panel-base-view.tsx @@ -0,0 +1,78 @@ +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Separator } from "@/components/ui/separator"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; + +interface PanelBaseViewProps { + children?: React.ReactNode; + defaultTab?: string; + value?: string; + onValueChange?: (value: string) => void; + tabs?: { + value: string; + label: string; + content: React.ReactNode; + }[]; + className?: string; + ref?: React.RefObject<HTMLDivElement>; +} + +function ViewContent({ + children, + className, +}: { + children: React.ReactNode; + className?: string; +}) { + return ( + <ScrollArea className="flex-1"> + <div className={`p-5 h-full ${className}`}>{children}</div> + </ScrollArea> + ); +} + +export function PanelBaseView({ + children, + defaultTab, + value, + onValueChange, + tabs, + className = "", + ref, +}: PanelBaseViewProps) { + return ( + <div className={`h-full flex flex-col ${className}`} ref={ref}> + {!tabs || tabs.length === 0 ? ( + <ViewContent className={className}>{children}</ViewContent> + ) : ( + <Tabs + defaultValue={defaultTab} + value={value} + onValueChange={onValueChange} + className="flex flex-col h-full" + > + <div className="sticky top-0 z-10 bg-panel"> + <div className="px-3 pt-4 pb-0"> + <TabsList> + {tabs.map((tab) => ( + <TabsTrigger key={tab.value} value={tab.value}> + {tab.label} + </TabsTrigger> + ))} + </TabsList> + </div> + <Separator className="mt-4" /> + </div> + {tabs.map((tab) => ( + <TabsContent + key={tab.value} + value={tab.value} + className="mt-0 flex-1 flex flex-col min-h-0" + > + <ViewContent>{tab.content}</ViewContent> + </TabsContent> + ))} + </Tabs> + )} + </div> + ); +} diff --git a/apps/web/src/components/editor/panel-preset-selector.tsx b/apps/web/src/components/editor/panel-preset-selector.tsx new file mode 100644 index 00000000..e6ae97b3 --- /dev/null +++ b/apps/web/src/components/editor/panel-preset-selector.tsx @@ -0,0 +1,91 @@ +"use client"; + +import { Button } from "../ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "../ui/dropdown-menu"; +import { ChevronDown, RotateCcw, LayoutPanelTop } from "lucide-react"; +import { usePanelStore, type PanelPreset } from "@/stores/panel-store"; + +const PRESET_LABELS: Record<PanelPreset, string> = { + default: "Default", + media: "Media", + inspector: "Inspector", + "vertical-preview": "Vertical Preview", +}; + +const PRESET_DESCRIPTIONS: Record<PanelPreset, string> = { + default: "Media, preview, and inspector on top row, timeline on bottom", + media: "Full height media on left, preview and inspector on top row", + inspector: "Full height inspector on right, media and preview on top row", + "vertical-preview": "Full height preview on right for vertical videos", +}; + +export function PanelPresetSelector() { + const { activePreset, setActivePreset, resetPreset } = usePanelStore(); + + const handlePresetChange = (preset: PanelPreset) => { + setActivePreset(preset); + }; + + const handleResetPreset = (preset: PanelPreset, event: React.MouseEvent) => { + event.stopPropagation(); + resetPreset(preset); + }; + + return ( + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button + variant="secondary" + size="sm" + className="h-8 px-2 flex items-center gap-1 text-xs" + title="Panel Presets" + > + <LayoutPanelTop className="h-4 w-4" /> + <ChevronDown className="h-3 w-3" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="start" className="w-64"> + <div className="px-2 py-1.5 text-xs font-medium text-muted-foreground"> + Panel Presets + </div> + <DropdownMenuSeparator /> + {(Object.keys(PRESET_LABELS) as PanelPreset[]).map((preset) => ( + <DropdownMenuItem + key={preset} + onClick={() => handlePresetChange(preset)} + className="flex items-start justify-between gap-2 py-2 cursor-pointer" + > + <div className="flex-1 min-w-0"> + <div className="flex items-center gap-2"> + <span className="font-medium text-sm"> + {PRESET_LABELS[preset]} + </span> + {activePreset === preset && ( + <div className="h-2 w-2 rounded-full bg-primary" /> + )} + </div> + <p className="text-xs text-muted-foreground mt-0.5 leading-tight"> + {PRESET_DESCRIPTIONS[preset]} + </p> + </div> + <Button + variant="secondary" + size="icon" + className="h-6 w-6 shrink-0 opacity-60 hover:opacity-100" + onClick={(e) => handleResetPreset(preset, e)} + title={`Reset ${PRESET_LABELS[preset]} preset`} + > + <RotateCcw className="h-3 w-3" /> + </Button> + </DropdownMenuItem> + ))} + </DropdownMenuContent> + </DropdownMenu> + ); +} diff --git a/apps/web/src/components/editor/preview-panel.tsx b/apps/web/src/components/editor/preview-panel.tsx index d988e7fb..1654624c 100644 --- a/apps/web/src/components/editor/preview-panel.tsx +++ b/apps/web/src/components/editor/preview-panel.tsx @@ -538,6 +538,7 @@ export function PreviewPanel() { activeProject?.backgroundType === "blur" ? "transparent" : activeProject?.backgroundColor || "#000000", + projectCanvasSize: canvasSize, }); // Blit offscreen to visible canvas diff --git a/apps/web/src/components/editor/properties-panel/property-item.tsx b/apps/web/src/components/editor/properties-panel/property-item.tsx index dc6b6d74..f7d5dbda 100644 --- a/apps/web/src/components/editor/properties-panel/property-item.tsx +++ b/apps/web/src/components/editor/properties-panel/property-item.tsx @@ -57,6 +57,7 @@ interface PropertyGroupProps { children: React.ReactNode; defaultExpanded?: boolean; className?: string; + titleClassName?: string; } export function PropertyGroup({ @@ -64,6 +65,7 @@ export function PropertyGroup({ children, defaultExpanded = true, className, + titleClassName, }: PropertyGroupProps) { const [isExpanded, setIsExpanded] = useState(defaultExpanded); @@ -73,7 +75,7 @@ export function PropertyGroup({ className="flex items-center gap-1.5 cursor-pointer" onClick={() => setIsExpanded(!isExpanded)} > - <PropertyItemLabel className="cursor-pointer"> + <PropertyItemLabel className={cn("cursor-pointer", titleClassName)}> {title} </PropertyItemLabel> <ChevronDown className={cn("size-3", !isExpanded && "-rotate-90")} /> diff --git a/apps/web/src/components/editor/properties-panel/text-properties.tsx b/apps/web/src/components/editor/properties-panel/text-properties.tsx index 744c2be5..139c7005 100644 --- a/apps/web/src/components/editor/properties-panel/text-properties.tsx +++ b/apps/web/src/components/editor/properties-panel/text-properties.tsx @@ -6,8 +6,14 @@ import { useTimelineStore } from "@/stores/timeline-store"; import { Slider } from "@/components/ui/slider"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; -import { Switch } from "@/components/ui/switch"; // Add Switch import +import { Switch } from "@/components/ui/switch"; import { useState, useRef } from "react"; +import { PanelBaseView } from "@/components/editor/panel-base-view"; +import { + TEXT_PROPERTIES_TABS, + isTextPropertiesTab, + useTextPropertiesStore, +} from "@/stores/text-properties-store"; import { PropertyItem, PropertyItemLabel, @@ -22,6 +28,7 @@ export function TextProperties({ trackId: string; }) { const { updateTextElement } = useTimelineStore(); + const { activeTab, setActiveTab } = useTextPropertiesStore(); // Local state for input values to allow temporary empty/invalid states const [fontSizeInput, setFontSizeInput] = useState( @@ -105,199 +112,232 @@ export function TextProperties({ }; return ( - <div className="space-y-6 p-5"> - <Textarea - placeholder="Name" - defaultValue={element.content} - className="min-h-18 resize-none bg-background/50" - onChange={(e) => - updateTextElement(trackId, element.id, { content: e.target.value }) - } - /> - <PropertyItem direction="row"> - <PropertyItemLabel>Font</PropertyItemLabel> - <PropertyItemValue> - <FontPicker - defaultValue={element.fontFamily} - onValueChange={(value: FontFamily) => - updateTextElement(trackId, element.id, { fontFamily: value }) - } - /> - </PropertyItemValue> - </PropertyItem> - <PropertyItem direction="column"> - <PropertyItem direction="row"> - <PropertyItemLabel>Style</PropertyItemLabel> - <PropertyItemValue> - <div className="flex items-center gap-2"> - <Button - variant={element.fontWeight === "bold" ? "default" : "outline"} - size="sm" - onClick={() => - updateTextElement(trackId, element.id, { - fontWeight: - element.fontWeight === "bold" ? "normal" : "bold", - }) - } - className="h-8 px-3 font-bold" - > - B - </Button> - <Button - variant={element.fontStyle === "italic" ? "default" : "outline"} - size="sm" - onClick={() => - updateTextElement(trackId, element.id, { - fontStyle: - element.fontStyle === "italic" ? "normal" : "italic", - }) - } - className="h-8 px-3 italic" - > - I - </Button> - <Button - variant={ - element.textDecoration === "underline" ? "default" : "outline" - } - size="sm" - onClick={() => + <PanelBaseView + defaultTab="transform" + value={activeTab} + onValueChange={(v) => { + if (isTextPropertiesTab(v)) setActiveTab(v); + }} + tabs={TEXT_PROPERTIES_TABS.map((t) => ({ + value: t.value, + label: t.label, + content: + t.value === "transform" ? ( + <div className="space-y-6"></div> + ) : ( + <div className="space-y-6"> + <Textarea + placeholder="Name" + defaultValue={element.content} + className="min-h-18 resize-none bg-background/50" + onChange={(e) => updateTextElement(trackId, element.id, { - textDecoration: - element.textDecoration === "underline" - ? "none" - : "underline", + content: e.target.value, }) } - className="h-8 px-3 underline" - > - U - </Button> - <Button - variant={ - element.textDecoration === "line-through" - ? "default" - : "outline" - } - size="sm" - onClick={() => - updateTextElement(trackId, element.id, { - textDecoration: - element.textDecoration === "line-through" - ? "none" - : "line-through", - }) - } - className="h-8 px-3 line-through" - > - S - </Button> - </div> - </PropertyItemValue> - </PropertyItem> - <PropertyItemLabel>Font size</PropertyItemLabel> - <PropertyItemValue> - <div className="flex items-center gap-2"> - <Slider - value={[element.fontSize]} - min={8} - max={300} - step={1} - onValueChange={([value]) => { - updateTextElement(trackId, element.id, { fontSize: value }); - setFontSizeInput(value.toString()); - }} - className="w-full" - /> - <Input - type="number" - value={fontSizeInput} - min={8} - max={300} - onChange={(e) => handleFontSizeChange(e.target.value)} - onBlur={handleFontSizeBlur} - className="w-12 !text-xs h-7 rounded-sm text-center + /> + <PropertyItem direction="row"> + <PropertyItemLabel>Font</PropertyItemLabel> + <PropertyItemValue> + <FontPicker + defaultValue={element.fontFamily} + onValueChange={(value: FontFamily) => + updateTextElement(trackId, element.id, { + fontFamily: value, + }) + } + /> + </PropertyItemValue> + </PropertyItem> + <PropertyItem direction="column"> + <PropertyItem direction="row"> + <PropertyItemLabel>Style</PropertyItemLabel> + <PropertyItemValue> + <div className="flex items-center gap-2"> + <Button + variant={ + element.fontWeight === "bold" ? "default" : "outline" + } + size="sm" + onClick={() => + updateTextElement(trackId, element.id, { + fontWeight: + element.fontWeight === "bold" ? "normal" : "bold", + }) + } + className="h-8 px-3 font-bold" + > + B + </Button> + <Button + variant={ + element.fontStyle === "italic" ? "default" : "outline" + } + size="sm" + onClick={() => + updateTextElement(trackId, element.id, { + fontStyle: + element.fontStyle === "italic" + ? "normal" + : "italic", + }) + } + className="h-8 px-3 italic" + > + I + </Button> + <Button + variant={ + element.textDecoration === "underline" + ? "default" + : "outline" + } + size="sm" + onClick={() => + updateTextElement(trackId, element.id, { + textDecoration: + element.textDecoration === "underline" + ? "none" + : "underline", + }) + } + className="h-8 px-3 underline" + > + U + </Button> + <Button + variant={ + element.textDecoration === "line-through" + ? "default" + : "outline" + } + size="sm" + onClick={() => + updateTextElement(trackId, element.id, { + textDecoration: + element.textDecoration === "line-through" + ? "none" + : "line-through", + }) + } + className="h-8 px-3 line-through" + > + S + </Button> + </div> + </PropertyItemValue> + </PropertyItem> + <PropertyItemLabel>Font size</PropertyItemLabel> + <PropertyItemValue> + <div className="flex items-center gap-2"> + <Slider + value={[element.fontSize]} + min={8} + max={300} + step={1} + onValueChange={([value]) => { + updateTextElement(trackId, element.id, { + fontSize: value, + }); + setFontSizeInput(value.toString()); + }} + className="w-full" + /> + <Input + type="number" + value={fontSizeInput} + min={8} + max={300} + onChange={(e) => handleFontSizeChange(e.target.value)} + onBlur={handleFontSizeBlur} + className="w-12 !text-xs h-7 rounded-sm text-center [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" - /> - </div> - </PropertyItemValue> - </PropertyItem> - <PropertyItem direction="row"> - <PropertyItemLabel>Color</PropertyItemLabel> - <PropertyItemValue> - <Input - type="color" - value={element.color || "#ffffff"} - onChange={(e) => { - const color = e.target.value; - updateTextElement(trackId, element.id, { color }); - }} - className="w-full cursor-pointer rounded-full" - /> - </PropertyItemValue> - </PropertyItem> - <PropertyItem direction="column"> - <div className="flex items-center justify-between"> - <PropertyItemLabel>Background</PropertyItemLabel> - <div className="flex items-center space-x-2"> - <Switch - id="transparent-bg-toggle" - checked={element.backgroundColor === "transparent"} - onCheckedChange={handleTransparentToggle} - /> - <label htmlFor="transparent-bg-toggle" className="text-sm font-medium"> - Transparent - </label> - </div> - </div> - <PropertyItemValue> - <Input - type="color" - value={ - element.backgroundColor === "transparent" - ? lastSelectedColor.current - : element.backgroundColor || "#000000" - } - onChange={(e) => handleColorChange(e.target.value)} - className="w-full cursor-pointer rounded-full" - disabled={element.backgroundColor === "transparent"} - /> - </PropertyItemValue> - </PropertyItem> - <PropertyItem direction="column"> - <PropertyItemLabel>Opacity</PropertyItemLabel> - <PropertyItemValue> - <div className="flex items-center gap-2"> - <Slider - value={[element.opacity * 100]} - min={0} - max={100} - step={1} - onValueChange={([value]) => { - updateTextElement(trackId, element.id, { - opacity: value / 100, - }); - setOpacityInput(value.toString()); - }} - className="w-full" - /> - <Input - type="number" - value={opacityInput} - min={0} - max={100} - onChange={(e) => handleOpacityChange(e.target.value)} - onBlur={handleOpacityBlur} - className="w-12 !text-xs h-7 rounded-sm text-center + /> + </div> + </PropertyItemValue> + </PropertyItem> + <PropertyItem direction="row"> + <PropertyItemLabel>Color</PropertyItemLabel> + <PropertyItemValue> + <Input + type="color" + value={element.color || "#ffffff"} + onChange={(e) => { + const color = e.target.value; + updateTextElement(trackId, element.id, { color }); + }} + className="w-full cursor-pointer rounded-full" + /> + </PropertyItemValue> + </PropertyItem> + <PropertyItem direction="column"> + <PropertyItemLabel>Opacity</PropertyItemLabel> + <PropertyItemValue> + <div className="flex items-center gap-2"> + <Slider + value={[element.opacity * 100]} + min={0} + max={100} + step={1} + onValueChange={([value]) => { + updateTextElement(trackId, element.id, { + opacity: value / 100, + }); + setOpacityInput(value.toString()); + }} + className="w-full" + /> + <Input + type="number" + value={opacityInput} + min={0} + max={100} + onChange={(e) => handleOpacityChange(e.target.value)} + onBlur={handleOpacityBlur} + className="w-12 !text-xs h-7 rounded-sm text-center [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" - /> - </div> - </PropertyItemValue> - </PropertyItem> - </div> + /> + </div> + </PropertyItemValue> + </PropertyItem> + <PropertyItem direction="column"> + <div className="flex items-center justify-between"> + <PropertyItemLabel>Background</PropertyItemLabel> + <div className="flex items-center space-x-2"> + <Switch + id="transparent-bg-toggle" + checked={element.backgroundColor === "transparent"} + onCheckedChange={handleTransparentToggle} + /> + <label + htmlFor="transparent-bg-toggle" + className="text-sm font-medium" + > + Transparent + </label> + </div> + </div> + <PropertyItemValue> + <Input + type="color" + value={ + element.backgroundColor === "transparent" + ? lastSelectedColor.current + : element.backgroundColor || "#000000" + } + onChange={(e) => handleColorChange(e.target.value)} + className="w-full cursor-pointer rounded-full" + disabled={element.backgroundColor === "transparent"} + /> + </PropertyItemValue> + </PropertyItem> + </div> + ), + }))} + /> ); } diff --git a/apps/web/src/components/export-button.tsx b/apps/web/src/components/export-button.tsx deleted file mode 100644 index c325db5c..00000000 --- a/apps/web/src/components/export-button.tsx +++ /dev/null @@ -1,185 +0,0 @@ -"use client"; - -import { useState } from "react"; -import { TransitionUpIcon } from "./icons"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from "./ui/dialog"; -import { Button } from "./ui/button"; -import { Input } from "./ui/input"; -import { useForm } from "react-hook-form"; -import { - Form, - FormControl, - FormDescription, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "./ui/form"; -import { - exportWaitlistSchema, - type ExportWaitlistInput, - type ExportWaitlistResponse, -} from "@/lib/schemas/waitlist"; -import { cn } from "@/lib/utils"; - -export function ExportButton() { - const [isExportDialogOpen, setIsExportDialogOpen] = useState(false); - - const handleExport = () => { - setIsExportDialogOpen(true); - }; - - return ( - <> - <button - type="button" - className="flex items-center gap-1.5 bg-[#38BDF8] text-white rounded-md px-[0.12rem] py-[0.12rem] cursor-pointer hover:brightness-95 transition-all duration-200" - onClick={handleExport} - onKeyDown={(event) => { - if (event.key === "Enter" || event.key === " ") { - event.preventDefault(); - handleExport(); - } - }} - > - <div className="flex items-center gap-1.5 bg-linear-270 from-[#2567EC] to-[#37B6F7] rounded-[0.8rem] px-4 py-1 relative shadow-[0_1px_3px_0px_rgba(0,0,0,0.65)]"> - <TransitionUpIcon className="z-50" /> - <span className="text-[0.875rem] z-50">Export (soon)</span> - <div className="absolute w-full h-full left-0 top-0 bg-linear-to-t from-white/0 to-white/50 z-10 rounded-[0.8rem] flex items-center justify-center"> - <div className="absolute w-[calc(100%-2px)] h-[calc(100%-2px)] top-[0.08rem] bg-linear-270 from-[#2567EC] to-[#37B6F7] z-50 rounded-[0.8rem]"></div> - </div> - </div> - </button> - <ExportDialog - isOpen={isExportDialogOpen} - onOpenChange={setIsExportDialogOpen} - /> - </> - ); -} - -function ExportDialog({ - isOpen, - onOpenChange, -}: { - isOpen: boolean; - onOpenChange: (open: boolean) => void; -}) { - const form = useForm<ExportWaitlistInput>({ defaultValues: { email: "" } }); - const { isSubmitting } = form.formState; - const [serverMessage, setServerMessage] = useState<{ - text: string; - type: "success" | "error"; - } | null>(null); - - const onSubmit = form.handleSubmit(async (values) => { - const parsed = exportWaitlistSchema.safeParse(values); - if (!parsed.success) { - for (const issue of parsed.error.issues) { - const fieldName = - (issue.path[0] as keyof ExportWaitlistInput) || "email"; - form.setError(fieldName, { type: "zod", message: issue.message }); - } - return; - } - setServerMessage(null); - const { email } = parsed.data; - const response = await fetch("/api/waitlist/export", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ email }), - }); - if (!response.ok) { - setServerMessage({ - text: "Something went wrong. Please try again.", - type: "error", - }); - return; - } - const data: ExportWaitlistResponse = await response.json(); - if (data.success && data.alreadySubscribed) { - setServerMessage({ - text: "You're already on the list.", - type: "success", - }); - return; - } - if (data.success) { - setServerMessage({ - text: "You're on the list. We'll email you when it's ready.", - type: "success", - }); - form.reset(); - return; - } - setServerMessage({ - text: "Couldn't add your email. Please try again.", - type: "error", - }); - }); - return ( - <Dialog open={isOpen} onOpenChange={onOpenChange}> - <DialogContent className="p-6"> - <DialogHeader> - <DialogTitle>Export Project</DialogTitle> - </DialogHeader> - <div className="space-y-4"> - <DialogDescription> - Export isn't ready yet. we're building a custom pipeline to make it - great. - </DialogDescription> - <Form {...form}> - <form className="flex flex-col gap-5" onSubmit={onSubmit}> - <div className="flex flex-col gap-4 w-full"> - <FormField - control={form.control} - name="email" - render={({ field }) => ( - <FormItem> - <FormLabel>Email</FormLabel> - <FormControl> - <Input - type="email" - placeholder="you@example.com" - inputMode="email" - autoComplete="email" - disabled={isSubmitting} - {...field} - /> - </FormControl> - <FormDescription> - We'll let you know once export is ready. - </FormDescription> - <FormMessage /> - </FormItem> - )} - /> - <Button type="submit" disabled={isSubmitting}> - {isSubmitting ? "Sending..." : "Notify me"} - </Button> - </div> - {serverMessage ? ( - <p - role="status" - aria-live="polite" - className={cn( - "text-xs", - serverMessage.type === "success" ? "text-green-600" : "text-red-600" - )} - > - {serverMessage.text} - </p> - ) : null} - </form> - </Form> - </div> - </DialogContent> - </Dialog> - ); -} diff --git a/apps/web/src/components/onboarding.tsx b/apps/web/src/components/onboarding.tsx deleted file mode 100644 index ac4b0f04..00000000 --- a/apps/web/src/components/onboarding.tsx +++ /dev/null @@ -1,138 +0,0 @@ -"use client"; - -import { Dialog, DialogContent, DialogTitle } from "./ui/dialog"; -import { Button } from "./ui/button"; -import { ArrowRightIcon } from "lucide-react"; -import { useState, useEffect } from "react"; -import ReactMarkdown from "react-markdown"; - -export function Onboarding() { - const [step, setStep] = useState(0); - const [isOpen, setIsOpen] = useState(false); - - useEffect(() => { - const hasSeenOnboarding = localStorage.getItem("hasSeenOnboarding"); - if (!hasSeenOnboarding) { - setIsOpen(true); - } - }, []); - - const handleNext = () => { - setStep(step + 1); - }; - - const handleClose = () => { - setIsOpen(false); - localStorage.setItem("hasSeenOnboarding", "true"); - }; - - const getStepTitle = () => { - switch (step) { - case 0: - return "Welcome to OpenCut Beta! 🎉"; - case 1: - return "⚠️ This is a super early beta!"; - case 2: - return "🦋 Have fun testing!"; - default: - return "OpenCut Onboarding"; - } - }; - - const renderStepContent = () => { - switch (step) { - case 0: - return ( - <div className="space-y-5"> - <div className="space-y-3"> - <Title title="Welcome to OpenCut Beta! 🎉" /> - <Description description="You're among the first to try OpenCut - the fully open source CapCut alternative." /> - </div> - <NextButton onClick={handleNext}>Next</NextButton> - </div> - ); - case 1: - return ( - <div className="space-y-5"> - <div className="space-y-3"> - <Title title={getStepTitle()} /> - <Description description="OpenCut started just one month ago. There's still a ton of things to do to make this editor amazing." /> - <Description description="A lot of features are still missing, like the export functionality. We're working hard to build them out!" /> - <Description description="If you're curious, check out our roadmap [here](https://opencut.app/roadmap)" /> - </div> - <NextButton onClick={handleNext}>Next</NextButton> - </div> - ); - case 2: - return ( - <div className="space-y-5"> - <div className="space-y-3"> - <Title title={getStepTitle()} /> - <Description description="Join our [Discord](https://discord.gg/zmR9N35cjK), chat with cool people and share feedback to help make OpenCut the best editor ever." /> - </div> - <NextButton onClick={handleClose}>Finish</NextButton> - </div> - ); - default: - return null; - } - }; - - return ( - <Dialog open={isOpen} onOpenChange={handleClose}> - <DialogContent className="sm:max-w-[425px] !outline-none pt-2"> - <DialogTitle> - <span className="sr-only">{getStepTitle()}</span> - </DialogTitle> - {renderStepContent()} - </DialogContent> - </Dialog> - ); -} - -function Title({ title }: { title: string }) { - return <h2 className="text-lg md:text-xl font-bold">{title}</h2>; -} - -function Subtitle({ subtitle }: { subtitle: string }) { - return <h3 className="text-lg font-medium">{subtitle}</h3>; -} - -function Description({ description }: { description: string }) { - return ( - <div className="text-muted-foreground"> - <ReactMarkdown - components={{ - p: ({ children }) => <p className="mb-0">{children}</p>, - a: ({ href, children }) => ( - <a - href={href} - target="_blank" - rel="noopener noreferrer" - className="text-foreground hover:text-foreground/80 underline" - > - {children} - </a> - ), - }} - > - {description} - </ReactMarkdown> - </div> - ); -} - -function NextButton({ - children, - onClick, -}: { - children: React.ReactNode; - onClick: () => void; -}) { - return ( - <Button onClick={onClick} variant="default" className="w-full"> - {children} - <ArrowRightIcon className="w-4 h-4" /> - </Button> - ); -} diff --git a/apps/web/src/components/panel-preset-selector.tsx b/apps/web/src/components/panel-preset-selector.tsx deleted file mode 100644 index c5351f76..00000000 --- a/apps/web/src/components/panel-preset-selector.tsx +++ /dev/null @@ -1,91 +0,0 @@ -"use client"; - -import { Button } from "./ui/button"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "./ui/dropdown-menu"; -import { ChevronDown, RotateCcw, LayoutPanelTop } from "lucide-react"; -import { usePanelStore, type PanelPreset } from "@/stores/panel-store"; - -const PRESET_LABELS: Record<PanelPreset, string> = { - default: "Default", - media: "Media", - inspector: "Inspector", - "vertical-preview": "Vertical Preview", -}; - -const PRESET_DESCRIPTIONS: Record<PanelPreset, string> = { - default: "Media, preview, and inspector on top row, timeline on bottom", - media: "Full height media on left, preview and inspector on top row", - inspector: "Full height inspector on right, media and preview on top row", - "vertical-preview": "Full height preview on right for vertical videos", -}; - -export function PanelPresetSelector() { - const { activePreset, setActivePreset, resetPreset } = usePanelStore(); - - const handlePresetChange = (preset: PanelPreset) => { - setActivePreset(preset); - }; - - const handleResetPreset = (preset: PanelPreset, event: React.MouseEvent) => { - event.stopPropagation(); - resetPreset(preset); - }; - - return ( - <DropdownMenu> - <DropdownMenuTrigger asChild> - <Button - variant="secondary" - size="sm" - className="h-8 px-2 flex items-center gap-1 text-xs" - title="Panel Presets" - > - <LayoutPanelTop className="h-4 w-4" /> - <ChevronDown className="h-3 w-3" /> - </Button> - </DropdownMenuTrigger> - <DropdownMenuContent align="start" className="w-64"> - <div className="px-2 py-1.5 text-xs font-medium text-muted-foreground"> - Panel Presets - </div> - <DropdownMenuSeparator /> - {(Object.keys(PRESET_LABELS) as PanelPreset[]).map((preset) => ( - <DropdownMenuItem - key={preset} - onClick={() => handlePresetChange(preset)} - className="flex items-start justify-between gap-2 py-2 cursor-pointer" - > - <div className="flex-1 min-w-0"> - <div className="flex items-center gap-2"> - <span className="font-medium text-sm"> - {PRESET_LABELS[preset]} - </span> - {activePreset === preset && ( - <div className="h-2 w-2 rounded-full bg-primary" /> - )} - </div> - <p className="text-xs text-muted-foreground mt-0.5 leading-tight"> - {PRESET_DESCRIPTIONS[preset]} - </p> - </div> - <Button - variant="secondary" - size="icon" - className="h-6 w-6 shrink-0 opacity-60 hover:opacity-100" - onClick={(e) => handleResetPreset(preset, e)} - title={`Reset ${PRESET_LABELS[preset]} preset`} - > - <RotateCcw className="h-3 w-3" /> - </Button> - </DropdownMenuItem> - ))} - </DropdownMenuContent> - </DropdownMenu> - ); -} diff --git a/apps/web/src/components/providers/editor-provider.tsx b/apps/web/src/components/providers/editor-provider.tsx new file mode 100644 index 00000000..b8e9ae1d --- /dev/null +++ b/apps/web/src/components/providers/editor-provider.tsx @@ -0,0 +1,53 @@ +"use client"; + +import { useEffect } from "react"; +import { Loader2 } from "lucide-react"; +import { useEditorStore } from "@/stores/editor-store"; +import { + useKeybindingsListener, + useKeybindingDisabler, +} from "@/hooks/use-keybindings"; +import { useEditorActions } from "@/hooks/use-editor-actions"; + +interface EditorProviderProps { + children: React.ReactNode; +} + +export function EditorProvider({ children }: EditorProviderProps) { + const { isInitializing, isPanelsReady, initializeApp } = useEditorStore(); + const { disableKeybindings, enableKeybindings } = useKeybindingDisabler(); + + // Set up action handlers + useEditorActions(); + + // Set up keybinding listener + useKeybindingsListener(); + + // Disable keybindings when initializing + useEffect(() => { + if (isInitializing || !isPanelsReady) { + disableKeybindings(); + } else { + enableKeybindings(); + } + }, [isInitializing, isPanelsReady, disableKeybindings, enableKeybindings]); + + useEffect(() => { + initializeApp(); + }, [initializeApp]); + + // Show loading screen while initializing + if (isInitializing || !isPanelsReady) { + return ( + <div className="h-screen w-screen flex items-center justify-center bg-background"> + <div className="flex flex-col items-center gap-4"> + <Loader2 className="h-8 w-8 animate-spin text-muted-foreground" /> + <p className="text-sm text-muted-foreground">Loading editor...</p> + </div> + </div> + ); + } + + // App is ready, render children + return <>{children}</>; +} diff --git a/apps/web/src/components/ui/progress.tsx b/apps/web/src/components/ui/progress.tsx index 283ba17a..6b785a58 100644 --- a/apps/web/src/components/ui/progress.tsx +++ b/apps/web/src/components/ui/progress.tsx @@ -12,13 +12,13 @@ const Progress = React.forwardRef< <ProgressPrimitive.Root ref={ref} className={cn( - "relative h-2 w-full overflow-hidden rounded-full bg-primary/20", + "relative h-2 w-full overflow-hidden rounded-full bg-accent", className )} {...props} > <ProgressPrimitive.Indicator - className="h-full w-full flex-1 bg-primary transition-all" + className="h-full w-full flex-1 bg-foreground transition-all" style={{ transform: `translateX(-${100 - (value || 0)}%)` }} /> </ProgressPrimitive.Root> diff --git a/apps/web/src/lib/export.ts b/apps/web/src/lib/export.ts new file mode 100644 index 00000000..69bc8d1d --- /dev/null +++ b/apps/web/src/lib/export.ts @@ -0,0 +1,147 @@ +import { + Output, + Mp4OutputFormat, + WebMOutputFormat, + BufferTarget, + CanvasSource, + QUALITY_LOW, + QUALITY_MEDIUM, + QUALITY_HIGH, + QUALITY_VERY_HIGH, +} from "mediabunny"; +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 { ExportOptions, ExportResult } from "@/types/export"; + +export const DEFAULT_EXPORT_OPTIONS: ExportOptions = { + format: "mp4", + quality: "high", +}; + +const qualityMap = { + low: QUALITY_LOW, + medium: QUALITY_MEDIUM, + high: QUALITY_HIGH, + very_high: QUALITY_VERY_HIGH, +}; + +export async function exportProject( + options: ExportOptions +): Promise<ExportResult> { + const { format, quality, fps, onProgress, onCancel } = options; + + try { + const timelineStore = useTimelineStore.getState(); + const mediaStore = useMediaStore.getState(); + const projectStore = useProjectStore.getState(); + + const { tracks, getTotalDuration } = timelineStore; + const { mediaFiles } = mediaStore; + const { activeProject } = projectStore; + + if (!activeProject) { + return { success: false, error: "No active project" }; + } + + const duration = getTotalDuration(); + if (duration === 0) { + return { success: false, error: "Project is empty" }; + } + + const exportFps = fps || activeProject.fps || DEFAULT_FPS; + const canvasSize = activeProject.canvasSize; + + const outputFormat = + format === "webm" ? new WebMOutputFormat() : new Mp4OutputFormat(); + + // BufferTarget for smaller files, StreamTarget for larger ones + // TODO: Implement StreamTarget + const output = new Output({ + format: outputFormat, + target: new BufferTarget(), + }); + + // Canvas for rendering + const canvas = document.createElement("canvas"); + canvas.width = canvasSize.width; + canvas.height = canvasSize.height; + const ctx = canvas.getContext("2d"); + + if (!ctx) { + return { success: false, error: "Failed to create canvas context" }; + } + + const videoSource = new CanvasSource(canvas, { + codec: format === "webm" ? "vp9" : "avc", // VP9 for WebM, H.264 for MP4 + bitrate: qualityMap[quality], + }); + + output.addVideoTrack(videoSource, { frameRate: exportFps }); + + // Start the output + await output.start(); + + const totalFrames = Math.ceil(duration * exportFps); + let cancelled = false; + + // Render each frame + for (let frameIndex = 0; frameIndex < totalFrames; frameIndex++) { + // Check for cancellation + if (onCancel?.()) { + cancelled = true; + break; + } + + const time = frameIndex / exportFps; + + await renderTimelineFrame({ + ctx, + time, + canvasWidth: canvas.width, + canvasHeight: canvas.height, + tracks, + mediaFiles, + backgroundColor: + activeProject.backgroundType === "blur" + ? "transparent" + : activeProject.backgroundColor || "#000000", + projectCanvasSize: canvasSize, + }); + + const frameDuration = 1 / exportFps; + await videoSource.add(time, frameDuration); + + onProgress?.(frameIndex / totalFrames); + } + + if (cancelled) { + await output.cancel(); + return { success: false, cancelled: true }; + } + videoSource.close(); + await output.finalize(); + onProgress?.(1); + + return { + success: true, + buffer: output.target.buffer || undefined, + }; + } catch (error) { + console.error("Export failed:", error); + return { + success: false, + error: error instanceof Error ? error.message : "Unknown export error", + }; + } +} + +export function getExportMimeType(format: "mp4" | "webm"): string { + return format === "webm" ? "video/webm" : "video/mp4"; +} + +export function getExportFileExtension(format: "mp4" | "webm"): string { + return `.${format}`; +} diff --git a/apps/web/src/lib/timeline-renderer.ts b/apps/web/src/lib/timeline-renderer.ts index 06825896..6241a7d9 100644 --- a/apps/web/src/lib/timeline-renderer.ts +++ b/apps/web/src/lib/timeline-renderer.ts @@ -10,6 +10,7 @@ export interface RenderContext { tracks: TimelineTrack[]; mediaFiles: MediaFile[]; backgroundColor?: string; + projectCanvasSize?: { width: number; height: number }; } export async function renderTimelineFrame({ @@ -20,6 +21,7 @@ export async function renderTimelineFrame({ tracks, mediaFiles, backgroundColor, + projectCanvasSize, }: RenderContext): Promise<void> { // Background ctx.clearRect(0, 0, canvasWidth, canvasHeight); @@ -28,8 +30,10 @@ export async function renderTimelineFrame({ ctx.fillRect(0, 0, canvasWidth, canvasHeight); } - const scaleX = 1; - const scaleY = 1; + const scaleX = projectCanvasSize ? canvasWidth / projectCanvasSize.width : 1; + const scaleY = projectCanvasSize + ? canvasHeight / projectCanvasSize.height + : 1; const idToMedia = new Map(mediaFiles.map((m) => [m.id, m] as const)); const active: Array<{ track: TimelineTrack; diff --git a/apps/web/src/stores/text-properties-store.ts b/apps/web/src/stores/text-properties-store.ts new file mode 100644 index 00000000..b3ce3689 --- /dev/null +++ b/apps/web/src/stores/text-properties-store.ts @@ -0,0 +1,33 @@ +import { create } from "zustand"; +import { persist } from "zustand/middleware"; + +export type TextPropertiesTab = "transform" | "style"; + +export interface TextPropertiesTabMeta { + value: TextPropertiesTab; + label: string; +} + +export const TEXT_PROPERTIES_TABS: ReadonlyArray<TextPropertiesTabMeta> = [ + { value: "transform", label: "Transform" }, + { value: "style", label: "Style" }, +] as const; + +export function isTextPropertiesTab(value: string): value is TextPropertiesTab { + return TEXT_PROPERTIES_TABS.some((t) => t.value === value); +} + +interface TextPropertiesState { + activeTab: TextPropertiesTab; + setActiveTab: (tab: TextPropertiesTab) => void; +} + +export const useTextPropertiesStore = create<TextPropertiesState>()( + persist( + (set) => ({ + activeTab: "transform", + setActiveTab: (tab) => set({ activeTab: tab }), + }), + { name: "text-properties" } + ) +); diff --git a/apps/web/src/types/export.ts b/apps/web/src/types/export.ts new file mode 100644 index 00000000..2fa10a50 --- /dev/null +++ b/apps/web/src/types/export.ts @@ -0,0 +1,17 @@ +export type ExportFormat = "mp4" | "webm"; +export type ExportQuality = "low" | "medium" | "high" | "very_high"; + +export interface ExportOptions { + format: ExportFormat; + quality: ExportQuality; + fps?: number; + onProgress?: (progress: number) => void; + onCancel?: () => boolean; +} + +export interface ExportResult { + success: boolean; + buffer?: ArrayBuffer; + error?: string; + cancelled?: boolean; +} -- cgit v1.3.1 From 163b3599dc80ba522b41fbd8d18c8c2b9911c35b Mon Sep 17 00:00:00 2001 From: Maze Winther <mazewinther@gmail.com> Date: Wed, 27 Aug 2025 00:56:37 +0200 Subject: padding --- apps/web/src/components/editor/panel-base-view.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) (limited to 'apps') diff --git a/apps/web/src/components/editor/panel-base-view.tsx b/apps/web/src/components/editor/panel-base-view.tsx index 0cfb8f40..ec01028c 100644 --- a/apps/web/src/components/editor/panel-base-view.tsx +++ b/apps/web/src/components/editor/panel-base-view.tsx @@ -51,7 +51,7 @@ export function PanelBaseView({ className="flex flex-col h-full" > <div className="sticky top-0 z-10 bg-panel"> - <div className="px-3 pt-4 pb-0"> + <div className="px-3 pt-3.5 pb-0"> <TabsList> {tabs.map((tab) => ( <TabsTrigger key={tab.value} value={tab.value}> @@ -60,7 +60,7 @@ export function PanelBaseView({ ))} </TabsList> </div> - <Separator className="mt-4" /> + <Separator className="mt-3.5" /> </div> {tabs.map((tab) => ( <TabsContent -- cgit v1.3.1 From 88c4d160e02c040c50c5b9252c761c50f56e1ae5 Mon Sep 17 00:00:00 2001 From: Maze Winther <mazewinther@gmail.com> Date: Wed, 27 Aug 2025 01:02:43 +0200 Subject: more padding --- apps/web/src/components/editor/panel-preset-selector.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) (limited to 'apps') diff --git a/apps/web/src/components/editor/panel-preset-selector.tsx b/apps/web/src/components/editor/panel-preset-selector.tsx index e6ae97b3..af22a171 100644 --- a/apps/web/src/components/editor/panel-preset-selector.tsx +++ b/apps/web/src/components/editor/panel-preset-selector.tsx @@ -51,7 +51,7 @@ export function PanelPresetSelector() { </Button> </DropdownMenuTrigger> <DropdownMenuContent align="start" className="w-64"> - <div className="px-2 py-1.5 text-xs font-medium text-muted-foreground"> + <div className="px-3 py-2 text-xs font-medium text-muted-foreground"> Panel Presets </div> <DropdownMenuSeparator /> @@ -59,7 +59,7 @@ export function PanelPresetSelector() { <DropdownMenuItem key={preset} onClick={() => handlePresetChange(preset)} - className="flex items-start justify-between gap-2 py-2 cursor-pointer" + className="flex items-start justify-between gap-2 py-2 px-3 cursor-pointer" > <div className="flex-1 min-w-0"> <div className="flex items-center gap-2"> -- cgit v1.3.1 From ebe1b38bdf572b5aa787e21218022f209720b17a Mon Sep 17 00:00:00 2001 From: Maze Winther <mazewinther@gmail.com> Date: Wed, 27 Aug 2025 11:26:28 +0200 Subject: style: properties panel --- .../editor/properties-panel/text-properties.tsx | 162 ++++++++++----------- apps/web/src/components/ui/font-picker.tsx | 2 +- apps/web/src/components/ui/textarea.tsx | 4 +- 3 files changed, 85 insertions(+), 83 deletions(-) (limited to 'apps') diff --git a/apps/web/src/components/editor/properties-panel/text-properties.tsx b/apps/web/src/components/editor/properties-panel/text-properties.tsx index 139c7005..5f0e3706 100644 --- a/apps/web/src/components/editor/properties-panel/text-properties.tsx +++ b/apps/web/src/components/editor/properties-panel/text-properties.tsx @@ -129,14 +129,14 @@ export function TextProperties({ <Textarea placeholder="Name" defaultValue={element.content} - className="min-h-18 resize-none bg-background/50" + className="min-h-18 resize-none bg-panel-accent" onChange={(e) => updateTextElement(trackId, element.id, { content: e.target.value, }) } /> - <PropertyItem direction="row"> + <PropertyItem direction="column"> <PropertyItemLabel>Font</PropertyItemLabel> <PropertyItemValue> <FontPicker @@ -150,83 +150,83 @@ export function TextProperties({ </PropertyItemValue> </PropertyItem> <PropertyItem direction="column"> - <PropertyItem direction="row"> - <PropertyItemLabel>Style</PropertyItemLabel> - <PropertyItemValue> - <div className="flex items-center gap-2"> - <Button - variant={ - element.fontWeight === "bold" ? "default" : "outline" - } - size="sm" - onClick={() => - updateTextElement(trackId, element.id, { - fontWeight: - element.fontWeight === "bold" ? "normal" : "bold", - }) - } - className="h-8 px-3 font-bold" - > - B - </Button> - <Button - variant={ - element.fontStyle === "italic" ? "default" : "outline" - } - size="sm" - onClick={() => - updateTextElement(trackId, element.id, { - fontStyle: - element.fontStyle === "italic" - ? "normal" - : "italic", - }) - } - className="h-8 px-3 italic" - > - I - </Button> - <Button - variant={ - element.textDecoration === "underline" - ? "default" - : "outline" - } - size="sm" - onClick={() => - updateTextElement(trackId, element.id, { - textDecoration: - element.textDecoration === "underline" - ? "none" - : "underline", - }) - } - className="h-8 px-3 underline" - > - U - </Button> - <Button - variant={ - element.textDecoration === "line-through" - ? "default" - : "outline" - } - size="sm" - onClick={() => - updateTextElement(trackId, element.id, { - textDecoration: - element.textDecoration === "line-through" - ? "none" - : "line-through", - }) - } - className="h-8 px-3 line-through" - > - S - </Button> - </div> - </PropertyItemValue> - </PropertyItem> + <PropertyItemLabel>Style</PropertyItemLabel> + <PropertyItemValue> + <div className="flex items-center gap-2"> + <Button + variant={ + element.fontWeight === "bold" ? "default" : "outline" + } + size="sm" + onClick={() => + updateTextElement(trackId, element.id, { + fontWeight: + element.fontWeight === "bold" ? "normal" : "bold", + }) + } + className="h-8 px-3 font-bold" + > + B + </Button> + <Button + variant={ + element.fontStyle === "italic" ? "default" : "outline" + } + size="sm" + onClick={() => + updateTextElement(trackId, element.id, { + fontStyle: + element.fontStyle === "italic" + ? "normal" + : "italic", + }) + } + className="h-8 px-3 italic" + > + I + </Button> + <Button + variant={ + element.textDecoration === "underline" + ? "default" + : "outline" + } + size="sm" + onClick={() => + updateTextElement(trackId, element.id, { + textDecoration: + element.textDecoration === "underline" + ? "none" + : "underline", + }) + } + className="h-8 px-3 underline" + > + U + </Button> + <Button + variant={ + element.textDecoration === "line-through" + ? "default" + : "outline" + } + size="sm" + onClick={() => + updateTextElement(trackId, element.id, { + textDecoration: + element.textDecoration === "line-through" + ? "none" + : "line-through", + }) + } + className="h-8 px-3 line-through" + > + S + </Button> + </div> + </PropertyItemValue> + </PropertyItem> + <PropertyItem direction="column"> <PropertyItemLabel>Font size</PropertyItemLabel> <PropertyItemValue> <div className="flex items-center gap-2"> @@ -250,7 +250,7 @@ export function TextProperties({ max={300} onChange={(e) => handleFontSizeChange(e.target.value)} onBlur={handleFontSizeBlur} - className="w-12 !text-xs h-7 rounded-sm text-center + className="w-12 px-2 !text-xs h-7 rounded-sm text-center [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" @@ -258,7 +258,7 @@ export function TextProperties({ </div> </PropertyItemValue> </PropertyItem> - <PropertyItem direction="row"> + <PropertyItem direction="column"> <PropertyItemLabel>Color</PropertyItemLabel> <PropertyItemValue> <Input diff --git a/apps/web/src/components/ui/font-picker.tsx b/apps/web/src/components/ui/font-picker.tsx index 479a5f0c..7bb11274 100644 --- a/apps/web/src/components/ui/font-picker.tsx +++ b/apps/web/src/components/ui/font-picker.tsx @@ -20,7 +20,7 @@ export function FontPicker({ }: FontPickerProps) { return ( <Select defaultValue={defaultValue} onValueChange={onValueChange}> - <SelectTrigger className={`w-full text-xs ${className || ""}`}> + <SelectTrigger className={`w-full bg-panel-accent h-8 text-xs ${className || ""}`}> <SelectValue placeholder="Select a font" /> </SelectTrigger> <SelectContent> diff --git a/apps/web/src/components/ui/textarea.tsx b/apps/web/src/components/ui/textarea.tsx index b9004a34..d9ae9ea6 100644 --- a/apps/web/src/components/ui/textarea.tsx +++ b/apps/web/src/components/ui/textarea.tsx @@ -9,7 +9,9 @@ const Textarea = React.forwardRef< return ( <textarea className={cn( - "flex min-h-[60px] w-full rounded-md border border-input bg-background px-3 py-2 text-base shadow-xs placeholder:text-muted-foreground focus-visible:outline-hidden focus-visible:ring-0 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-xs", + "file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex min-h-[60px] w-full rounded-md border bg-accent/50 px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", + "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[2px]", + "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", className )} ref={ref} -- cgit v1.3.1 From f9170dcf09571b617bce9895f9c72efda8544b4c Mon Sep 17 00:00:00 2001 From: Maze Winther <mazewinther@gmail.com> Date: Wed, 27 Aug 2025 17:23:16 +0200 Subject: style: re-design colors in text properties panel --- .../editor/properties-panel/text-properties.tsx | 101 ++++--- apps/web/src/components/ui/color-picker.tsx | 325 +++++++++++++++++++++ apps/web/src/lib/utils.ts | 4 + bun.lock | 8 +- 4 files changed, 400 insertions(+), 38 deletions(-) create mode 100644 apps/web/src/components/ui/color-picker.tsx (limited to 'apps') diff --git a/apps/web/src/components/editor/properties-panel/text-properties.tsx b/apps/web/src/components/editor/properties-panel/text-properties.tsx index 5f0e3706..deaee157 100644 --- a/apps/web/src/components/editor/properties-panel/text-properties.tsx +++ b/apps/web/src/components/editor/properties-panel/text-properties.tsx @@ -6,7 +6,6 @@ import { useTimelineStore } from "@/stores/timeline-store"; import { Slider } from "@/components/ui/slider"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; -import { Switch } from "@/components/ui/switch"; import { useState, useRef } from "react"; import { PanelBaseView } from "@/components/editor/panel-base-view"; import { @@ -19,6 +18,14 @@ import { PropertyItemLabel, PropertyItemValue, } from "./property-item"; +import { ColorPicker } from "@/components/ui/color-picker"; +import { cn, uppercase } from "@/lib/utils"; +import { Grid2x2 } from "lucide-react"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; export function TextProperties({ element, @@ -29,7 +36,7 @@ export function TextProperties({ }) { const { updateTextElement } = useTimelineStore(); const { activeTab, setActiveTab } = useTextPropertiesStore(); - + const containerRef = useRef<HTMLDivElement>(null); // Local state for input values to allow temporary empty/invalid states const [fontSizeInput, setFontSizeInput] = useState( element.fontSize.toString() @@ -118,6 +125,7 @@ export function TextProperties({ onValueChange={(v) => { if (isTextPropertiesTab(v)) setActiveTab(v); }} + ref={containerRef} tabs={TEXT_PROPERTIES_TABS.map((t) => ({ value: t.value, label: t.label, @@ -250,7 +258,7 @@ export function TextProperties({ max={300} onChange={(e) => handleFontSizeChange(e.target.value)} onBlur={handleFontSizeBlur} - className="w-12 px-2 !text-xs h-7 rounded-sm text-center + className="w-12 px-2 !text-xs h-7 rounded-sm text-center bg-panel-accent [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" @@ -261,14 +269,16 @@ export function TextProperties({ <PropertyItem direction="column"> <PropertyItemLabel>Color</PropertyItemLabel> <PropertyItemValue> - <Input - type="color" - value={element.color || "#ffffff"} - onChange={(e) => { - const color = e.target.value; - updateTextElement(trackId, element.id, { color }); + <ColorPicker + value={uppercase( + (element.color || "FFFFFF").replace("#", "") + )} + onChange={(color) => { + updateTextElement(trackId, element.id, { + color: `#${color}`, + }); }} - className="w-full cursor-pointer rounded-full" + containerRef={containerRef} /> </PropertyItemValue> </PropertyItem> @@ -296,7 +306,7 @@ export function TextProperties({ max={100} onChange={(e) => handleOpacityChange(e.target.value)} onBlur={handleOpacityBlur} - className="w-12 !text-xs h-7 rounded-sm text-center + className="w-12 !text-xs h-7 rounded-sm text-center bg-panel-accent [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" @@ -305,34 +315,51 @@ export function TextProperties({ </PropertyItemValue> </PropertyItem> <PropertyItem direction="column"> - <div className="flex items-center justify-between"> - <PropertyItemLabel>Background</PropertyItemLabel> - <div className="flex items-center space-x-2"> - <Switch - id="transparent-bg-toggle" - checked={element.backgroundColor === "transparent"} - onCheckedChange={handleTransparentToggle} + <PropertyItemLabel>Background</PropertyItemLabel> + <PropertyItemValue> + <div className="flex items-center gap-2"> + <ColorPicker + value={uppercase( + element.backgroundColor === "transparent" + ? lastSelectedColor.current.replace("#", "") + : (element.backgroundColor || "#000000").replace( + "#", + "" + ) + )} + onChange={(color) => handleColorChange(`#${color}`)} + containerRef={containerRef} + className={ + element.backgroundColor === "transparent" + ? "opacity-50 pointer-events-none" + : "" + } /> - <label - htmlFor="transparent-bg-toggle" - className="text-sm font-medium" - > - Transparent - </label> + + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="outline" + size="icon" + onClick={() => + handleTransparentToggle( + element.backgroundColor !== "transparent" + ) + } + className="size-9 rounded-full bg-panel-accent p-0 overflow-hidden" + > + <Grid2x2 + className={cn( + "text-foreground", + element.backgroundColor === "transparent" && + "text-primary" + )} + /> + </Button> + </TooltipTrigger> + <TooltipContent>Transparent background</TooltipContent> + </Tooltip> </div> - </div> - <PropertyItemValue> - <Input - type="color" - value={ - element.backgroundColor === "transparent" - ? lastSelectedColor.current - : element.backgroundColor || "#000000" - } - onChange={(e) => handleColorChange(e.target.value)} - className="w-full cursor-pointer rounded-full" - disabled={element.backgroundColor === "transparent"} - /> </PropertyItemValue> </PropertyItem> </div> diff --git a/apps/web/src/components/ui/color-picker.tsx b/apps/web/src/components/ui/color-picker.tsx new file mode 100644 index 00000000..6a309d5a --- /dev/null +++ b/apps/web/src/components/ui/color-picker.tsx @@ -0,0 +1,325 @@ +import * as React from "react"; +import { createPortal } from "react-dom"; +import { cn } from "../../lib/utils"; +import { Input } from "./input"; + +interface ColorPickerProps { + value?: string; + onChange?: (value: string) => void; + className?: string; + containerRef?: React.RefObject<HTMLDivElement>; +} + +const hexToHsv = (hex: string) => { + const r = parseInt(hex.slice(0, 2), 16) / 255; + const g = parseInt(hex.slice(2, 4), 16) / 255; + const b = parseInt(hex.slice(4, 6), 16) / 255; + + const max = Math.max(r, g, b); + const min = Math.min(r, g, b); + const diff = max - min; + + let h = 0; + let s = max === 0 ? 0 : diff / max; + let v = max; + + if (diff !== 0) { + switch (max) { + case r: + h = ((g - b) / diff) % 6; + break; + case g: + h = (b - r) / diff + 2; + break; + case b: + h = (r - g) / diff + 4; + break; + } + } + + return [h * 60, s, v]; +}; + +const hsvToHex = (h: number, s: number, v: number) => { + const c = v * s; + const x = c * (1 - Math.abs(((h / 60) % 2) - 1)); + const m = v - c; + + let r = 0, + g = 0, + b = 0; + + if (h >= 0 && h < 60) { + r = c; + g = x; + b = 0; + } else if (h >= 60 && h < 120) { + r = x; + g = c; + b = 0; + } else if (h >= 120 && h < 180) { + r = 0; + g = c; + b = x; + } else if (h >= 180 && h < 240) { + r = 0; + g = x; + b = c; + } else if (h >= 240 && h < 300) { + r = x; + g = 0; + b = c; + } else if (h >= 300 && h < 360) { + r = c; + g = 0; + b = x; + } + + r = Math.round((r + m) * 255); + g = Math.round((g + m) * 255); + b = Math.round((b + m) * 255); + + return [r, g, b].map((x) => x.toString(16).padStart(2, "0")).join(""); +}; + +const ColorPicker = React.forwardRef<HTMLDivElement, ColorPickerProps>( + ({ className, value = "FFFFFF", onChange, containerRef, ...props }, ref) => { + const [isOpen, setIsOpen] = React.useState(false); + const [isDragging, setIsDragging] = React.useState< + "saturation" | "hue" | null + >(null); + const [pickerPosition, setPickerPosition] = React.useState({ + right: 0, + bottom: 0, + }); + const pickerRef = React.useRef<HTMLDivElement>(null); + const saturationRef = React.useRef<HTMLDivElement>(null); + const hueRef = React.useRef<HTMLDivElement>(null); + const triggerRef = React.useRef<HTMLDivElement>(null); + + const [h, s, v] = hexToHsv(value); + + React.useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ( + pickerRef.current && + !pickerRef.current.contains(event.target as Node) + ) { + setIsOpen(false); + } + }; + + if (isOpen) { + document.addEventListener("mousedown", handleClickOutside); + return () => + document.removeEventListener("mousedown", handleClickOutside); + } + }, [isOpen]); + + React.useEffect(() => { + const handleMouseMove = (e: MouseEvent) => { + if (!isDragging) return; + + if (isDragging === "saturation" && saturationRef.current) { + const rect = saturationRef.current.getBoundingClientRect(); + const x = Math.max( + 0, + Math.min(1, (e.clientX - rect.left) / rect.width) + ); + const y = Math.max( + 0, + Math.min(1, (e.clientY - rect.top) / rect.height) + ); + + const newS = x; + const newV = 1 - y; + const newHex = hsvToHex(h, newS, newV); + onChange?.(newHex); + } + + if (isDragging === "hue" && hueRef.current) { + const rect = hueRef.current.getBoundingClientRect(); + const x = Math.max( + 0, + Math.min(1, (e.clientX - rect.left) / rect.width) + ); + const newH = x * 360; + const newHex = hsvToHex(newH, s, v); + onChange?.(newHex); + } + }; + + const handleMouseUp = () => { + setIsDragging(null); + }; + + if (isDragging) { + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); + return () => { + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + }; + } + }, [isDragging, h, s, v, onChange]); + + const handleSaturationMouseDown = (e: React.MouseEvent) => { + e.preventDefault(); + setIsDragging("saturation"); + + const rect = saturationRef.current!.getBoundingClientRect(); + const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); + const y = Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height)); + const newS = x; + const newV = 1 - y; + const newHex = hsvToHex(h, newS, newV); + onChange?.(newHex); + }; + + const handleHueMouseDown = (e: React.MouseEvent) => { + setIsDragging("hue"); + + const rect = hueRef.current!.getBoundingClientRect(); + const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); + const newH = x * 360; + const newHex = hsvToHex(newH, s, v); + onChange?.(newHex); + }; + + const [inputValue, setInputValue] = React.useState(value); + + React.useEffect(() => { + setInputValue(value); + }, [value]); + + const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => { + const hex = e.target.value.replace("#", ""); + setInputValue(hex); + }; + + const handleInputBlur = () => { + onChange?.(inputValue); + }; + + const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => { + if (e.key === "Enter") { + onChange?.(inputValue); + e.currentTarget.blur(); + } + }; + + const saturationStyle = { + background: `linear-gradient(to top, #000, transparent), linear-gradient(to right, #fff, hsl(${h}, 100%, 50%))`, + }; + + const hueStyle = { + background: + "linear-gradient(to right, #f00 0%, #ff0 17%, #0f0 33%, #0ff 50%, #00f 67%, #f0f 83%, #f00 100%)", + }; + + return ( + <div className="relative"> + <div + ref={ref} + className={cn( + "bg-panel-accent h-9 rounded-full flex items-center gap-2 px-[0.45rem]", + className + )} + {...props} + > + <div + ref={triggerRef} + className="size-6 rounded-full cursor-pointer hover:ring-2 hover:ring-white/20 transition-all" + style={{ backgroundColor: `#${value}` }} + onClick={() => { + if (!isOpen && triggerRef.current && containerRef?.current) { + const containerRect = + containerRef.current.getBoundingClientRect(); + + setPickerPosition({ + right: window.innerWidth - containerRect.left - 8, + bottom: window.innerHeight - containerRect.bottom, + }); + } + setIsOpen(!isOpen); + }} + /> + <div className="flex-1 flex items-center"> + <Input + className="bg-transparent p-0 !ring-0 !ring-offset-0 !border-0 " + containerClassName="w-full" + value={inputValue} + onChange={handleInputChange} + onBlur={handleInputBlur} + onKeyDown={handleInputKeyDown} + /> + </div> + </div> + + {isOpen && + createPortal( + <div + ref={pickerRef} + className="fixed z-50 p-4 bg-popover border border-border rounded-lg shadow-lg select-none" + style={{ + right: pickerPosition.right, + bottom: pickerPosition.bottom, + }} + > + <div + ref={saturationRef} + className="relative w-48 h-32 cursor-crosshair mb-3" + style={saturationStyle} + onMouseDown={handleSaturationMouseDown} + > + <ColorCircle + size="sm" + position={{ left: `${s * 100}%`, top: `${(1 - v) * 100}%` }} + color={`#${value}`} + /> + </div> + + <div + ref={hueRef} + className="relative w-48 h-4 rounded-lg cursor-pointer" + style={hueStyle} + onMouseDown={handleHueMouseDown} + > + <ColorCircle + size="md" + position={{ left: `${(h / 360) * 100}%`, top: "50%" }} + color={`#${value}`} + /> + </div> + </div>, + document.body + )} + </div> + ); + } +); +ColorPicker.displayName = "ColorPicker"; + +const ColorCircle = ({ + size, + position, + color, +}: { + size: "sm" | "md"; + position: { left: string; top: string }; + color: string; +}) => ( + <div + className={`absolute border-3 border-accent rounded-full shadow-lg pointer-events-none ${ + size === "sm" ? "w-3 h-3" : "w-4 h-4" + }`} + style={{ + left: position.left, + top: position.top, + transform: "translate(-50%, -50%)", + backgroundColor: color, + }} + /> +); + +export { ColorPicker }; diff --git a/apps/web/src/lib/utils.ts b/apps/web/src/lib/utils.ts index 8d2ee03a..42159b2e 100644 --- a/apps/web/src/lib/utils.ts +++ b/apps/web/src/lib/utils.ts @@ -7,6 +7,10 @@ export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } +export function uppercase(str: string) { + return str.toUpperCase(); +} + /** * Generates a UUID v4 string * Uses crypto.randomUUID() if available, otherwise falls back to a custom implementation diff --git a/bun.lock b/bun.lock index 95f7c874..659110a6 100644 --- a/bun.lock +++ b/bun.lock @@ -533,7 +533,7 @@ "@tailwindcss/typography": ["@tailwindcss/typography@0.5.16", "", { "dependencies": { "lodash.castarray": "^4.4.0", "lodash.isplainobject": "^4.0.6", "lodash.merge": "^4.6.2", "postcss-selector-parser": "6.0.10" }, "peerDependencies": { "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" } }, "sha512-0wDLwCVF5V3x3b1SGXPCDcdsbDHMBe+lkFzBRaHeLvNi+nrrnZ1lA18u+OTWO8iSWU2GxUOCvlXtDuqftc1oiA=="], - "@types/bun": ["@types/bun@1.2.19", "", { "dependencies": { "bun-types": "1.2.19" } }, "sha512-d9ZCmrH3CJ2uYKXQIUuZ/pUnTqIvLDS0SK7pFmbx8ma+ziH/FRMoAq5bYpRG7y+w1gl+HgyNZbtqgMq4W4e2Lg=="], + "@types/bun": ["@types/bun@1.2.21", "", { "dependencies": { "bun-types": "1.2.21" } }, "sha512-NiDnvEqmbfQ6dmZ3EeUO577s4P5bf4HCTXtI6trMc6f6RzirY5IrF3aIookuSpyslFzrnvv2lmEWv5HyC1X79A=="], "@types/chai": ["@types/chai@5.2.2", "", { "dependencies": { "@types/deep-eql": "*" } }, "sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg=="], @@ -1257,6 +1257,8 @@ "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "@types/bun/bun-types": ["bun-types@1.2.21", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-sa2Tj77Ijc/NTLS0/Odjq/qngmEPZfbfnOERi0KRUYhT9R8M4VBioWVmMWE5GrYbKMc+5lVybXygLdibHaqVqw=="], + "better-auth/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], "motion/framer-motion": ["framer-motion@12.23.6", "", { "dependencies": { "motion-dom": "^12.23.6", "motion-utils": "^12.23.6", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-dsJ389QImVE3lQvM8Mnk99/j8tiZDM/7706PCqvkQ8sSCnpmWxsgX+g0lj7r5OBVL0U36pIecCTBoIWcM2RuKw=="], @@ -1323,6 +1325,8 @@ "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-VyyPYFlOMNylG45GoAe0xDoLwWuowvf92F9kySqzYh8vmYm7D2u4iUJKa1tOUpS70Ku13ASrOkS4ScXFsTaCNQ=="], + "@types/bun/bun-types/@types/node": ["@types/node@24.2.1", "", { "dependencies": { "undici-types": "~7.10.0" } }, "sha512-DRh5K+ka5eJic8CjH7td8QpYEV6Zo10gfRkjHCO3weqZHWDtAaSTFtl4+VMqOJ4N5jcuhZ9/l+yy8rVgw7BQeQ=="], + "motion/framer-motion/motion-dom": ["motion-dom@12.23.6", "", { "dependencies": { "motion-utils": "^12.23.6" } }, "sha512-G2w6Nw7ZOVSzcQmsdLc0doMe64O/Sbuc2bVAbgMz6oP/6/pRStKRiVRV4bQfHp5AHYAKEGhEdVHTM+R3FDgi5w=="], "motion/framer-motion/motion-utils": ["motion-utils@12.23.6", "", {}, "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ=="], @@ -1351,6 +1355,8 @@ "opencut/next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="], + "@types/bun/bun-types/@types/node/undici-types": ["undici-types@7.10.0", "", {}, "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag=="], + "opencut/next/postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], } } -- cgit v1.3.1 From 06e3d0ca2aba3d804a7abe58e6a72f07dbb69806 Mon Sep 17 00:00:00 2001 From: Maze Winther <mazewinther@gmail.com> Date: Wed, 27 Aug 2025 17:45:13 +0200 Subject: fix: color picker --- apps/web/src/components/ui/color-picker.tsx | 55 +++++++++++++++++------------ 1 file changed, 33 insertions(+), 22 deletions(-) (limited to 'apps') diff --git a/apps/web/src/components/ui/color-picker.tsx b/apps/web/src/components/ui/color-picker.tsx index 6a309d5a..b94c1f98 100644 --- a/apps/web/src/components/ui/color-picker.tsx +++ b/apps/web/src/components/ui/color-picker.tsx @@ -37,7 +37,10 @@ const hexToHsv = (hex: string) => { } } - return [h * 60, s, v]; + h = (h * 60 + 360) % 360; + if (isNaN(h)) h = 0; + + return [h, s, v]; }; const hsvToHex = (h: number, s: number, v: number) => { @@ -92,12 +95,20 @@ const ColorPicker = React.forwardRef<HTMLDivElement, ColorPickerProps>( right: 0, bottom: 0, }); + const [internalHue, setInternalHue] = React.useState(0); + const [inputValue, setInputValue] = React.useState(value); + const pickerRef = React.useRef<HTMLDivElement>(null); const saturationRef = React.useRef<HTMLDivElement>(null); const hueRef = React.useRef<HTMLDivElement>(null); const triggerRef = React.useRef<HTMLDivElement>(null); const [h, s, v] = hexToHsv(value); + const displayHue = s > 0 ? h : internalHue; + + React.useEffect(() => { + setInputValue(value); + }, [value]); React.useEffect(() => { const handleClickOutside = (event: MouseEvent) => { @@ -130,10 +141,9 @@ const ColorPicker = React.forwardRef<HTMLDivElement, ColorPickerProps>( 0, Math.min(1, (e.clientY - rect.top) / rect.height) ); - const newS = x; const newV = 1 - y; - const newHex = hsvToHex(h, newS, newV); + const newHex = hsvToHex(displayHue, newS, newV); onChange?.(newHex); } @@ -144,8 +154,11 @@ const ColorPicker = React.forwardRef<HTMLDivElement, ColorPickerProps>( Math.min(1, (e.clientX - rect.left) / rect.width) ); const newH = x * 360; - const newHex = hsvToHex(newH, s, v); - onChange?.(newHex); + setInternalHue(newH); + if (s > 0) { + const newHex = hsvToHex(newH, s, v); + onChange?.(newHex); + } } }; @@ -161,37 +174,33 @@ const ColorPicker = React.forwardRef<HTMLDivElement, ColorPickerProps>( document.removeEventListener("mouseup", handleMouseUp); }; } - }, [isDragging, h, s, v, onChange]); + }, [isDragging, displayHue, s, v, onChange]); const handleSaturationMouseDown = (e: React.MouseEvent) => { e.preventDefault(); setIsDragging("saturation"); - const rect = saturationRef.current!.getBoundingClientRect(); const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); const y = Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height)); const newS = x; const newV = 1 - y; - const newHex = hsvToHex(h, newS, newV); + const newHex = hsvToHex(displayHue, newS, newV); onChange?.(newHex); }; const handleHueMouseDown = (e: React.MouseEvent) => { + e.preventDefault(); setIsDragging("hue"); - const rect = hueRef.current!.getBoundingClientRect(); const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); const newH = x * 360; - const newHex = hsvToHex(newH, s, v); - onChange?.(newHex); + setInternalHue(newH); + if (s > 0) { + const newHex = hsvToHex(newH, s, v); + onChange?.(newHex); + } }; - const [inputValue, setInputValue] = React.useState(value); - - React.useEffect(() => { - setInputValue(value); - }, [value]); - const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => { const hex = e.target.value.replace("#", ""); setInputValue(hex); @@ -209,7 +218,7 @@ const ColorPicker = React.forwardRef<HTMLDivElement, ColorPickerProps>( }; const saturationStyle = { - background: `linear-gradient(to top, #000, transparent), linear-gradient(to right, #fff, hsl(${h}, 100%, 50%))`, + background: `linear-gradient(to top, #000, transparent), linear-gradient(to right, #fff, hsl(${displayHue}, 100%, 50%))`, }; const hueStyle = { @@ -235,7 +244,6 @@ const ColorPicker = React.forwardRef<HTMLDivElement, ColorPickerProps>( if (!isOpen && triggerRef.current && containerRef?.current) { const containerRect = containerRef.current.getBoundingClientRect(); - setPickerPosition({ right: window.innerWidth - containerRect.left - 8, bottom: window.innerHeight - containerRect.bottom, @@ -246,7 +254,7 @@ const ColorPicker = React.forwardRef<HTMLDivElement, ColorPickerProps>( /> <div className="flex-1 flex items-center"> <Input - className="bg-transparent p-0 !ring-0 !ring-offset-0 !border-0 " + className="bg-transparent p-0 !ring-0 !ring-offset-0 !border-0" containerClassName="w-full" value={inputValue} onChange={handleInputChange} @@ -287,7 +295,10 @@ const ColorPicker = React.forwardRef<HTMLDivElement, ColorPickerProps>( > <ColorCircle size="md" - position={{ left: `${(h / 360) * 100}%`, top: "50%" }} + position={{ + left: `${(displayHue / 360) * 100}%`, + top: "50%", + }} color={`#${value}`} /> </div> @@ -310,7 +321,7 @@ const ColorCircle = ({ color: string; }) => ( <div - className={`absolute border-3 border-accent rounded-full shadow-lg pointer-events-none ${ + className={`absolute border-3 border-white rounded-full shadow-lg pointer-events-none ${ size === "sm" ? "w-3 h-3" : "w-4 h-4" }`} style={{ -- cgit v1.3.1 From d4e08bb7e3ecfe944f683a26f063e6d7a487c992 Mon Sep 17 00:00:00 2001 From: Maze Winther <mazewinther@gmail.com> Date: Wed, 27 Aug 2025 17:55:54 +0200 Subject: fix: deselect text before timeline selection --- apps/web/src/hooks/use-selection-box.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) (limited to 'apps') diff --git a/apps/web/src/hooks/use-selection-box.ts b/apps/web/src/hooks/use-selection-box.ts index 46e77fa9..87026500 100644 --- a/apps/web/src/hooks/use-selection-box.ts +++ b/apps/web/src/hooks/use-selection-box.ts @@ -190,6 +190,22 @@ export function useSelectionBox({ }; }, [selectionBox, selectElementsInBox]); + useEffect(() => { + if (!selectionBox?.isActive) return; + + const previousBodyUserSelect = document.body.style.userSelect; + const container = containerRef.current; + const previousContainerUserSelect = container?.style.userSelect ?? ""; + + document.body.style.userSelect = "none"; + if (container) container.style.userSelect = "none"; + + return () => { + document.body.style.userSelect = previousBodyUserSelect; + if (container) container.style.userSelect = previousContainerUserSelect; + }; + }, [selectionBox?.isActive, containerRef]); + return { selectionBox, handleMouseDown, -- cgit v1.3.1 From 70a9bfebb196d5c0bde35ce0a165b696bb9ae1d0 Mon Sep 17 00:00:00 2001 From: Maze Winther <mazewinther@gmail.com> Date: Thu, 28 Aug 2025 21:52:03 +0200 Subject: feat: add audio support for export --- apps/web/src/components/editor/export-button.tsx | 22 ++ apps/web/src/lib/export.ts | 443 +++++++++++++++-------- apps/web/src/types/export.ts | 35 +- 3 files changed, 336 insertions(+), 164 deletions(-) (limited to 'apps') diff --git a/apps/web/src/components/editor/export-button.tsx b/apps/web/src/components/editor/export-button.tsx index e24d20df..c9738e81 100644 --- a/apps/web/src/components/editor/export-button.tsx +++ b/apps/web/src/components/editor/export-button.tsx @@ -7,6 +7,7 @@ import { Button } from "../ui/button"; import { Label } from "../ui/label"; import { RadioGroup, RadioGroupItem } from "../ui/radio-group"; import { Progress } from "../ui/progress"; +import { Checkbox } from "../ui/checkbox"; import { cn } from "@/lib/utils"; import { exportProject, @@ -75,6 +76,9 @@ function ExportPopover({ const [quality, setQuality] = useState<ExportQuality>( DEFAULT_EXPORT_OPTIONS.quality ); + const [includeAudio, setIncludeAudio] = useState<boolean>( + DEFAULT_EXPORT_OPTIONS.includeAudio || true + ); const [isExporting, setIsExporting] = useState(false); const [progress, setProgress] = useState(0); const [exportResult, setExportResult] = useState<ExportResult | null>(null); @@ -90,6 +94,7 @@ function ExportPopover({ format, quality, fps: activeProject.fps, + includeAudio, onProgress: setProgress, onCancel: () => false, // TODO: Add cancel functionality }); @@ -197,6 +202,23 @@ function ExportPopover({ </div> </RadioGroup> </PropertyGroup> + + <PropertyGroup + title="Audio" + titleClassName="text-sm" + defaultExpanded={false} + > + <div className="flex items-center space-x-2"> + <Checkbox + id="include-audio" + checked={includeAudio} + onCheckedChange={(checked) => setIncludeAudio(!!checked)} + /> + <Label htmlFor="include-audio"> + Include audio in export + </Label> + </div> + </PropertyGroup> </div> <Button onClick={handleExport} className="w-full gap-2"> diff --git a/apps/web/src/lib/export.ts b/apps/web/src/lib/export.ts index 69bc8d1d..f25f5f6b 100644 --- a/apps/web/src/lib/export.ts +++ b/apps/web/src/lib/export.ts @@ -1,147 +1,296 @@ -import { - Output, - Mp4OutputFormat, - WebMOutputFormat, - BufferTarget, - CanvasSource, - QUALITY_LOW, - QUALITY_MEDIUM, - QUALITY_HIGH, - QUALITY_VERY_HIGH, -} from "mediabunny"; -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 { ExportOptions, ExportResult } from "@/types/export"; - -export const DEFAULT_EXPORT_OPTIONS: ExportOptions = { - format: "mp4", - quality: "high", -}; - -const qualityMap = { - low: QUALITY_LOW, - medium: QUALITY_MEDIUM, - high: QUALITY_HIGH, - very_high: QUALITY_VERY_HIGH, -}; - -export async function exportProject( - options: ExportOptions -): Promise<ExportResult> { - const { format, quality, fps, onProgress, onCancel } = options; - - try { - const timelineStore = useTimelineStore.getState(); - const mediaStore = useMediaStore.getState(); - const projectStore = useProjectStore.getState(); - - const { tracks, getTotalDuration } = timelineStore; - const { mediaFiles } = mediaStore; - const { activeProject } = projectStore; - - if (!activeProject) { - return { success: false, error: "No active project" }; - } - - const duration = getTotalDuration(); - if (duration === 0) { - return { success: false, error: "Project is empty" }; - } - - const exportFps = fps || activeProject.fps || DEFAULT_FPS; - const canvasSize = activeProject.canvasSize; - - const outputFormat = - format === "webm" ? new WebMOutputFormat() : new Mp4OutputFormat(); - - // BufferTarget for smaller files, StreamTarget for larger ones - // TODO: Implement StreamTarget - const output = new Output({ - format: outputFormat, - target: new BufferTarget(), - }); - - // Canvas for rendering - const canvas = document.createElement("canvas"); - canvas.width = canvasSize.width; - canvas.height = canvasSize.height; - const ctx = canvas.getContext("2d"); - - if (!ctx) { - return { success: false, error: "Failed to create canvas context" }; - } - - const videoSource = new CanvasSource(canvas, { - codec: format === "webm" ? "vp9" : "avc", // VP9 for WebM, H.264 for MP4 - bitrate: qualityMap[quality], - }); - - output.addVideoTrack(videoSource, { frameRate: exportFps }); - - // Start the output - await output.start(); - - const totalFrames = Math.ceil(duration * exportFps); - let cancelled = false; - - // Render each frame - for (let frameIndex = 0; frameIndex < totalFrames; frameIndex++) { - // Check for cancellation - if (onCancel?.()) { - cancelled = true; - break; - } - - const time = frameIndex / exportFps; - - await renderTimelineFrame({ - ctx, - time, - canvasWidth: canvas.width, - canvasHeight: canvas.height, - tracks, - mediaFiles, - backgroundColor: - activeProject.backgroundType === "blur" - ? "transparent" - : activeProject.backgroundColor || "#000000", - projectCanvasSize: canvasSize, - }); - - const frameDuration = 1 / exportFps; - await videoSource.add(time, frameDuration); - - onProgress?.(frameIndex / totalFrames); - } - - if (cancelled) { - await output.cancel(); - return { success: false, cancelled: true }; - } - videoSource.close(); - await output.finalize(); - onProgress?.(1); - - return { - success: true, - buffer: output.target.buffer || undefined, - }; - } catch (error) { - console.error("Export failed:", error); - return { - success: false, - error: error instanceof Error ? error.message : "Unknown export error", - }; - } -} - -export function getExportMimeType(format: "mp4" | "webm"): string { - return format === "webm" ? "video/webm" : "video/mp4"; -} - -export function getExportFileExtension(format: "mp4" | "webm"): string { - return `.${format}`; -} +import { + Output, + Mp4OutputFormat, + WebMOutputFormat, + BufferTarget, + CanvasSource, + AudioBufferSource, + QUALITY_LOW, + QUALITY_MEDIUM, + QUALITY_HIGH, + QUALITY_VERY_HIGH, +} from "mediabunny"; +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 { ExportOptions, ExportResult } from "@/types/export"; + +export const DEFAULT_EXPORT_OPTIONS: ExportOptions = { + format: "mp4", + quality: "high", + includeAudio: true, +}; + +const qualityMap = { + low: QUALITY_LOW, + medium: QUALITY_MEDIUM, + high: QUALITY_HIGH, + very_high: QUALITY_VERY_HIGH, +}; + +interface AudioElement { + buffer: AudioBuffer; + startTime: number; + duration: number; + trimStart: number; + trimEnd: number; + muted: boolean; +} + +async function createTimelineAudioBuffer( + tracks: any[], + mediaFiles: any[], + duration: number, + sampleRate: number = 44100 +): Promise<AudioBuffer | null> { + // Get Web Audio context + const audioContext = new (window.AudioContext || + (window as any).webkitAudioContext)(); + + // Collect all audio elements from timeline + const audioElements: AudioElement[] = []; + const mediaMap = new Map(mediaFiles.map((m) => [m.id, m])); + + for (const track of tracks) { + if (track.muted) continue; + + for (const element of track.elements) { + if (element.type !== "media") continue; + + const mediaItem = mediaMap.get(element.mediaId); + if (!mediaItem || mediaItem.type !== "audio") continue; + + const visibleDuration = + element.duration - element.trimStart - element.trimEnd; + if (visibleDuration <= 0) continue; + + try { + // Decode audio file + const arrayBuffer = await mediaItem.file.arrayBuffer(); + const audioBuffer = await audioContext.decodeAudioData( + arrayBuffer.slice(0) + ); + + audioElements.push({ + buffer: audioBuffer, + startTime: element.startTime, + duration: element.duration, + trimStart: element.trimStart, + trimEnd: element.trimEnd, + muted: element.muted || track.muted || false, + }); + } catch (error) { + console.warn(`Failed to decode audio file ${mediaItem.name}:`, error); + } + } + } + + if (audioElements.length === 0) { + return null; // No audio to mix + } + + // Create output buffer + const outputChannels = 2; // Stereo + const outputLength = Math.ceil(duration * sampleRate); + const outputBuffer = audioContext.createBuffer( + outputChannels, + outputLength, + sampleRate + ); + + // Mix all audio elements + for (const element of audioElements) { + if (element.muted) continue; + + const { + buffer, + startTime, + trimStart, + trimEnd, + duration: elementDuration, + } = element; + + // Calculate timing + const sourceStartSample = Math.floor(trimStart * buffer.sampleRate); + const sourceDuration = elementDuration - trimStart - trimEnd; + const sourceLengthSamples = Math.floor(sourceDuration * buffer.sampleRate); + const outputStartSample = Math.floor(startTime * sampleRate); + + // Resample if needed (simple approach) + const resampleRatio = sampleRate / buffer.sampleRate; + const resampledLength = Math.floor(sourceLengthSamples * resampleRatio); + + // Mix each channel + for (let channel = 0; channel < outputChannels; channel++) { + const outputData = outputBuffer.getChannelData(channel); + const sourceChannel = Math.min(channel, buffer.numberOfChannels - 1); + const sourceData = buffer.getChannelData(sourceChannel); + + for (let i = 0; i < resampledLength; i++) { + const outputIndex = outputStartSample + i; + if (outputIndex >= outputLength) break; + + // Simple resampling (could be improved with proper interpolation) + const sourceIndex = sourceStartSample + Math.floor(i / resampleRatio); + if (sourceIndex >= sourceData.length) break; + + outputData[outputIndex] += sourceData[sourceIndex]; + } + } + } + + return outputBuffer; +} + +export async function exportProject( + options: ExportOptions +): Promise<ExportResult> { + const { format, quality, fps, includeAudio, onProgress, onCancel } = options; + + try { + const timelineStore = useTimelineStore.getState(); + const mediaStore = useMediaStore.getState(); + const projectStore = useProjectStore.getState(); + + const { tracks, getTotalDuration } = timelineStore; + const { mediaFiles } = mediaStore; + const { activeProject } = projectStore; + + if (!activeProject) { + return { success: false, error: "No active project" }; + } + + const duration = getTotalDuration(); + if (duration === 0) { + return { success: false, error: "Project is empty" }; + } + + const exportFps = fps || activeProject.fps || DEFAULT_FPS; + const canvasSize = activeProject.canvasSize; + + const outputFormat = + format === "webm" ? new WebMOutputFormat() : new Mp4OutputFormat(); + + // BufferTarget for smaller files, StreamTarget for larger ones + // TODO: Implement StreamTarget + const output = new Output({ + format: outputFormat, + target: new BufferTarget(), + }); + + // Canvas for rendering + const canvas = document.createElement("canvas"); + canvas.width = canvasSize.width; + canvas.height = canvasSize.height; + const ctx = canvas.getContext("2d"); + + if (!ctx) { + return { success: false, error: "Failed to create canvas context" }; + } + + const videoSource = new CanvasSource(canvas, { + codec: format === "webm" ? "vp9" : "avc", // VP9 for WebM, H.264 for MP4 + bitrate: qualityMap[quality], + }); + + output.addVideoTrack(videoSource, { frameRate: exportFps }); + + // Add audio track if requested (but don't add data yet) + let audioSource: AudioBufferSource | null = null; + let audioBuffer: AudioBuffer | null = null; + + if (includeAudio) { + onProgress?.(0.05); // 5% for audio processing + + audioBuffer = await createTimelineAudioBuffer( + tracks, + mediaFiles, + duration + ); + + if (audioBuffer) { + audioSource = new AudioBufferSource({ + codec: format === "webm" ? "opus" : "aac", // Opus for WebM, AAC for MP4 + bitrate: qualityMap[quality], // Use same quality for audio + }); + + output.addAudioTrack(audioSource); + } + } + + // Start the output (after all tracks are added) + await output.start(); + + // Now add audio data after starting + if (audioSource && audioBuffer) { + await audioSource.add(audioBuffer); + audioSource.close(); + } + + const totalFrames = Math.ceil(duration * exportFps); + let cancelled = false; + + // Render each frame + for (let frameIndex = 0; frameIndex < totalFrames; frameIndex++) { + // Check for cancellation + if (onCancel?.()) { + cancelled = true; + break; + } + + const time = frameIndex / exportFps; + + await renderTimelineFrame({ + ctx, + time, + canvasWidth: canvas.width, + canvasHeight: canvas.height, + tracks, + mediaFiles, + backgroundColor: + activeProject.backgroundType === "blur" + ? "transparent" + : activeProject.backgroundColor || "#000000", + projectCanvasSize: canvasSize, + }); + + const frameDuration = 1 / exportFps; + await videoSource.add(time, frameDuration); + + // Adjust progress to account for audio processing (5% at start) + const videoProgress = includeAudio + ? 0.05 + (frameIndex / totalFrames) * 0.95 + : frameIndex / totalFrames; + onProgress?.(videoProgress); + } + + if (cancelled) { + await output.cancel(); + return { success: false, cancelled: true }; + } + videoSource.close(); + await output.finalize(); + onProgress?.(1); + + return { + success: true, + buffer: output.target.buffer || undefined, + }; + } catch (error) { + console.error("Export failed:", error); + return { + success: false, + error: error instanceof Error ? error.message : "Unknown export error", + }; + } +} + +export function getExportMimeType(format: "mp4" | "webm"): string { + return format === "webm" ? "video/webm" : "video/mp4"; +} + +export function getExportFileExtension(format: "mp4" | "webm"): string { + return `.${format}`; +} diff --git a/apps/web/src/types/export.ts b/apps/web/src/types/export.ts index 2fa10a50..093268ce 100644 --- a/apps/web/src/types/export.ts +++ b/apps/web/src/types/export.ts @@ -1,17 +1,18 @@ -export type ExportFormat = "mp4" | "webm"; -export type ExportQuality = "low" | "medium" | "high" | "very_high"; - -export interface ExportOptions { - format: ExportFormat; - quality: ExportQuality; - fps?: number; - onProgress?: (progress: number) => void; - onCancel?: () => boolean; -} - -export interface ExportResult { - success: boolean; - buffer?: ArrayBuffer; - error?: string; - cancelled?: boolean; -} +export type ExportFormat = "mp4" | "webm"; +export type ExportQuality = "low" | "medium" | "high" | "very_high"; + +export interface ExportOptions { + format: ExportFormat; + quality: ExportQuality; + fps?: number; + includeAudio?: boolean; + onProgress?: (progress: number) => void; + onCancel?: () => boolean; +} + +export interface ExportResult { + success: boolean; + buffer?: ArrayBuffer; + error?: string; + cancelled?: boolean; +} -- cgit v1.3.1 From a7d90f7eac90966920c750134d7de9b611b2c38f Mon Sep 17 00:00:00 2001 From: Maze Winther <mazewinther@gmail.com> Date: Thu, 28 Aug 2025 22:37:51 +0200 Subject: feat: copy/pasting for elements --- .../editor/timeline/timeline-element.tsx | 10 +++ apps/web/src/constants/actions.ts | 4 +- apps/web/src/hooks/use-editor-actions.ts | 17 ++++ apps/web/src/hooks/use-keyboard-shortcuts-help.ts | 8 ++ apps/web/src/stores/keybindings-store.ts | 4 +- apps/web/src/stores/timeline-store.ts | 100 +++++++++++++++++++-- 6 files changed, 136 insertions(+), 7 deletions(-) (limited to 'apps') diff --git a/apps/web/src/components/editor/timeline/timeline-element.tsx b/apps/web/src/components/editor/timeline/timeline-element.tsx index 300c4c7b..677b9c61 100644 --- a/apps/web/src/components/editor/timeline/timeline-element.tsx +++ b/apps/web/src/components/editor/timeline/timeline-element.tsx @@ -51,6 +51,7 @@ export function TimelineElement({ rippleEditingEnabled, toggleElementHidden, toggleElementMuted, + copySelected, } = useTimelineStore(); const { currentTime } = usePlaybackStore(); @@ -116,6 +117,11 @@ export function TimelineElement({ }); }; + const handleElementCopyContext = (e: React.MouseEvent) => { + e.stopPropagation(); + copySelected(); + }; + const handleElementDeleteContext = (e: React.MouseEvent) => { e.stopPropagation(); if (rippleEditingEnabled) { @@ -330,6 +336,10 @@ export function TimelineElement({ <Scissors className="h-4 w-4 mr-2" /> Split at playhead </ContextMenuItem> + <ContextMenuItem onClick={handleElementCopyContext}> + <Copy className="h-4 w-4 mr-2" /> + Copy element + </ContextMenuItem> <ContextMenuItem onClick={handleToggleElementContext}> {hasAudio ? ( isMuted ? ( diff --git a/apps/web/src/constants/actions.ts b/apps/web/src/constants/actions.ts index 4278ce7e..50e8ae5d 100644 --- a/apps/web/src/constants/actions.ts +++ b/apps/web/src/constants/actions.ts @@ -45,7 +45,9 @@ export type Action = | "duplicate-selected" // Duplicate selected element | "toggle-snapping" // Toggle snapping | "undo" // Undo last action - | "redo"; // Redo last undone action + | "redo" // Redo last undone action + | "copy-selected" // Copy selected elements to clipboard + | "paste-selected"; // Paste elements from clipboard at playhead /** * Defines the arguments, if present for a given type that is required to be passed on diff --git a/apps/web/src/hooks/use-editor-actions.ts b/apps/web/src/hooks/use-editor-actions.ts index 81500416..974f9db5 100644 --- a/apps/web/src/hooks/use-editor-actions.ts +++ b/apps/web/src/hooks/use-editor-actions.ts @@ -202,6 +202,23 @@ export function useEditorActions() { undefined ); + useActionHandler( + "copy-selected", + () => { + if (selectedElements.length === 0) return; + useTimelineStore.getState().copySelected(); + }, + undefined + ); + + useActionHandler( + "paste-selected", + () => { + useTimelineStore.getState().pasteAtTime(currentTime); + }, + undefined + ); + useActionHandler( "toggle-snapping", () => { diff --git a/apps/web/src/hooks/use-keyboard-shortcuts-help.ts b/apps/web/src/hooks/use-keyboard-shortcuts-help.ts index e2cde3b4..29e3c09b 100644 --- a/apps/web/src/hooks/use-keyboard-shortcuts-help.ts +++ b/apps/web/src/hooks/use-keyboard-shortcuts-help.ts @@ -63,6 +63,14 @@ const actionDescriptions: Record< "toggle-snapping": { description: "Toggle snapping", category: "Editing" }, undo: { description: "Undo", category: "History" }, redo: { description: "Redo", category: "History" }, + "copy-selected": { + description: "Copy selected elements", + category: "Editing", + }, + "paste-selected": { + description: "Paste elements at playhead", + category: "Editing", + }, }; // Convert key binding format to display format diff --git a/apps/web/src/stores/keybindings-store.ts b/apps/web/src/stores/keybindings-store.ts index 4c11cfde..7fa0da5e 100644 --- a/apps/web/src/stores/keybindings-store.ts +++ b/apps/web/src/stores/keybindings-store.ts @@ -23,6 +23,8 @@ export const defaultKeybindings: KeybindingConfig = { n: "toggle-snapping", "ctrl+a": "select-all", "ctrl+d": "duplicate-selected", + "ctrl+c": "copy-selected", + "ctrl+v": "paste-selected", "ctrl+z": "undo", "ctrl+shift+z": "redo", "ctrl+y": "redo", @@ -162,7 +164,7 @@ export const useKeybindingsStore = create<KeybindingsState>()( }), { name: "opencut-keybindings", - version: 1, + version: 2, } ) ); diff --git a/apps/web/src/stores/timeline-store.ts b/apps/web/src/stores/timeline-store.ts index 884db23e..f0bae00c 100644 --- a/apps/web/src/stores/timeline-store.ts +++ b/apps/web/src/stores/timeline-store.ts @@ -41,6 +41,11 @@ interface TimelineStore { history: TimelineTrack[][]; redoStack: TimelineTrack[][]; + // Clipboard buffer + clipboard: { + items: Array<{ trackType: TrackType; element: CreateTimelineElement }>; + } | null; + // Always returns properly ordered tracks with main track ensured tracks: TimelineTrack[]; @@ -176,6 +181,10 @@ interface TimelineStore { loadProjectTimeline: (projectId: string) => Promise<void>; saveProjectTimeline: (projectId: string) => Promise<void>; clearTimeline: () => void; + + // Clipboard actions + copySelected: () => void; + pasteAtTime: (time: number) => void; updateTextElement: ( trackId: string, elementId: string, @@ -253,6 +262,7 @@ export const useTimelineStore = create<TimelineStore>((set, get) => { redoStack: [], selectedElements: [], rippleEditingEnabled: false, + clipboard: null, // Snapping settings defaults snappingEnabled: true, @@ -493,10 +503,12 @@ export const useTimelineStore = create<TimelineStore>((set, get) => { const newElement: TimelineElement = { ...elementData, id: generateUUID(), - startTime: elementData.startTime || 0, - trimStart: 0, - trimEnd: 0, - ...(elementData.type === "media" ? { muted: false } : {}), + startTime: elementData.startTime, + trimStart: elementData.trimStart ?? 0, + trimEnd: elementData.trimEnd ?? 0, + ...(elementData.type === "media" + ? { muted: elementData.muted ?? false } + : {}), } as TimelineElement; if (isFirstElement && newElement.type === "media") { @@ -1347,7 +1359,12 @@ export const useTimelineStore = create<TimelineStore>((set, get) => { clearTimeline: () => { const defaultTracks = ensureMainTrack([]); updateTracks(defaultTracks); - set({ history: [], redoStack: [], selectedElements: [] }); + set({ + history: [], + redoStack: [], + selectedElements: [], + clipboard: null, + }); }, // Snapping actions @@ -1451,6 +1468,79 @@ export const useTimelineStore = create<TimelineStore>((set, get) => { }); return true; }, + + copySelected: () => { + const { selectedElements, _tracks } = get(); + if (selectedElements.length === 0) return; + + const items: Array<{ + trackType: TrackType; + element: CreateTimelineElement; + }> = []; + + for (const { trackId, elementId } of selectedElements) { + const track = _tracks.find((t) => t.id === trackId); + const element = track?.elements.find((e) => e.id === elementId); + if (!track || !element) continue; + + // Prepare a creation-friendly copy without id + const { id: _id, ...rest } = element as TimelineElement; + items.push({ + trackType: track.type, + element: rest as CreateTimelineElement, + }); + } + + set({ clipboard: { items } }); + }, + + pasteAtTime: (time) => { + const { clipboard } = get(); + if (!clipboard || clipboard.items.length === 0) return; + + // Determine reference start time offset based on earliest element in clipboard + const minStart = Math.min( + ...clipboard.items.map((x) => x.element.startTime) + ); + + get().pushHistory(); + + for (const item of clipboard.items) { + const targetTrackId = get().findOrCreateTrack(item.trackType); + const relativeOffset = item.element.startTime - minStart; + const startTime = Math.max(0, time + relativeOffset); + + // Ensure no overlap on target track + const duration = + item.element.duration - item.element.trimStart - item.element.trimEnd; + const hasOverlap = get().checkElementOverlap( + targetTrackId, + startTime, + duration + ); + if (hasOverlap) { + // If overlap, nudge forward slightly until free (simple resolve) + let candidate = startTime; + let safety = 0; + while ( + get().checkElementOverlap(targetTrackId, candidate, duration) && + safety < 1000 + ) { + candidate += 0.01; + safety += 1; + } + get().addElementToTrack(targetTrackId, { + ...item.element, + startTime: candidate, + }); + } else { + get().addElementToTrack(targetTrackId, { + ...item.element, + startTime, + }); + } + } + }, }; }); -- cgit v1.3.1