This commit is contained in:
Nino Chavez 2026-02-23 23:52:37 +00:00 committed by GitHub
commit 77d2b0b0cb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 511 additions and 141 deletions

View File

@ -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 (
<BasePage
title="About OpenCut"
description="A free, open-source video editor that respects your privacy and gets the job done."
>
<section className="flex flex-col gap-4">
<h2 className="text-2xl font-semibold">Our Mission</h2>
<p>
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.
</p>
<p>
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.
</p>
</section>
<section className="flex flex-col gap-4">
<h2 className="text-2xl font-semibold">Key Features</h2>
<ul className="list-disc space-y-2 pl-6">
<li>
<strong>Privacy-first:</strong> Basic editing happens entirely in
your browser - your files never leave your device
</li>
<li>
<strong>Multi-track timeline:</strong> Arrange video, audio, and
images across multiple tracks with precision
</li>
<li>
<strong>Real-time preview:</strong> See your edits instantly without
waiting for renders
</li>
<li>
<strong>Cross-platform:</strong> Works on any device with a modern
web browser
</li>
<li>
<strong>Open source:</strong> Fully transparent code you can audit,
modify, or self-host
</li>
<li>
<strong>Free forever:</strong> No subscriptions, no paywalls, no
hidden costs
</li>
</ul>
</section>
<section className="flex flex-col gap-4">
<h2 className="text-2xl font-semibold">Open Source</h2>
<p>
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.
</p>
<p>
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.
</p>
<div className="flex gap-4 pt-2">
<Button variant="outline" asChild>
<Link href={SOCIAL_LINKS.github} target="_blank" rel="noopener">
View on GitHub
<ArrowRight className="ml-1 size-4" />
</Link>
</Button>
</div>
</section>
<section className="flex flex-col gap-4">
<h2 className="text-2xl font-semibold">Community</h2>
<p>
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.
</p>
<div className="flex flex-wrap gap-4 pt-2">
<Button variant="outline" asChild>
<Link href={SOCIAL_LINKS.discord} target="_blank" rel="noopener">
Join Discord
</Link>
</Button>
<Button variant="outline" asChild>
<Link href={SOCIAL_LINKS.x} target="_blank" rel="noopener">
Follow on X
</Link>
</Button>
<Button variant="outline" asChild>
<Link href="/contributors">View Contributors</Link>
</Button>
</div>
</section>
<section className="flex flex-col gap-4">
<h2 className="text-2xl font-semibold">Get Started</h2>
<p>
Ready to start editing? OpenCut runs directly in your browser - no
downloads or installations required.
</p>
<div className="pt-2">
<Button variant="foreground" asChild>
<Link href="/projects">
Start Editing
<ArrowRight className="ml-1 size-4" />
</Link>
</Button>
</div>
</section>
</BasePage>
);
}

View File

@ -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";
@ -56,6 +57,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 +177,7 @@ function ProjectsHeader() {
<div className="flex items-center gap-3 md:gap-4">
<SearchBar className="hidden md:block" />
<NewProjectButton />
<ThemeToggle />
</div>
</div>
<SearchBar className="block md:hidden mb-4" />
@ -578,9 +581,23 @@ function ProjectItem({
</div>
<CardContent className="flex flex-col gap-2 px-0 pt-4">
<h3 className="group-hover:text-foreground/90 line-clamp-2 text-sm leading-snug font-medium">
{project.name}
</h3>
<div className="flex items-center gap-1.5">
<h3 className="group-hover:text-foreground/90 line-clamp-2 text-sm leading-snug font-medium">
{project.name}
</h3>
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
window.open(`/editor/${project.id}`, "_blank");
}}
className="opacity-0 group-hover:opacity-100 transition-opacity p-0.5 hover:bg-accent rounded shrink-0"
title="Open in new tab"
>
<HugeiconsIcon icon={LinkSquare02Icon} className="size-3.5 text-muted-foreground" />
</button>
</div>
<div className="text-muted-foreground flex items-center gap-1.5 text-sm">
<HugeiconsIcon icon={Calendar04Icon} className="size-4" />
<span>Created {formatDate({ date: project.createdAt })}</span>
@ -606,9 +623,23 @@ function ProjectItem({
)}
</div>
<h3 className="group-hover:text-foreground/90 text-sm font-medium truncate flex-1 min-w-0">
{project.name}
</h3>
<div className="flex items-center gap-1.5 flex-1 min-w-0">
<h3 className="group-hover:text-foreground/90 text-sm font-medium truncate">
{project.name}
</h3>
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
window.open(`/editor/${project.id}`, "_blank");
}}
className="opacity-0 group-hover:opacity-100 transition-opacity p-0.5 hover:bg-accent rounded shrink-0"
title="Open in new tab"
>
<HugeiconsIcon icon={LinkSquare02Icon} className="size-3.5 text-muted-foreground" />
</button>
</div>
<span className="text-muted-foreground text-sm shrink-0 hidden sm:block">
{durationLabel ?? "—"}

View File

@ -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<KeyboardShortcut | null>(null);
const [recordingState, setRecordingState] = useState<RecordingState | null>(
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 (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent className="flex max-h-[80vh] max-w-2xl flex-col p-0">
@ -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}
/>
))}
</div>
@ -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 (
<div className="flex items-center justify-between">
<div className="flex items-center justify-between py-1">
<div className="flex items-center gap-3">
{shortcut.icon && (
<div className="text-muted-foreground">{shortcut.icon}</div>
@ -178,11 +213,17 @@ function ShortcutItem({
<div className="flex items-center gap-1">
{key.split("+").map((keyPart: string, partIndex: number) => {
const keyId = `${shortcut.id}-${index}-${partIndex}`;
const rawKey = rawKeys[index];
return (
<EditableShortcutKey
key={keyId}
isRecording={isRecording}
onStartRecording={() => onStartRecording({ shortcut })}
isRecording={isRecording && recordingMode === "replace"}
onStartRecording={() => rawKey && onStartReplacing(rawKey)}
onRemove={
displayKeys.length > 1 && rawKey
? () => onRemoveKey(rawKey)
: undefined
}
>
{keyPart}
</EditableShortcutKey>
@ -194,6 +235,19 @@ function ShortcutItem({
)}
</div>
))}
<Button
variant="outline"
size="sm"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onStartAdding();
}}
title="Add another shortcut"
className={isRecording && recordingMode === "add" ? "ring-2 ring-primary" : ""}
>
<HugeiconsIcon icon={PlusSignIcon} className="size-3" />
</Button>
</div>
</div>
);
@ -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 (
<Button
variant="outline"
size="sm"
onClick={handleClick}
onContextMenu={handleRightClick}
className={isRecording ? "ring-2 ring-primary" : ""}
title={
isRecording ? "Press any key combination..." : "Click to edit shortcut"
isRecording
? "Press any key combination..."
: onRemove
? "Click to edit, right-click to remove"
: "Click to edit shortcut"
}
>
{children}

View File

@ -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),
});

View File

@ -539,20 +539,15 @@ function TrackToggleIcon({
onClick: () => void;
}) {
return (
<>
{isOff ? (
<HugeiconsIcon
icon={icons.off}
className="text-destructive size-4 cursor-pointer"
onClick={onClick}
/>
) : (
<HugeiconsIcon
icon={icons.on}
className="text-muted-foreground size-4 cursor-pointer"
onClick={onClick}
/>
)}
</>
<button
type="button"
onClick={onClick}
className="flex items-center justify-center"
>
<HugeiconsIcon
icon={isOff ? icons.off : icons.on}
className={`size-4 ${isOff ? "text-destructive" : "text-muted-foreground"}`}
/>
</button>
);
}

View File

@ -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" },
],
};

View File

@ -46,11 +46,11 @@ export function Header() {
</Link>
<nav className="hidden items-center gap-4 md:flex">
{links.map((link) => (
<Link key={link.href} href={link.href}>
<Button variant="text" className="p-0 text-sm">
<Button key={link.href} variant="text" className="p-0 text-sm" asChild>
<Link href={link.href}>
{link.label}
</Button>
</Link>
</Link>
</Button>
))}
</nav>
</div>
@ -67,18 +67,18 @@ export function Header() {
</Button>
</div>
<div className="hidden items-center gap-3 md:flex">
<Link href={SOCIAL_LINKS.github}>
<Button className="bg-background text-sm" variant="outline">
<Button className="bg-background text-sm" variant="outline" asChild>
<Link href={SOCIAL_LINKS.github}>
<HugeiconsIcon icon={GithubIcon} className="size-4" />
40k+
</Button>
</Link>
<Link href="/projects">
<Button variant="foreground" className="text-sm">
</Link>
</Button>
<Button variant="foreground" className="text-sm" asChild>
<Link href="/projects">
Projects
<ArrowRight className="size-4" />
</Button>
</Link>
</Link>
</Button>
<ThemeToggle />
</div>
</div>

View File

@ -28,17 +28,17 @@ export function Hero() {
</p>
<div className="mt-8 flex justify-center gap-8">
<Link href="/projects">
<Button
variant="foreground"
type="submit"
size="lg"
className="h-11 text-base"
>
<Button
variant="foreground"
size="lg"
className="h-11 text-base"
asChild
>
<Link href="/projects">
Try early beta
<ArrowRight className="ml-0.5" />
</Button>
</Link>
</Link>
</Button>
</div>
</div>
</div>

View File

@ -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",
() => {

View File

@ -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",

View File

@ -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));
}
}
}

View File

@ -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();
}
}

View File

@ -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();
}
}
@ -195,6 +199,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)

View File

@ -7,6 +7,7 @@ import {
} from "mediabunny";
interface VideoSinkData {
input: Input;
sink: CanvasSink;
iterator: AsyncGenerator<WrappedCanvas, void, unknown> | null;
currentFrame: WrappedCanvas | null;
@ -246,19 +247,21 @@ export class VideoCache {
mediaId: string;
file: File;
}): Promise<void> {
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);
}