From acf689f97fd8a6b3dfde51c5b1c914af68f99e70 Mon Sep 17 00:00:00 2001 From: Abelino Chavez Date: Fri, 30 Jan 2026 20:31:25 -0600 Subject: [PATCH 01/10] fix: UI improvements and multiple keyboard shortcuts support - Add theme toggle to Projects page header (#689) - Fix nested button HTML validation errors in header and hero (#687) - Use asChild prop to render Links with button styles - Add ability to add multiple keyboard shortcuts per action (#696) - New "+" button to add additional shortcuts - Replace mode only removes the specific key being edited - Right-click to remove a shortcut when multiple exist Co-Authored-By: Claude Opus 4.5 --- apps/web/src/app/projects/page.tsx | 2 + .../editor/dialogs/shortcuts-dialog.tsx | 118 ++++++++++++++---- apps/web/src/components/header.tsx | 24 ++-- apps/web/src/components/landing/hero.tsx | 18 +-- 4 files changed, 117 insertions(+), 45 deletions(-) diff --git a/apps/web/src/app/projects/page.tsx b/apps/web/src/app/projects/page.tsx index b855cefc..ea1ef522 100644 --- a/apps/web/src/app/projects/page.tsx +++ b/apps/web/src/app/projects/page.tsx @@ -56,6 +56,7 @@ import { import { DeleteProjectDialog } from "@/components/editor/dialogs/delete-project-dialog"; import { ProjectInfoDialog } from "@/components/editor/dialogs/project-info-dialog"; import { RenameProjectDialog } from "@/components/editor/dialogs/rename-project-dialog"; +import { ThemeToggle } from "@/components/theme-toggle"; import { cn } from "@/utils/ui"; const formatProjectDuration = ({ @@ -175,6 +176,7 @@ function ProjectsHeader() {
+
diff --git a/apps/web/src/components/editor/dialogs/shortcuts-dialog.tsx b/apps/web/src/components/editor/dialogs/shortcuts-dialog.tsx index 565fe705..bb013a12 100644 --- a/apps/web/src/components/editor/dialogs/shortcuts-dialog.tsx +++ b/apps/web/src/components/editor/dialogs/shortcuts-dialog.tsx @@ -16,6 +16,15 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; +import { PlusSignIcon } from "@hugeicons/core-free-icons"; +import { HugeiconsIcon } from "@hugeicons/react"; +import type { ShortcutKey } from "@/types/keybinding"; + +interface RecordingState { + shortcut: KeyboardShortcut; + mode: "add" | "replace"; + keyToReplace?: ShortcutKey; +} export function ShortcutsDialog({ isOpen, @@ -24,15 +33,15 @@ export function ShortcutsDialog({ isOpen: boolean; onOpenChange: (open: boolean) => void; }) { - const [recordingShortcut, setRecordingShortcut] = - useState(null); + const [recordingState, setRecordingState] = useState( + null, + ); const { updateKeybinding, removeKeybinding, getKeybindingString, validateKeybinding, - getKeybindingsForAction, setIsRecording, resetToDefaults, isRecording, @@ -43,7 +52,7 @@ export function ShortcutsDialog({ const categories = Array.from(new Set(shortcuts.map((s) => s.category))); useEffect(() => { - if (!isRecording || !recordingShortcut) return; + if (!isRecording || !recordingState) return; const handleKeyDown = (e: KeyboardEvent) => { e.preventDefault(); @@ -53,30 +62,31 @@ export function ShortcutsDialog({ if (keyString) { const conflict = validateKeybinding( keyString, - recordingShortcut.action, + recordingState.shortcut.action, ); if (conflict) { toast.error( `Key "${keyString}" is already bound to "${conflict.existingAction}"`, ); - setRecordingShortcut(null); + setRecordingState(null); + setIsRecording(false); return; } - const oldKeys = getKeybindingsForAction(recordingShortcut.action); - for (const key of oldKeys) { - removeKeybinding(key); + // Only remove the specific key being replaced, not all keys + if (recordingState.mode === "replace" && recordingState.keyToReplace) { + removeKeybinding(recordingState.keyToReplace); } - updateKeybinding(keyString, recordingShortcut.action); + updateKeybinding(keyString, recordingState.shortcut.action); setIsRecording(false); - setRecordingShortcut(null); + setRecordingState(null); } }; const handleClickOutside = () => { - setRecordingShortcut(null); + setRecordingState(null); setIsRecording(false); }; @@ -88,21 +98,29 @@ export function ShortcutsDialog({ document.removeEventListener("click", handleClickOutside); }; }, [ - recordingShortcut, + recordingState, getKeybindingString, updateKeybinding, removeKeybinding, validateKeybinding, - getKeybindingsForAction, setIsRecording, isRecording, ]); - const handleStartRecording = (shortcut: KeyboardShortcut) => { - setRecordingShortcut(shortcut); + const handleStartReplacing = (shortcut: KeyboardShortcut, key: ShortcutKey) => { + setRecordingState({ shortcut, mode: "replace", keyToReplace: key }); setIsRecording(true); }; + const handleStartAdding = (shortcut: KeyboardShortcut) => { + setRecordingState({ shortcut, mode: "add" }); + setIsRecording(true); + }; + + const handleRemoveKey = (key: ShortcutKey) => { + removeKeybinding(key); + }; + return ( @@ -125,9 +143,14 @@ export function ShortcutsDialog({ key={shortcut.action} shortcut={shortcut} isRecording={ - shortcut.action === recordingShortcut?.action + shortcut.action === recordingState?.shortcut.action } - onStartRecording={() => handleStartRecording(shortcut)} + recordingMode={recordingState?.mode} + onStartReplacing={(key: ShortcutKey) => + handleStartReplacing(shortcut, key) + } + onStartAdding={() => handleStartAdding(shortcut)} + onRemoveKey={handleRemoveKey} /> ))} @@ -148,11 +171,17 @@ export function ShortcutsDialog({ function ShortcutItem({ shortcut, isRecording, - onStartRecording, + recordingMode, + onStartReplacing, + onStartAdding, + onRemoveKey, }: { shortcut: KeyboardShortcut; isRecording: boolean; - onStartRecording: (params: { shortcut: KeyboardShortcut }) => void; + recordingMode?: "add" | "replace"; + onStartReplacing: (key: ShortcutKey) => void; + onStartAdding: () => void; + onRemoveKey: (key: ShortcutKey) => void; }) { const displayKeys = shortcut.keys.filter((key: string) => { if ( @@ -164,8 +193,14 @@ function ShortcutItem({ return true; }); + // Get raw keys for remove functionality (before formatting) + const { keybindings } = useKeybindingsStore(); + const rawKeys = Object.entries(keybindings) + .filter(([, action]) => action === shortcut.action) + .map(([key]) => key as ShortcutKey); + return ( -
+
{shortcut.icon && (
{shortcut.icon}
@@ -178,11 +213,17 @@ function ShortcutItem({
{key.split("+").map((keyPart: string, partIndex: number) => { const keyId = `${shortcut.id}-${index}-${partIndex}`; + const rawKey = rawKeys[index]; return ( onStartRecording({ shortcut })} + isRecording={isRecording && recordingMode === "replace"} + onStartRecording={() => rawKey && onStartReplacing(rawKey)} + onRemove={ + displayKeys.length > 1 && rawKey + ? () => onRemoveKey(rawKey) + : undefined + } > {keyPart} @@ -194,6 +235,19 @@ function ShortcutItem({ )}
))} +
); @@ -203,10 +257,12 @@ function EditableShortcutKey({ children, isRecording, onStartRecording, + onRemove, }: { children: React.ReactNode; isRecording: boolean; onStartRecording: () => void; + onRemove?: () => void; }) { const handleClick = (e: React.MouseEvent) => { e.preventDefault(); @@ -214,13 +270,27 @@ function EditableShortcutKey({ onStartRecording(); }; + const handleRightClick = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (onRemove) { + onRemove(); + } + }; + return ( - + + ))}
@@ -67,18 +67,18 @@ export function Header() {
- - - - - + - + +
diff --git a/apps/web/src/components/landing/hero.tsx b/apps/web/src/components/landing/hero.tsx index 6f11ad7c..65a809be 100644 --- a/apps/web/src/components/landing/hero.tsx +++ b/apps/web/src/components/landing/hero.tsx @@ -28,17 +28,17 @@ export function Hero() {

- - - + +
From 7670144be4912842def9f974b3e3b146f12903c2 Mon Sep 17 00:00:00 2001 From: Abelino Chavez Date: Fri, 30 Jan 2026 20:35:43 -0600 Subject: [PATCH 02/10] feat: add "open in new tab" icon next to project names (#644) - Add LinkSquare02Icon to grid and list views on Projects page - Icon appears on hover and opens project editor in new tab - Prevents default link navigation to allow new tab opening Co-Authored-By: Claude Opus 4.5 --- apps/web/src/app/projects/page.tsx | 41 +++++++++++++++++++++++++----- 1 file changed, 35 insertions(+), 6 deletions(-) diff --git a/apps/web/src/app/projects/page.tsx b/apps/web/src/app/projects/page.tsx index ea1ef522..6a50da6b 100644 --- a/apps/web/src/app/projects/page.tsx +++ b/apps/web/src/app/projects/page.tsx @@ -43,6 +43,7 @@ import { Edit03Icon, ArrowDown02Icon, InformationCircleIcon, + LinkSquare02Icon, } from "@hugeicons/core-free-icons"; import { OcVideoIcon } from "@opencut/ui/icons"; import { Label } from "@/components/ui/label"; @@ -580,9 +581,23 @@ function ProjectItem({ -

- {project.name} -

+
+

+ {project.name} +

+ +
Created {formatDate({ date: project.createdAt })} @@ -608,9 +623,23 @@ function ProjectItem({ )}
-

- {project.name} -

+
+

+ {project.name} +

+ +
{durationLabel ?? "—"} From 6bad0d42cadb183c83f615f4787d1b96ba4114f7 Mon Sep 17 00:00:00 2001 From: Abelino Chavez Date: Fri, 30 Jan 2026 20:40:54 -0600 Subject: [PATCH 03/10] feat: add file size validation for media uploads (#653) Validates file sizes before processing media uploads: - Image limit: 50MB - Video limit: 500MB - Audio limit: 100MB Shows clear error messages with actual file size when exceeded. Valid files still process even when some files are rejected. Co-Authored-By: Claude Opus 4.5 --- .../editor/panels/assets/views/assets.tsx | 71 ++++++++++++++++++- 1 file changed, 70 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/editor/panels/assets/views/assets.tsx b/apps/web/src/components/editor/panels/assets/views/assets.tsx index 808961f1..55fabd8c 100644 --- a/apps/web/src/components/editor/panels/assets/views/assets.tsx +++ b/apps/web/src/components/editor/panels/assets/views/assets.tsx @@ -45,6 +45,45 @@ import { } from "@hugeicons/core-free-icons"; import { HugeiconsIcon, type IconSvgElement } from "@hugeicons/react"; +// File size limits in bytes +const FILE_SIZE_LIMITS = { + image: 50 * 1024 * 1024, // 50MB + video: 500 * 1024 * 1024, // 500MB + audio: 100 * 1024 * 1024, // 100MB +} as const; + +function formatFileSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + if (bytes < 1024 * 1024 * 1024) + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`; +} + +function getMediaTypeFromFile(file: File): "image" | "video" | "audio" | null { + if (file.type.startsWith("image/")) return "image"; + if (file.type.startsWith("video/")) return "video"; + if (file.type.startsWith("audio/")) return "audio"; + return null; +} + +function validateFileSize(file: File): { valid: boolean; error?: string } { + const mediaType = getMediaTypeFromFile(file); + if (!mediaType) { + return { valid: false, error: `Unsupported file type: ${file.type}` }; + } + + const limit = FILE_SIZE_LIMITS[mediaType]; + if (file.size > limit) { + return { + valid: false, + error: `${file.name} (${formatFileSize(file.size)}) exceeds the ${formatFileSize(limit)} limit for ${mediaType} files`, + }; + } + + return { valid: true }; +} + export function MediaView() { const editor = useEditor(); const mediaFiles = editor.media.getAssets(); @@ -71,11 +110,41 @@ export function MediaView() { return; } + // Validate file sizes before processing + const validFiles: File[] = []; + const errors: string[] = []; + + for (const file of Array.from(files)) { + const validation = validateFileSize(file); + if (validation.valid) { + validFiles.push(file); + } else if (validation.error) { + errors.push(validation.error); + } + } + + // Show errors for rejected files + for (const error of errors) { + toast.error(error); + } + + // If no valid files, stop here + if (validFiles.length === 0) { + return; + } + + // Create a new FileList-like object from valid files + const dataTransfer = new DataTransfer(); + for (const file of validFiles) { + dataTransfer.items.add(file); + } + const validFileList = dataTransfer.files; + setIsProcessing(true); setProgress(0); try { const processedAssets = await processMediaAssets({ - files, + files: validFileList, onProgress: (progress: { progress: number }) => setProgress(progress.progress), }); From 147f473fd3cb32fcc4057cec6f6b0d835173062f Mon Sep 17 00:00:00 2001 From: Abelino Chavez Date: Fri, 30 Jan 2026 20:42:51 -0600 Subject: [PATCH 04/10] feat: add Ctrl+X cut shortcut for timeline elements (#672) Adds a "cut-selected" action that copies selected elements to clipboard and then deletes them, enabling standard cut behavior. Note: Drag/drop elements to other tracks was already implemented. Co-Authored-By: Claude Opus 4.5 --- .../src/hooks/actions/use-editor-actions.ts | 27 +++++++++++++++++++ apps/web/src/lib/actions/definitions.ts | 5 ++++ 2 files changed, 32 insertions(+) diff --git a/apps/web/src/hooks/actions/use-editor-actions.ts b/apps/web/src/hooks/actions/use-editor-actions.ts index 1ce890cb..abf54dd8 100644 --- a/apps/web/src/hooks/actions/use-editor-actions.ts +++ b/apps/web/src/hooks/actions/use-editor-actions.ts @@ -274,6 +274,33 @@ export function useEditorActions() { undefined, ); + useActionHandler( + "cut-selected", + () => { + if (selectedElements.length === 0) return; + + // Copy elements to clipboard + const results = editor.timeline.getElementsWithTracks({ + elements: selectedElements, + }); + const items = results.map(({ track, element }) => { + const { ...elementWithoutId } = element; + return { + trackId: track.id, + trackType: track.type, + element: elementWithoutId, + }; + }); + setClipboard({ items }); + + // Delete the selected elements + editor.timeline.deleteElements({ + elements: selectedElements, + }); + }, + undefined, + ); + useActionHandler( "paste-copied", () => { diff --git a/apps/web/src/lib/actions/definitions.ts b/apps/web/src/lib/actions/definitions.ts index 83144a25..bdcb685d 100644 --- a/apps/web/src/lib/actions/definitions.ts +++ b/apps/web/src/lib/actions/definitions.ts @@ -95,6 +95,11 @@ export const ACTIONS = { category: "editing", defaultShortcuts: ["ctrl+c"], }, + "cut-selected": { + description: "Cut selected elements", + category: "editing", + defaultShortcuts: ["ctrl+x"], + }, "paste-copied": { description: "Paste elements at playhead", category: "editing", From de5293b02454c411f3a7513ce9f02b56aba425b0 Mon Sep 17 00:00:00 2001 From: Abelino Chavez Date: Fri, 30 Jan 2026 20:44:26 -0600 Subject: [PATCH 05/10] feat: add dedicated About page (#624) Creates a new /about page with: - Project mission and vision - Key features overview - Open source information - Community links - Call to action Updates footer to link to /about instead of GitHub README. Co-Authored-By: Claude Opus 4.5 --- apps/web/src/app/about/page.tsx | 133 +++++++++++++++++++++++++++++ apps/web/src/components/footer.tsx | 2 +- 2 files changed, 134 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/app/about/page.tsx diff --git a/apps/web/src/app/about/page.tsx b/apps/web/src/app/about/page.tsx new file mode 100644 index 00000000..48ec4860 --- /dev/null +++ b/apps/web/src/app/about/page.tsx @@ -0,0 +1,133 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { BasePage } from "@/app/base-page"; +import { Button } from "@/components/ui/button"; +import { SOCIAL_LINKS } from "@/constants/site-constants"; +import { ArrowRight } from "lucide-react"; + +export const metadata: Metadata = { + title: "About - OpenCut", + description: + "OpenCut is a free, open-source video editor built for privacy and simplicity. Edit videos directly in your browser without uploading to servers.", + openGraph: { + title: "About - OpenCut", + description: + "OpenCut is a free, open-source video editor built for privacy and simplicity. Edit videos directly in your browser without uploading to servers.", + type: "website", + }, +}; + +export default function AboutPage() { + return ( + +
+

Our Mission

+

+ OpenCut was created to provide a powerful video editing experience + that doesn't compromise on privacy. We believe that editing your + videos should be simple, fast, and free from data collection. +

+

+ Your videos stay on your device. No uploads to external servers for + basic editing. No tracking of your content. Just a clean, efficient + editor that works anywhere. +

+
+ +
+

Key Features

+
    +
  • + Privacy-first: Basic editing happens entirely in + your browser - your files never leave your device +
  • +
  • + Multi-track timeline: Arrange video, audio, and + images across multiple tracks with precision +
  • +
  • + Real-time preview: See your edits instantly without + waiting for renders +
  • +
  • + Cross-platform: Works on any device with a modern + web browser +
  • +
  • + Open source: Fully transparent code you can audit, + modify, or self-host +
  • +
  • + Free forever: No subscriptions, no paywalls, no + hidden costs +
  • +
+
+ +
+

Open Source

+

+ OpenCut is completely open source under the MIT license. This means + you can view, modify, and distribute the code freely. We believe in + transparency and community-driven development. +

+

+ Our source code is available on GitHub, where you can report issues, + suggest features, or contribute code. Every contribution helps make + OpenCut better for everyone. +

+
+ +
+
+ +
+

Community

+

+ Join our growing community of creators, developers, and video editing + enthusiasts. Whether you need help, want to share your creations, or + are interested in contributing, we'd love to have you. +

+
+ + + +
+
+ +
+

Get Started

+

+ Ready to start editing? OpenCut runs directly in your browser - no + downloads or installations required. +

+
+ +
+
+
+ ); +} diff --git a/apps/web/src/components/footer.tsx b/apps/web/src/components/footer.tsx index 7c78fc57..31a0a9c2 100644 --- a/apps/web/src/components/footer.tsx +++ b/apps/web/src/components/footer.tsx @@ -25,7 +25,7 @@ const links: CategoryLinks = { { label: "Contributors", href: "/contributors" }, { label: "Sponsors", href: "/sponsors" }, { label: "Branding", href: "/branding" }, - { label: "About", href: `${SOCIAL_LINKS.github}/blob/main/README.md` }, + { label: "About", href: "/about" }, ], }; From 80b229c3bf5525ec3e2768e922bb23c6c264e1fb Mon Sep 17 00:00:00 2001 From: Abelino Chavez Date: Fri, 30 Jan 2026 21:06:30 -0600 Subject: [PATCH 06/10] fix: properly dispose WebCodecs resources in video cache (#663) The VideoCache was creating Input objects but not storing them, so input.dispose() was never called. This caused WebCodecs VideoDecoder resources to leak, leading to browser crashes during video playback. Changes: - Store Input object in VideoSinkData - Call input.dispose() on initialization errors - Call input.dispose() when clearing video from cache - Clear frame references when disposing This follows the same pattern as AudioManager which properly manages Input disposal. Co-Authored-By: Claude Opus 4.5 --- apps/web/src/services/video-cache/service.ts | 22 +++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/apps/web/src/services/video-cache/service.ts b/apps/web/src/services/video-cache/service.ts index 36441981..cb23df8e 100644 --- a/apps/web/src/services/video-cache/service.ts +++ b/apps/web/src/services/video-cache/service.ts @@ -7,6 +7,7 @@ import { } from "mediabunny"; interface VideoSinkData { + input: Input; sink: CanvasSink; iterator: AsyncGenerator | null; currentFrame: WrappedCanvas | null; @@ -246,19 +247,21 @@ export class VideoCache { mediaId: string; file: File; }): Promise { - try { - const input = new Input({ - source: new BlobSource(file), - formats: ALL_FORMATS, - }); + const input = new Input({ + source: new BlobSource(file), + formats: ALL_FORMATS, + }); + try { const videoTrack = await input.getPrimaryVideoTrack(); if (!videoTrack) { + input.dispose(); throw new Error("No video track found"); } const canDecode = await videoTrack.canDecode(); if (!canDecode) { + input.dispose(); throw new Error("Video codec not supported for decoding"); } @@ -268,6 +271,7 @@ export class VideoCache { }); this.sinks.set(mediaId, { + input, sink, iterator: null, currentFrame: null, @@ -277,6 +281,7 @@ export class VideoCache { prefetchPromise: null, }); } catch (error) { + input.dispose(); console.error(`Failed to initialize video sink for ${mediaId}:`, error); throw error; } @@ -289,6 +294,13 @@ export class VideoCache { void sinkData.iterator.return(); } + // Clear frame references + sinkData.currentFrame = null; + sinkData.nextFrame = null; + + // Dispose the input to release WebCodecs resources + sinkData.input.dispose(); + this.sinks.delete(mediaId); } From 905238d424754e4fedff56875374e6840e8a9db1 Mon Sep 17 00:00:00 2001 From: Abelino Chavez Date: Fri, 30 Jan 2026 21:07:54 -0600 Subject: [PATCH 07/10] fix: improve audio mixing quality and prevent clipping (#677) - Use linear interpolation for resampling instead of nearest neighbor - Clamp output samples to [-1, 1] range to prevent distortion from overlapping audio clips exceeding valid amplitude range This should reduce audio artifacts in exported videos, especially when multiple audio sources overlap. Co-Authored-By: Claude Opus 4.5 --- apps/web/src/lib/media/audio.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/apps/web/src/lib/media/audio.ts b/apps/web/src/lib/media/audio.ts index 6f789301..c70e9ce8 100644 --- a/apps/web/src/lib/media/audio.ts +++ b/apps/web/src/lib/media/audio.ts @@ -549,10 +549,19 @@ function mixAudioChannels({ const outputIndex = outputStartSample + i; if (outputIndex >= outputLength) break; - const sourceIndex = sourceStartSample + Math.floor(i / resampleRatio); - if (sourceIndex >= sourceData.length) break; + // Use linear interpolation for better resampling quality + const sourcePosition = sourceStartSample + i / resampleRatio; + const sourceIndex = Math.floor(sourcePosition); + if (sourceIndex >= sourceData.length - 1) break; - outputData[outputIndex] += sourceData[sourceIndex]; + const fraction = sourcePosition - sourceIndex; + const sample1 = sourceData[sourceIndex]; + const sample2 = sourceData[sourceIndex + 1] ?? sample1; + const interpolatedSample = sample1 + fraction * (sample2 - sample1); + + // Add sample and clamp to prevent distortion from clipping + const newValue = outputData[outputIndex] + interpolatedSample; + outputData[outputIndex] = Math.max(-1, Math.min(1, newValue)); } } } From 71bdafac7554da75b4d9f736eacebc94430036b2 Mon Sep 17 00:00:00 2001 From: Abelino Chavez Date: Fri, 30 Jan 2026 21:13:23 -0600 Subject: [PATCH 08/10] fix: make track visibility toggle button clickable (#658) The eye icon in the track labels was not responding to clicks because the onClick handler was placed directly on HugeiconsIcon which doesn't forward click events to the underlying SVG. Fixed by wrapping the icon in a button element. Co-Authored-By: Claude Opus 4.5 --- .../editor/panels/timeline/index.tsx | 25 ++++++++----------- 1 file changed, 10 insertions(+), 15 deletions(-) diff --git a/apps/web/src/components/editor/panels/timeline/index.tsx b/apps/web/src/components/editor/panels/timeline/index.tsx index 18f768a9..6fd14533 100644 --- a/apps/web/src/components/editor/panels/timeline/index.tsx +++ b/apps/web/src/components/editor/panels/timeline/index.tsx @@ -539,20 +539,15 @@ function TrackToggleIcon({ onClick: () => void; }) { return ( - <> - {isOff ? ( - - ) : ( - - )} - + ); } From edd2138c6b32e345a1c341614af8ccfb8810b85d Mon Sep 17 00:00:00 2001 From: Abelino Chavez Date: Fri, 30 Jan 2026 21:15:40 -0600 Subject: [PATCH 09/10] fix: show user-friendly error for unsupported video codecs (#637) When a video file uses a codec not supported by WebCodecs (like H.265/HEVC from Clipchamp), show a helpful toast message suggesting to convert to H.264/MP4 format instead of failing silently. Co-Authored-By: Claude Opus 4.5 --- apps/web/src/lib/media/processing.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/apps/web/src/lib/media/processing.ts b/apps/web/src/lib/media/processing.ts index fbe29acd..6a6c7fd3 100644 --- a/apps/web/src/lib/media/processing.ts +++ b/apps/web/src/lib/media/processing.ts @@ -195,6 +195,13 @@ export async function processMediaAssets({ }); } catch (error) { console.warn("Video processing failed", error); + const errorMessage = + error instanceof Error ? error.message : String(error); + if (errorMessage.includes("codec not supported")) { + toast.error( + `${file.name}: Video codec not supported. Try converting to H.264/MP4 format.`, + ); + } } } else if (fileType === "audio") { // For audio, we don't set width/height/fps (they'll be undefined) From 5854906a8778d0a000713be20b1daab12b823cfe Mon Sep 17 00:00:00 2001 From: Abelino Chavez Date: Fri, 30 Jan 2026 21:18:06 -0600 Subject: [PATCH 10/10] fix: dispose Input objects to prevent memory leaks (#656) Multiple functions were creating mediabunny Input objects without disposing them, causing WebCodecs decoder resources to leak. This led to RAM consumption spiraling until crash when importing video clips. Fixed by adding try/finally blocks to ensure input.dispose() is called: - getVideoInfo(): leaked Input when getting video metadata - generateThumbnail(): leaked Input when generating thumbnails - decodeAndMixAudioSource(): leaked Input when mixing audio Co-Authored-By: Claude Opus 4.5 --- apps/web/src/lib/media/mediabunny.ts | 86 +++++++++++++++------------- apps/web/src/lib/media/processing.ts | 56 +++++++++--------- 2 files changed, 77 insertions(+), 65 deletions(-) diff --git a/apps/web/src/lib/media/mediabunny.ts b/apps/web/src/lib/media/mediabunny.ts index 11fe9837..2d4978f9 100644 --- a/apps/web/src/lib/media/mediabunny.ts +++ b/apps/web/src/lib/media/mediabunny.ts @@ -18,22 +18,26 @@ export async function getVideoInfo({ formats: ALL_FORMATS, }); - const duration = await input.computeDuration(); - const videoTrack = await input.getPrimaryVideoTrack(); + try { + const duration = await input.computeDuration(); + const videoTrack = await input.getPrimaryVideoTrack(); - if (!videoTrack) { - throw new Error("No video track found in the file"); + if (!videoTrack) { + throw new Error("No video track found in the file"); + } + + const packetStats = await videoTrack.computePacketStats(100); + const fps = packetStats.averagePacketRate; + + return { + duration, + width: videoTrack.displayWidth, + height: videoTrack.displayHeight, + fps, + }; + } finally { + input.dispose(); } - - const packetStats = await videoTrack.computePacketStats(100); - const fps = packetStats.averagePacketRate; - - return { - duration, - width: videoTrack.displayWidth, - height: videoTrack.displayHeight, - fps, - }; } const SAMPLE_RATE = 44100; @@ -134,40 +138,44 @@ async function decodeAndMixAudioSource({ formats: ALL_FORMATS, }); - const audioTrack = await input.getPrimaryAudioTrack(); - if (!audioTrack) return; + try { + const audioTrack = await input.getPrimaryAudioTrack(); + if (!audioTrack) return; - const sink = new AudioBufferSink(audioTrack); - const trimEnd = source.trimStart + source.duration; + const sink = new AudioBufferSink(audioTrack); + const trimEnd = source.trimStart + source.duration; - for await (const { buffer, timestamp } of sink.buffers( - source.trimStart, - trimEnd, - )) { - const relativeTime = timestamp - source.trimStart; - const outputStartSample = Math.floor( - (source.startTime + relativeTime) * SAMPLE_RATE, - ); + for await (const { buffer, timestamp } of sink.buffers( + source.trimStart, + trimEnd, + )) { + const relativeTime = timestamp - source.trimStart; + const outputStartSample = Math.floor( + (source.startTime + relativeTime) * SAMPLE_RATE, + ); - // resample if needed - const resampleRatio = SAMPLE_RATE / buffer.sampleRate; + // resample if needed + const resampleRatio = SAMPLE_RATE / buffer.sampleRate; - for (let ch = 0; ch < NUM_CHANNELS; ch++) { - const sourceChannel = Math.min(ch, buffer.numberOfChannels - 1); - const channelData = buffer.getChannelData(sourceChannel); - const outputChannel = mixBuffers[ch]; + for (let ch = 0; ch < NUM_CHANNELS; ch++) { + const sourceChannel = Math.min(ch, buffer.numberOfChannels - 1); + const channelData = buffer.getChannelData(sourceChannel); + const outputChannel = mixBuffers[ch]; - const resampledLength = Math.floor(channelData.length * resampleRatio); - for (let i = 0; i < resampledLength; i++) { - const outputIdx = outputStartSample + i; - if (outputIdx < 0 || outputIdx >= totalSamples) continue; + const resampledLength = Math.floor(channelData.length * resampleRatio); + for (let i = 0; i < resampledLength; i++) { + const outputIdx = outputStartSample + i; + if (outputIdx < 0 || outputIdx >= totalSamples) continue; - const sourceIdx = Math.floor(i / resampleRatio); - if (sourceIdx < channelData.length) { - outputChannel[outputIdx] += channelData[sourceIdx]; + const sourceIdx = Math.floor(i / resampleRatio); + if (sourceIdx < channelData.length) { + outputChannel[outputIdx] += channelData[sourceIdx]; + } } } } + } finally { + input.dispose(); } } diff --git a/apps/web/src/lib/media/processing.ts b/apps/web/src/lib/media/processing.ts index 6a6c7fd3..89c30b19 100644 --- a/apps/web/src/lib/media/processing.ts +++ b/apps/web/src/lib/media/processing.ts @@ -75,34 +75,38 @@ export async function generateThumbnail({ formats: ALL_FORMATS, }); - const videoTrack = await input.getPrimaryVideoTrack(); - if (!videoTrack) { - throw new Error("No video track found in the file"); - } - - 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"); - } - try { - return renderToThumbnailDataUrl({ - width: videoTrack.displayWidth, - height: videoTrack.displayHeight, - draw: ({ context, width, height }) => { - frame.draw(context, 0, 0, width, height); - }, - }); + const videoTrack = await input.getPrimaryVideoTrack(); + if (!videoTrack) { + throw new Error("No video track found in the file"); + } + + 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"); + } + + try { + return renderToThumbnailDataUrl({ + width: videoTrack.displayWidth, + height: videoTrack.displayHeight, + draw: ({ context, width, height }) => { + frame.draw(context, 0, 0, width, height); + }, + }); + } finally { + frame.close(); + } } finally { - frame.close(); + input.dispose(); } }