Merge 5854906a87 into 4d77e3f2bb
This commit is contained in:
commit
77d2b0b0cb
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 ?? "—"}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
],
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
() => {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue