summaryrefslogtreecommitdiff
path: root/apps/web/src
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2025-08-27 00:52:39 +0200
committerMaze Winther <mazewinther@gmail.com>2025-08-27 00:52:39 +0200
commitadc8cb37fcec21afac848b1e5dd724394c344050 (patch)
tree9157bc1973840b00a96912d42f6741352abedbc4 /apps/web/src
parentb459b46802a760a4a81ce91fdf24390e07375fb0 (diff)
downloadopencut-adc8cb37fcec21afac848b1e5dd724394c344050.tar.gz
another huge refactor
Diffstat (limited to 'apps/web/src')
-rw-r--r--apps/web/src/app/editor/[project_id]/page.tsx16
-rw-r--r--apps/web/src/components/background-settings.tsx184
-rw-r--r--apps/web/src/components/editor/editor-header.tsx (renamed from apps/web/src/components/editor-header.tsx)12
-rw-r--r--apps/web/src/components/editor/export-button.tsx246
-rw-r--r--apps/web/src/components/editor/media-panel/views/captions.tsx2
-rw-r--r--apps/web/src/components/editor/media-panel/views/settings.tsx2
-rw-r--r--apps/web/src/components/editor/media-panel/views/text.tsx2
-rw-r--r--apps/web/src/components/editor/onboarding.tsx (renamed from apps/web/src/components/onboarding.tsx)4
-rw-r--r--apps/web/src/components/editor/panel-base-view.tsx (renamed from apps/web/src/components/editor/media-panel/views/base-view.tsx)37
-rw-r--r--apps/web/src/components/editor/panel-preset-selector.tsx (renamed from apps/web/src/components/panel-preset-selector.tsx)4
-rw-r--r--apps/web/src/components/editor/preview-panel.tsx1
-rw-r--r--apps/web/src/components/editor/properties-panel/property-item.tsx4
-rw-r--r--apps/web/src/components/editor/properties-panel/text-properties.tsx412
-rw-r--r--apps/web/src/components/export-button.tsx185
-rw-r--r--apps/web/src/components/providers/editor-provider.tsx (renamed from apps/web/src/components/editor-provider.tsx)0
-rw-r--r--apps/web/src/components/ui/progress.tsx4
-rw-r--r--apps/web/src/lib/export.ts147
-rw-r--r--apps/web/src/lib/timeline-renderer.ts8
-rw-r--r--apps/web/src/stores/text-properties-store.ts33
-rw-r--r--apps/web/src/types/export.ts17
20 files changed, 726 insertions, 594 deletions
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 (
- <Popover>
- <PopoverTrigger asChild>
- <Button
- variant="text"
- size="icon"
- className="size-4! border border-muted-foreground"
- >
- <BackgroundIcon className="size-3!" />
- </Button>
- </PopoverTrigger>
- <PopoverContent className="flex flex-col items-start w-[20rem] h-64 overflow-hidden p-0">
- <div className="flex items-center justify-between w-full gap-2 z-10 bg-popover p-3">
- <h2 className="text-sm">Background</h2>
- <div className="flex items-center gap-2 text-sm">
- {tabs.map((tab) => (
- <span
- key={tab.value}
- onClick={() => {
- // 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}
- </span>
- ))}
- </div>
- </div>
- {activeTab === "color" ? (
- <ColorView
- selectedColor={activeProject?.backgroundColor || "#000000"}
- onColorSelect={handleColorSelect}
- />
- ) : (
- <BlurView
- selectedBlur={activeProject?.blurIntensity || 8}
- onBlurSelect={handleBlurSelect}
- />
- )}
- </PopoverContent>
- </Popover>
- );
-}
-
-function ColorView({
- selectedColor,
- onColorSelect,
-}: {
- selectedColor: string;
- onColorSelect: (color: string) => void;
-}) {
- return (
- <div className="w-full h-full">
- <div className="absolute top-8 left-0 w-[calc(100%-1rem)] h-12 bg-linear-to-b from-popover to-transparent pointer-events-none" />
- <div className="grid grid-cols-4 gap-2 w-full h-full p-3 pt-0 overflow-auto">
- <div className="w-full aspect-square rounded-sm cursor-pointer border border-foreground/15 hover:border-primary flex items-center justify-center">
- <PipetteIcon className="size-4" />
- </div>
- {colors.map((color) => (
- <ColorItem
- key={color}
- color={color}
- isSelected={color === selectedColor}
- onClick={() => onColorSelect(color)}
- />
- ))}
- </div>
- </div>
- );
-}
-
-function ColorItem({
- color,
- isSelected,
- onClick,
-}: {
- color: string;
- isSelected: boolean;
- onClick: () => void;
-}) {
- return (
- <div
- className={cn(
- "w-full aspect-square rounded-sm cursor-pointer hover:border-2 hover:border-primary",
- isSelected && "border-2 border-primary"
- )}
- style={{ backgroundColor: color }}
- onClick={onClick}
- />
- );
-}
-
-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 (
- <div className="grid grid-cols-3 gap-2 w-full p-3 pt-0">
- {blurLevels.map((blur) => (
- <div
- key={blur.value}
- className={cn(
- "w-full aspect-square rounded-sm cursor-pointer hover:border-2 hover:border-primary relative overflow-hidden",
- selectedBlur === blur.value && "border-2 border-primary"
- )}
- onClick={() => onBlurSelect(blur.value)}
- >
- <Image
- src={blurImage}
- alt={`Blur preview ${blur.label}`}
- fill
- className="object-cover"
- style={{ filter: `blur(${blur.value}px)` }}
- />
- <div className="absolute bottom-1 left-1 right-1 text-center">
- <span className="text-xs text-white bg-black/50 px-1 rounded">
- {blur.label}
- </span>
- </div>
- </div>
- ))}
- </div>
- );
-}
diff --git a/apps/web/src/components/editor-header.tsx b/apps/web/src/components/editor/editor-header.tsx
index ee5dfc17..ad2a5b5a 100644
--- a/apps/web/src/components/editor-header.tsx
+++ b/apps/web/src/components/editor/editor-header.tsx
@@ -1,10 +1,10 @@
"use client";
-import { Button } from "./ui/button";
+import { Button } from "../ui/button";
import { ChevronDown, ArrowLeft, SquarePen, Trash, Sun } from "lucide-react";
-import { HeaderBase } from "./header-base";
+import { HeaderBase } from "../header-base";
import { useProjectStore } from "@/stores/project-store";
-import { KeyboardShortcutsHelp } from "./keyboard-shortcuts-help";
+import { KeyboardShortcutsHelp } from "../keyboard-shortcuts-help";
import { useState } from "react";
import {
DropdownMenu,
@@ -12,10 +12,10 @@ import {
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
-} from "./ui/dropdown-menu";
+} from "../ui/dropdown-menu";
import Link from "next/link";
-import { RenameProjectDialog } from "./rename-project-dialog";
-import { DeleteProjectDialog } from "./delete-project-dialog";
+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";
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 (
+ <Popover open={isExportPopoverOpen} onOpenChange={setIsExportPopoverOpen}>
+ <PopoverTrigger asChild>
+ <button
+ type="button"
+ className={cn(
+ "flex items-center gap-1.5 bg-[#38BDF8] text-white rounded-md px-[0.12rem] py-[0.12rem] transition-all duration-200",
+ hasProject
+ ? "cursor-pointer hover:brightness-95"
+ : "cursor-not-allowed opacity-50"
+ )}
+ onClick={hasProject ? handleExport : undefined}
+ disabled={!hasProject}
+ onKeyDown={(event) => {
+ if (hasProject && (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</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>
+ </PopoverTrigger>
+ {hasProject && <ExportPopover onOpenChange={setIsExportPopoverOpen} />}
+ </Popover>
+ );
+}
+
+function ExportPopover({
+ onOpenChange,
+}: {
+ onOpenChange: (open: boolean) => void;
+}) {
+ const { activeProject } = useProjectStore();
+ const [format, setFormat] = useState<ExportFormat>(
+ DEFAULT_EXPORT_OPTIONS.format
+ );
+ const [quality, setQuality] = useState<ExportQuality>(
+ DEFAULT_EXPORT_OPTIONS.quality
+ );
+ const [isExporting, setIsExporting] = useState(false);
+ const [progress, setProgress] = useState(0);
+ const [exportResult, setExportResult] = useState<ExportResult | null>(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 (
+ <PopoverContent className="w-80 mr-4 flex flex-col gap-3">
+ <>
+ <div className="flex items-center justify-between">
+ <h3 className=" font-medium">
+ {isExporting ? "Exporting project" : "Export project"}
+ </h3>
+ <Button variant="text" size="icon" onClick={handleClose}>
+ <X className="!size-5 text-foreground/85" />
+ </Button>
+ </div>
+
+ <div className="flex flex-col gap-4">
+ {!isExporting && (
+ <>
+ <div className="flex flex-col gap-3">
+ <PropertyGroup
+ title="Format"
+ titleClassName="text-sm"
+ defaultExpanded={false}
+ >
+ <RadioGroup
+ value={format}
+ onValueChange={(value) => setFormat(value as ExportFormat)}
+ >
+ <div className="flex items-center space-x-2">
+ <RadioGroupItem value="mp4" id="mp4" />
+ <Label htmlFor="mp4">
+ MP4 (H.264) - Better compatibility
+ </Label>
+ </div>
+ <div className="flex items-center space-x-2">
+ <RadioGroupItem value="webm" id="webm" />
+ <Label htmlFor="webm">
+ WebM (VP9) - Smaller file size
+ </Label>
+ </div>
+ </RadioGroup>
+ </PropertyGroup>
+
+ <PropertyGroup
+ title="Quality"
+ titleClassName="text-sm"
+ defaultExpanded={false}
+ >
+ <RadioGroup
+ value={quality}
+ onValueChange={(value) =>
+ setQuality(value as ExportQuality)
+ }
+ >
+ <div className="flex items-center space-x-2">
+ <RadioGroupItem value="low" id="low" />
+ <Label htmlFor="low">Low - Smallest file size</Label>
+ </div>
+ <div className="flex items-center space-x-2">
+ <RadioGroupItem value="medium" id="medium" />
+ <Label htmlFor="medium">Medium - Balanced</Label>
+ </div>
+ <div className="flex items-center space-x-2">
+ <RadioGroupItem value="high" id="high" />
+ <Label htmlFor="high">High - Recommended</Label>
+ </div>
+ <div className="flex items-center space-x-2">
+ <RadioGroupItem value="very_high" id="very_high" />
+ <Label htmlFor="very_high">
+ Very High - Largest file size
+ </Label>
+ </div>
+ </RadioGroup>
+ </PropertyGroup>
+ </div>
+
+ <Button onClick={handleExport} className="w-full gap-2">
+ <Download className="w-4 h-4" />
+ Export
+ </Button>
+ </>
+ )}
+
+ {isExporting && (
+ <div className="space-y-4">
+ <div className="flex flex-col">
+ <div className="text-center flex items-center justify-between">
+ <p className="text-sm text-muted-foreground mb-2">
+ {Math.round(progress * 100)}%
+ </p>
+ <p className="text-sm text-muted-foreground mb-2">100%</p>
+ </div>
+ <Progress value={progress * 100} className="w-full" />
+ </div>
+
+ <Button
+ variant="outline"
+ className="rounded-md w-full"
+ onClick={() => {}}
+ >
+ Cancel
+ </Button>
+ </div>
+ )}
+
+ {exportResult && !exportResult.success && (
+ <div className="text-center space-y-3">
+ <div className="text-red-600 font-medium">Export failed</div>
+ <p className="text-sm text-muted-foreground">
+ {exportResult.error || "Unknown error occurred"}
+ </p>
+ <Button variant="outline" onClick={() => setExportResult(null)}>
+ Try Again
+ </Button>
+ </div>
+ )}
+ </div>
+ </>
+ </PopoverContent>
+ );
+}
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/onboarding.tsx b/apps/web/src/components/editor/onboarding.tsx
index ac4b0f04..8ee60690 100644
--- a/apps/web/src/components/onboarding.tsx
+++ b/apps/web/src/components/editor/onboarding.tsx
@@ -1,7 +1,7 @@
"use client";
-import { Dialog, DialogContent, DialogTitle } from "./ui/dialog";
-import { Button } from "./ui/button";
+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";
diff --git a/apps/web/src/components/editor/media-panel/views/base-view.tsx b/apps/web/src/components/editor/panel-base-view.tsx
index 2cd33535..0cfb8f40 100644
--- a/apps/web/src/components/editor/media-panel/views/base-view.tsx
+++ b/apps/web/src/components/editor/panel-base-view.tsx
@@ -2,9 +2,11 @@ import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
-interface BaseViewProps {
+interface PanelBaseViewProps {
children?: React.ReactNode;
defaultTab?: string;
+ value?: string;
+ onValueChange?: (value: string) => void;
tabs?: {
value: string;
label: string;
@@ -28,29 +30,38 @@ function ViewContent({
);
}
-export function BaseView({
+export function PanelBaseView({
children,
defaultTab,
+ value,
+ onValueChange,
tabs,
className = "",
ref,
-}: BaseViewProps) {
+}: PanelBaseViewProps) {
return (
<div className={`h-full flex flex-col ${className}`} ref={ref}>
{!tabs || tabs.length === 0 ? (
<ViewContent className={className}>{children}</ViewContent>
) : (
- <Tabs defaultValue={defaultTab} className="flex flex-col h-full">
- <div className="px-3 pt-4 pb-0">
- <TabsList>
- {tabs.map((tab) => (
- <TabsTrigger key={tab.value} value={tab.value}>
- {tab.label}
- </TabsTrigger>
- ))}
- </TabsList>
+ <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>
- <Separator className="mt-4" />
{tabs.map((tab) => (
<TabsContent
key={tab.value}
diff --git a/apps/web/src/components/panel-preset-selector.tsx b/apps/web/src/components/editor/panel-preset-selector.tsx
index c5351f76..e6ae97b3 100644
--- a/apps/web/src/components/panel-preset-selector.tsx
+++ b/apps/web/src/components/editor/panel-preset-selector.tsx
@@ -1,13 +1,13 @@
"use client";
-import { Button } from "./ui/button";
+import { Button } from "../ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
-} from "./ui/dropdown-menu";
+} from "../ui/dropdown-menu";
import { ChevronDown, RotateCcw, LayoutPanelTop } from "lucide-react";
import { usePanelStore, type PanelPreset } from "@/stores/panel-store";
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/editor-provider.tsx b/apps/web/src/components/providers/editor-provider.tsx
index b8e9ae1d..b8e9ae1d 100644
--- a/apps/web/src/components/editor-provider.tsx
+++ b/apps/web/src/components/providers/editor-provider.tsx
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;
+}