mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
another huge refactor
This commit is contained in:
@@ -6,17 +6,17 @@ import {
|
|||||||
ResizablePanelGroup,
|
ResizablePanelGroup,
|
||||||
ResizablePanel,
|
ResizablePanel,
|
||||||
ResizableHandle,
|
ResizableHandle,
|
||||||
} from "../../../components/ui/resizable";
|
} from "@/components/ui/resizable";
|
||||||
import { MediaPanel } from "../../../components/editor/media-panel";
|
import { MediaPanel } from "@/components/editor/media-panel";
|
||||||
import { PropertiesPanel } from "../../../components/editor/properties-panel";
|
import { PropertiesPanel } from "@/components/editor/properties-panel";
|
||||||
import { Timeline } from "../../../components/editor/timeline";
|
import { Timeline } from "@/components/editor/timeline";
|
||||||
import { PreviewPanel } from "../../../components/editor/preview-panel";
|
import { PreviewPanel } from "@/components/editor/preview-panel";
|
||||||
import { EditorHeader } from "@/components/editor-header";
|
import { EditorHeader } from "@/components/editor/editor-header";
|
||||||
import { usePanelStore } from "@/stores/panel-store";
|
import { usePanelStore } from "@/stores/panel-store";
|
||||||
import { useProjectStore } from "@/stores/project-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 { usePlaybackControls } from "@/hooks/use-playback-controls";
|
||||||
import { Onboarding } from "@/components/onboarding";
|
import { Onboarding } from "@/components/editor/onboarding";
|
||||||
|
|
||||||
export default function Editor() {
|
export default function Editor() {
|
||||||
const {
|
const {
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
+6
-6
@@ -1,10 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Button } from "./ui/button";
|
import { Button } from "../ui/button";
|
||||||
import { ChevronDown, ArrowLeft, SquarePen, Trash, Sun } from "lucide-react";
|
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 { useProjectStore } from "@/stores/project-store";
|
||||||
import { KeyboardShortcutsHelp } from "./keyboard-shortcuts-help";
|
import { KeyboardShortcutsHelp } from "../keyboard-shortcuts-help";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -12,10 +12,10 @@ import {
|
|||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "./ui/dropdown-menu";
|
} from "../ui/dropdown-menu";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { RenameProjectDialog } from "./rename-project-dialog";
|
import { RenameProjectDialog } from "../rename-project-dialog";
|
||||||
import { DeleteProjectDialog } from "./delete-project-dialog";
|
import { DeleteProjectDialog } from "../delete-project-dialog";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { FaDiscord } from "react-icons/fa6";
|
import { FaDiscord } from "react-icons/fa6";
|
||||||
import { useTheme } from "next-themes";
|
import { useTheme } from "next-themes";
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { PropertyGroup } from "../../properties-panel/property-item";
|
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 { Language, LanguageSelect } from "@/components/language-select";
|
||||||
import { useState, useRef, useEffect } from "react";
|
import { useState, useRef, useEffect } from "react";
|
||||||
import { extractTimelineAudio } from "@/lib/mediabunny-utils";
|
import { extractTimelineAudio } from "@/lib/mediabunny-utils";
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { BaseView } from "./base-view";
|
import { PanelBaseView as BaseView } from "@/components/editor/panel-base-view";
|
||||||
import {
|
import {
|
||||||
PropertyItem,
|
PropertyItem,
|
||||||
PropertyItemLabel,
|
PropertyItemLabel,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { DraggableMediaItem } from "@/components/ui/draggable-item";
|
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 { useTimelineStore } from "@/stores/timeline-store";
|
||||||
import { DEFAULT_TEXT_ELEMENT } from "@/constants/text-constants";
|
import { DEFAULT_TEXT_ELEMENT } from "@/constants/text-constants";
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Dialog, DialogContent, DialogTitle } from "./ui/dialog";
|
import { Dialog, DialogContent, DialogTitle } from "../ui/dialog";
|
||||||
import { Button } from "./ui/button";
|
import { Button } from "../ui/button";
|
||||||
import { ArrowRightIcon } from "lucide-react";
|
import { ArrowRightIcon } from "lucide-react";
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import ReactMarkdown from "react-markdown";
|
import ReactMarkdown from "react-markdown";
|
||||||
+24
-13
@@ -2,9 +2,11 @@ import { ScrollArea } from "@/components/ui/scroll-area";
|
|||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
|
||||||
interface BaseViewProps {
|
interface PanelBaseViewProps {
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
defaultTab?: string;
|
defaultTab?: string;
|
||||||
|
value?: string;
|
||||||
|
onValueChange?: (value: string) => void;
|
||||||
tabs?: {
|
tabs?: {
|
||||||
value: string;
|
value: string;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -28,29 +30,38 @@ function ViewContent({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BaseView({
|
export function PanelBaseView({
|
||||||
children,
|
children,
|
||||||
defaultTab,
|
defaultTab,
|
||||||
|
value,
|
||||||
|
onValueChange,
|
||||||
tabs,
|
tabs,
|
||||||
className = "",
|
className = "",
|
||||||
ref,
|
ref,
|
||||||
}: BaseViewProps) {
|
}: PanelBaseViewProps) {
|
||||||
return (
|
return (
|
||||||
<div className={`h-full flex flex-col ${className}`} ref={ref}>
|
<div className={`h-full flex flex-col ${className}`} ref={ref}>
|
||||||
{!tabs || tabs.length === 0 ? (
|
{!tabs || tabs.length === 0 ? (
|
||||||
<ViewContent className={className}>{children}</ViewContent>
|
<ViewContent className={className}>{children}</ViewContent>
|
||||||
) : (
|
) : (
|
||||||
<Tabs defaultValue={defaultTab} className="flex flex-col h-full">
|
<Tabs
|
||||||
<div className="px-3 pt-4 pb-0">
|
defaultValue={defaultTab}
|
||||||
<TabsList>
|
value={value}
|
||||||
{tabs.map((tab) => (
|
onValueChange={onValueChange}
|
||||||
<TabsTrigger key={tab.value} value={tab.value}>
|
className="flex flex-col h-full"
|
||||||
{tab.label}
|
>
|
||||||
</TabsTrigger>
|
<div className="sticky top-0 z-10 bg-panel">
|
||||||
))}
|
<div className="px-3 pt-4 pb-0">
|
||||||
</TabsList>
|
<TabsList>
|
||||||
|
{tabs.map((tab) => (
|
||||||
|
<TabsTrigger key={tab.value} value={tab.value}>
|
||||||
|
{tab.label}
|
||||||
|
</TabsTrigger>
|
||||||
|
))}
|
||||||
|
</TabsList>
|
||||||
|
</div>
|
||||||
|
<Separator className="mt-4" />
|
||||||
</div>
|
</div>
|
||||||
<Separator className="mt-4" />
|
|
||||||
{tabs.map((tab) => (
|
{tabs.map((tab) => (
|
||||||
<TabsContent
|
<TabsContent
|
||||||
key={tab.value}
|
key={tab.value}
|
||||||
+2
-2
@@ -1,13 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Button } from "./ui/button";
|
import { Button } from "../ui/button";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "./ui/dropdown-menu";
|
} from "../ui/dropdown-menu";
|
||||||
import { ChevronDown, RotateCcw, LayoutPanelTop } from "lucide-react";
|
import { ChevronDown, RotateCcw, LayoutPanelTop } from "lucide-react";
|
||||||
import { usePanelStore, type PanelPreset } from "@/stores/panel-store";
|
import { usePanelStore, type PanelPreset } from "@/stores/panel-store";
|
||||||
|
|
||||||
@@ -538,6 +538,7 @@ export function PreviewPanel() {
|
|||||||
activeProject?.backgroundType === "blur"
|
activeProject?.backgroundType === "blur"
|
||||||
? "transparent"
|
? "transparent"
|
||||||
: activeProject?.backgroundColor || "#000000",
|
: activeProject?.backgroundColor || "#000000",
|
||||||
|
projectCanvasSize: canvasSize,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Blit offscreen to visible canvas
|
// Blit offscreen to visible canvas
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ interface PropertyGroupProps {
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
defaultExpanded?: boolean;
|
defaultExpanded?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
titleClassName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PropertyGroup({
|
export function PropertyGroup({
|
||||||
@@ -64,6 +65,7 @@ export function PropertyGroup({
|
|||||||
children,
|
children,
|
||||||
defaultExpanded = true,
|
defaultExpanded = true,
|
||||||
className,
|
className,
|
||||||
|
titleClassName,
|
||||||
}: PropertyGroupProps) {
|
}: PropertyGroupProps) {
|
||||||
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
|
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
|
||||||
|
|
||||||
@@ -73,7 +75,7 @@ export function PropertyGroup({
|
|||||||
className="flex items-center gap-1.5 cursor-pointer"
|
className="flex items-center gap-1.5 cursor-pointer"
|
||||||
onClick={() => setIsExpanded(!isExpanded)}
|
onClick={() => setIsExpanded(!isExpanded)}
|
||||||
>
|
>
|
||||||
<PropertyItemLabel className="cursor-pointer">
|
<PropertyItemLabel className={cn("cursor-pointer", titleClassName)}>
|
||||||
{title}
|
{title}
|
||||||
</PropertyItemLabel>
|
</PropertyItemLabel>
|
||||||
<ChevronDown className={cn("size-3", !isExpanded && "-rotate-90")} />
|
<ChevronDown className={cn("size-3", !isExpanded && "-rotate-90")} />
|
||||||
|
|||||||
@@ -6,8 +6,14 @@ import { useTimelineStore } from "@/stores/timeline-store";
|
|||||||
import { Slider } from "@/components/ui/slider";
|
import { Slider } from "@/components/ui/slider";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Button } from "@/components/ui/button";
|
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 { useState, useRef } from "react";
|
||||||
|
import { PanelBaseView } from "@/components/editor/panel-base-view";
|
||||||
|
import {
|
||||||
|
TEXT_PROPERTIES_TABS,
|
||||||
|
isTextPropertiesTab,
|
||||||
|
useTextPropertiesStore,
|
||||||
|
} from "@/stores/text-properties-store";
|
||||||
import {
|
import {
|
||||||
PropertyItem,
|
PropertyItem,
|
||||||
PropertyItemLabel,
|
PropertyItemLabel,
|
||||||
@@ -22,6 +28,7 @@ export function TextProperties({
|
|||||||
trackId: string;
|
trackId: string;
|
||||||
}) {
|
}) {
|
||||||
const { updateTextElement } = useTimelineStore();
|
const { updateTextElement } = useTimelineStore();
|
||||||
|
const { activeTab, setActiveTab } = useTextPropertiesStore();
|
||||||
|
|
||||||
// Local state for input values to allow temporary empty/invalid states
|
// Local state for input values to allow temporary empty/invalid states
|
||||||
const [fontSizeInput, setFontSizeInput] = useState(
|
const [fontSizeInput, setFontSizeInput] = useState(
|
||||||
@@ -105,199 +112,232 @@ export function TextProperties({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 p-5">
|
<PanelBaseView
|
||||||
<Textarea
|
defaultTab="transform"
|
||||||
placeholder="Name"
|
value={activeTab}
|
||||||
defaultValue={element.content}
|
onValueChange={(v) => {
|
||||||
className="min-h-18 resize-none bg-background/50"
|
if (isTextPropertiesTab(v)) setActiveTab(v);
|
||||||
onChange={(e) =>
|
}}
|
||||||
updateTextElement(trackId, element.id, { content: e.target.value })
|
tabs={TEXT_PROPERTIES_TABS.map((t) => ({
|
||||||
}
|
value: t.value,
|
||||||
/>
|
label: t.label,
|
||||||
<PropertyItem direction="row">
|
content:
|
||||||
<PropertyItemLabel>Font</PropertyItemLabel>
|
t.value === "transform" ? (
|
||||||
<PropertyItemValue>
|
<div className="space-y-6"></div>
|
||||||
<FontPicker
|
) : (
|
||||||
defaultValue={element.fontFamily}
|
<div className="space-y-6">
|
||||||
onValueChange={(value: FontFamily) =>
|
<Textarea
|
||||||
updateTextElement(trackId, element.id, { fontFamily: value })
|
placeholder="Name"
|
||||||
}
|
defaultValue={element.content}
|
||||||
/>
|
className="min-h-18 resize-none bg-background/50"
|
||||||
</PropertyItemValue>
|
onChange={(e) =>
|
||||||
</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, {
|
updateTextElement(trackId, element.id, {
|
||||||
fontWeight:
|
content: e.target.value,
|
||||||
element.fontWeight === "bold" ? "normal" : "bold",
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
className="h-8 px-3 font-bold"
|
/>
|
||||||
>
|
<PropertyItem direction="row">
|
||||||
B
|
<PropertyItemLabel>Font</PropertyItemLabel>
|
||||||
</Button>
|
<PropertyItemValue>
|
||||||
<Button
|
<FontPicker
|
||||||
variant={element.fontStyle === "italic" ? "default" : "outline"}
|
defaultValue={element.fontFamily}
|
||||||
size="sm"
|
onValueChange={(value: FontFamily) =>
|
||||||
onClick={() =>
|
updateTextElement(trackId, element.id, {
|
||||||
updateTextElement(trackId, element.id, {
|
fontFamily: value,
|
||||||
fontStyle:
|
})
|
||||||
element.fontStyle === "italic" ? "normal" : "italic",
|
}
|
||||||
})
|
/>
|
||||||
}
|
</PropertyItemValue>
|
||||||
className="h-8 px-3 italic"
|
</PropertyItem>
|
||||||
>
|
<PropertyItem direction="column">
|
||||||
I
|
<PropertyItem direction="row">
|
||||||
</Button>
|
<PropertyItemLabel>Style</PropertyItemLabel>
|
||||||
<Button
|
<PropertyItemValue>
|
||||||
variant={
|
<div className="flex items-center gap-2">
|
||||||
element.textDecoration === "underline" ? "default" : "outline"
|
<Button
|
||||||
}
|
variant={
|
||||||
size="sm"
|
element.fontWeight === "bold" ? "default" : "outline"
|
||||||
onClick={() =>
|
}
|
||||||
updateTextElement(trackId, element.id, {
|
size="sm"
|
||||||
textDecoration:
|
onClick={() =>
|
||||||
element.textDecoration === "underline"
|
updateTextElement(trackId, element.id, {
|
||||||
? "none"
|
fontWeight:
|
||||||
: "underline",
|
element.fontWeight === "bold" ? "normal" : "bold",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
className="h-8 px-3 underline"
|
className="h-8 px-3 font-bold"
|
||||||
>
|
>
|
||||||
U
|
B
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant={
|
variant={
|
||||||
element.textDecoration === "line-through"
|
element.fontStyle === "italic" ? "default" : "outline"
|
||||||
? "default"
|
}
|
||||||
: "outline"
|
size="sm"
|
||||||
}
|
onClick={() =>
|
||||||
size="sm"
|
updateTextElement(trackId, element.id, {
|
||||||
onClick={() =>
|
fontStyle:
|
||||||
updateTextElement(trackId, element.id, {
|
element.fontStyle === "italic"
|
||||||
textDecoration:
|
? "normal"
|
||||||
element.textDecoration === "line-through"
|
: "italic",
|
||||||
? "none"
|
})
|
||||||
: "line-through",
|
}
|
||||||
})
|
className="h-8 px-3 italic"
|
||||||
}
|
>
|
||||||
className="h-8 px-3 line-through"
|
I
|
||||||
>
|
</Button>
|
||||||
S
|
<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">
|
||||||
|
<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>
|
||||||
|
<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>
|
</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
|
|
||||||
[appearance:textfield]
|
|
||||||
[&::-webkit-outer-spin-button]:appearance-none
|
|
||||||
[&::-webkit-inner-spin-button]:appearance-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</PropertyItemValue>
|
|
||||||
</PropertyItem>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -12,13 +12,13 @@ const Progress = React.forwardRef<
|
|||||||
<ProgressPrimitive.Root
|
<ProgressPrimitive.Root
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<ProgressPrimitive.Indicator
|
<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)}%)` }}
|
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||||
/>
|
/>
|
||||||
</ProgressPrimitive.Root>
|
</ProgressPrimitive.Root>
|
||||||
|
|||||||
@@ -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}`;
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ export interface RenderContext {
|
|||||||
tracks: TimelineTrack[];
|
tracks: TimelineTrack[];
|
||||||
mediaFiles: MediaFile[];
|
mediaFiles: MediaFile[];
|
||||||
backgroundColor?: string;
|
backgroundColor?: string;
|
||||||
|
projectCanvasSize?: { width: number; height: number };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function renderTimelineFrame({
|
export async function renderTimelineFrame({
|
||||||
@@ -20,6 +21,7 @@ export async function renderTimelineFrame({
|
|||||||
tracks,
|
tracks,
|
||||||
mediaFiles,
|
mediaFiles,
|
||||||
backgroundColor,
|
backgroundColor,
|
||||||
|
projectCanvasSize,
|
||||||
}: RenderContext): Promise<void> {
|
}: RenderContext): Promise<void> {
|
||||||
// Background
|
// Background
|
||||||
ctx.clearRect(0, 0, canvasWidth, canvasHeight);
|
ctx.clearRect(0, 0, canvasWidth, canvasHeight);
|
||||||
@@ -28,8 +30,10 @@ export async function renderTimelineFrame({
|
|||||||
ctx.fillRect(0, 0, canvasWidth, canvasHeight);
|
ctx.fillRect(0, 0, canvasWidth, canvasHeight);
|
||||||
}
|
}
|
||||||
|
|
||||||
const scaleX = 1;
|
const scaleX = projectCanvasSize ? canvasWidth / projectCanvasSize.width : 1;
|
||||||
const scaleY = 1;
|
const scaleY = projectCanvasSize
|
||||||
|
? canvasHeight / projectCanvasSize.height
|
||||||
|
: 1;
|
||||||
const idToMedia = new Map(mediaFiles.map((m) => [m.id, m] as const));
|
const idToMedia = new Map(mediaFiles.map((m) => [m.id, m] as const));
|
||||||
const active: Array<{
|
const active: Array<{
|
||||||
track: TimelineTrack;
|
track: TimelineTrack;
|
||||||
|
|||||||
@@ -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" }
|
||||||
|
)
|
||||||
|
);
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user