diff options
| author | Maze Winther <mazewinther@gmail.com> | 2025-08-27 00:52:39 +0200 |
|---|---|---|
| committer | Maze Winther <mazewinther@gmail.com> | 2025-08-27 00:52:39 +0200 |
| commit | adc8cb37fcec21afac848b1e5dd724394c344050 (patch) | |
| tree | 9157bc1973840b00a96912d42f6741352abedbc4 /apps/web | |
| parent | b459b46802a760a4a81ce91fdf24390e07375fb0 (diff) | |
| download | opencut-adc8cb37fcec21afac848b1e5dd724394c344050.tar.gz | |
another huge refactor
Diffstat (limited to 'apps/web')
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;
+}
|
