shipping this slop

This commit is contained in:
Maze Winther 2026-01-20 22:14:49 +01:00
parent 7117f17ece
commit 6e4069b42f
97 changed files with 3357 additions and 1858 deletions

View File

@ -23,6 +23,7 @@ captions-constants.ts
editor-constants.ts
export const PLATFORM_LAYOUTS: Record<TPlatformLayout, string>
export const PANEL_CONFIG
export-constants.ts
export const DEFAULT_EXPORT_OPTIONS: ExportOptions
@ -84,7 +85,7 @@ text-constants.ts
timeline-constants.tsx
export const TRACK_COLORS: Record<
TrackType,
{ solid: string; background: string; border: string }
{ background: string; }
>
export const TRACK_HEIGHTS: Record<TrackType, number>
export const TRACK_GAP
@ -292,6 +293,7 @@ use-element-interaction.ts
timelineRef,
tracksContainerRef,
tracksScrollRef,
snappingEnabled,
onSnapPointChange,
}: UseElementInteractionProps)
@ -358,23 +360,24 @@ blog-query.ts
export function processHtmlContent({ html }: { html: string }): Promise<string>
drag-data.ts
export function setDragData({
dataTransfer,
dragData,
}: {
dataTransfer: DataTransfer;
dragData: TimelineDragData;
export function setDragData({
dataTransfer,
dragData,
}: {
dataTransfer: DataTransfer;
dragData: TimelineDragData;
}): void
export function getDragData({
dataTransfer,
}: {
dataTransfer: DataTransfer;
export function getDragData({
dataTransfer,
}: {
dataTransfer: DataTransfer;
}): TimelineDragData | null
export function hasDragData({
dataTransfer,
}: {
dataTransfer: DataTransfer;
export function hasDragData({
dataTransfer,
}: {
dataTransfer: DataTransfer;
}): boolean
export function clearDragData(): void
editor-utils.ts
export function dimensionToAspectRatio({
@ -636,6 +639,7 @@ utils.ts
export function cn(...inputs: ClassValue[]): string
export function formatDate({ date }: { date: Date }): string
export function capitalizeFirstLetter({ string }: { string: string })
export function uppercase({ string }: { string: string })
export function generateUUID(): string
export function isTypableDOMElement({
element,
@ -893,6 +897,7 @@ drop-utils.ts
elementDuration,
pixelsPerSecond,
zoomLevel,
verticalDragDirection,
startTimeOverride,
excludeElementId,
}: ComputeDropTargetParams): DropTarget
@ -965,7 +970,7 @@ element-utils.ts
name: string;
duration: number;
startTime: number;
buffer: AudioBuffer;
buffer?: AudioBuffer;
}): CreateUploadAudioElement
export function buildLibraryAudioElement({
sourceUrl,
@ -978,7 +983,7 @@ element-utils.ts
name: string;
duration: number;
startTime: number;
buffer: AudioBuffer;
buffer?: AudioBuffer;
}): CreateLibraryAudioElement
index.ts
@ -1019,6 +1024,13 @@ track-utils.ts
type: TrackType;
name?: string;
}): TimelineTrack
export function getDefaultInsertIndexForTrack({
tracks,
trackType,
}: {
tracks: TimelineTrack[];
trackType: TrackType;
}): number
export function isMainTrack(track: TimelineTrack)
export function getMainTrack({
tracks,
@ -1126,11 +1138,14 @@ keybindings-store.ts
export const useKeybindingsStore
panel-store.ts
export type PanelPreset = | "default"
| "media"
| "inspector"
| "vertical-preview"
export const PRESET_CONFIGS: Record<PanelPreset, PanelSizes>
export interface PanelSizes {
tools: number
preview: number
properties: number
mainContent: number
timeline: number
}
export type PanelId = keyof PanelSizes
export const usePanelStore
sounds-store.ts
@ -1140,7 +1155,7 @@ stickers-store.ts
export const useStickersStore
text-properties-store.ts
export type TextPropertiesTab = "transform" | "style"
export type TextPropertiesTab = "text" | "transform"
export interface TextPropertiesTabMeta {
value: TextPropertiesTab
label: string
@ -1284,12 +1299,13 @@ keybinding.ts
project.ts
export type TBackground = | {
type: "color";
color: string;
}
type: "color";
color: string;
}
| {
type: "blur";
blurIntensity...
type: "blur";
blurIntensity: number;
}
export interface TCanvasSize {
width: number
height: number
@ -1304,6 +1320,7 @@ project.ts
export interface TProjectSettings {
fps: number
canvasSize: TCanvasSize
originalCanvasSize?: TCanvasSize | null
background: TBackground
}
export interface TProject {
@ -1494,6 +1511,7 @@ timeline.ts
elementDuration: number
pixelsPerSecond: number
zoomLevel: number
verticalDragDirection?: "up" | "down" | null
startTimeOverride?: number
excludeElementId?: string
}

View File

@ -8,10 +8,10 @@
"editor.formatOnPaste": true,
"emmet.showExpandedAbbreviation": "never",
"[typescriptreact]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
"editor.defaultFormatter": "vscode.typescript-language-features"
},
"[typescript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
"editor.defaultFormatter": "vscode.typescript-language-features"
}
}

View File

@ -11,10 +11,10 @@ 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 { EditorProvider } from "@/components/providers/editor-provider";
import { Onboarding } from "@/components/editor/onboarding";
import { MigrationDialog } from "@/components/editor/migration-dialog";
import { usePanelStore } from "@/stores/panel-store";
export default function Editor() {
const params = useParams();
@ -34,271 +34,37 @@ export default function Editor() {
);
}
function EditorLayout({}: {}) {
const {
activePreset,
resetCounter,
toolsPanel,
previewPanel,
mainContent,
timeline,
setToolsPanel,
setPreviewPanel,
setMainContent,
setTimeline,
propertiesPanel,
setPropertiesPanel,
} = usePanelStore();
function EditorLayout() {
const { panels, setPanel } = usePanelStore();
return activePreset === "media" ? (
return (
<ResizablePanelGroup
key={`media-${activePreset}-${resetCounter}`}
direction="horizontal"
className="h-full w-full gap-[0.18rem] px-3 pb-3"
>
<ResizablePanel
defaultSize={toolsPanel}
minSize={15}
maxSize={40}
onResize={setToolsPanel}
className="min-w-0 rounded-sm"
>
<AssetsPanel />
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel
defaultSize={100 - toolsPanel}
minSize={60}
className="min-h-0 min-w-0"
>
<ResizablePanelGroup
direction="vertical"
className="h-full w-full gap-[0.18rem]"
>
<ResizablePanel
defaultSize={mainContent}
minSize={30}
maxSize={85}
onResize={setMainContent}
className="min-h-0"
>
<ResizablePanelGroup
direction="horizontal"
className="h-full w-full gap-[0.19rem]"
>
<ResizablePanel
defaultSize={previewPanel}
minSize={30}
onResize={setPreviewPanel}
className="min-h-0 min-w-0 flex-1"
>
<PreviewPanel />
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel
defaultSize={propertiesPanel}
minSize={15}
maxSize={40}
onResize={setPropertiesPanel}
className="min-w-0"
>
<PropertiesPanel />
</ResizablePanel>
</ResizablePanelGroup>
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel
defaultSize={timeline}
minSize={15}
maxSize={70}
onResize={setTimeline}
className="min-h-0"
>
<Timeline />
</ResizablePanel>
</ResizablePanelGroup>
</ResizablePanel>
</ResizablePanelGroup>
) : activePreset === "inspector" ? (
<ResizablePanelGroup
key={`inspector-${activePreset}-${resetCounter}`}
direction="horizontal"
className="h-full w-full gap-[0.18rem] px-3 pb-3"
>
<ResizablePanel
defaultSize={100 - propertiesPanel}
minSize={30}
onResize={(size) => setPropertiesPanel(100 - size)}
className="min-h-0 min-w-0"
>
<ResizablePanelGroup
direction="vertical"
className="h-full w-full gap-[0.18rem]"
>
<ResizablePanel
defaultSize={mainContent}
minSize={30}
maxSize={85}
onResize={setMainContent}
className="min-h-0"
>
<ResizablePanelGroup
direction="horizontal"
className="h-full w-full gap-[0.19rem]"
>
<ResizablePanel
defaultSize={toolsPanel}
minSize={15}
maxSize={40}
onResize={setToolsPanel}
className="min-w-0 rounded-sm"
>
<AssetsPanel />
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel
defaultSize={previewPanel}
minSize={30}
onResize={setPreviewPanel}
className="min-h-0 min-w-0 flex-1"
>
<PreviewPanel />
</ResizablePanel>
</ResizablePanelGroup>
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel
defaultSize={timeline}
minSize={15}
maxSize={70}
onResize={setTimeline}
className="min-h-0"
>
<Timeline />
</ResizablePanel>
</ResizablePanelGroup>
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel
defaultSize={propertiesPanel}
minSize={15}
maxSize={40}
onResize={setPropertiesPanel}
className="min-h-0 min-w-0"
>
<PropertiesPanel />
</ResizablePanel>
</ResizablePanelGroup>
) : activePreset === "vertical-preview" ? (
<ResizablePanelGroup
key={`vertical-preview-${activePreset}-${resetCounter}`}
direction="horizontal"
className="h-full w-full gap-[0.18rem] px-3 pb-3"
>
<ResizablePanel
defaultSize={100 - previewPanel}
minSize={30}
onResize={(size) => setPreviewPanel(100 - size)}
className="min-h-0 min-w-0"
>
<ResizablePanelGroup
direction="vertical"
className="h-full w-full gap-[0.18rem]"
>
<ResizablePanel
defaultSize={mainContent}
minSize={30}
maxSize={85}
onResize={setMainContent}
className="min-h-0"
>
<ResizablePanelGroup
direction="horizontal"
className="h-full w-full gap-[0.19rem]"
>
<ResizablePanel
defaultSize={toolsPanel}
minSize={15}
maxSize={40}
onResize={setToolsPanel}
className="min-w-0 rounded-sm"
>
<AssetsPanel />
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel
defaultSize={propertiesPanel}
minSize={15}
maxSize={40}
onResize={setPropertiesPanel}
className="min-w-0"
>
<PropertiesPanel />
</ResizablePanel>
</ResizablePanelGroup>
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel
defaultSize={timeline}
minSize={15}
maxSize={70}
onResize={setTimeline}
className="min-h-0"
>
<Timeline />
</ResizablePanel>
</ResizablePanelGroup>
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel
defaultSize={previewPanel}
minSize={30}
onResize={setPreviewPanel}
className="min-h-0 min-w-0"
>
<PreviewPanel />
</ResizablePanel>
</ResizablePanelGroup>
) : (
<ResizablePanelGroup
key={`default-${activePreset}-${resetCounter}`}
direction="vertical"
className="h-full w-full gap-[0.18rem]"
onLayout={(sizes) => {
setPanel("mainContent", sizes[0] ?? panels.mainContent);
setPanel("timeline", sizes[1] ?? panels.timeline);
}}
>
<ResizablePanel
defaultSize={mainContent}
defaultSize={panels.mainContent}
minSize={30}
maxSize={85}
onResize={setMainContent}
className="min-h-0"
>
<ResizablePanelGroup
direction="horizontal"
className="h-full w-full gap-[0.19rem] px-3"
onLayout={(sizes) => {
setPanel("tools", sizes[0] ?? panels.tools);
setPanel("preview", sizes[1] ?? panels.preview);
setPanel("properties", sizes[2] ?? panels.properties);
}}
>
<ResizablePanel
defaultSize={toolsPanel}
defaultSize={panels.tools}
minSize={15}
maxSize={40}
onResize={setToolsPanel}
className="min-w-0 rounded-sm"
>
<AssetsPanel />
@ -307,9 +73,8 @@ function EditorLayout({}: {}) {
<ResizableHandle withHandle />
<ResizablePanel
defaultSize={previewPanel}
defaultSize={panels.preview}
minSize={30}
onResize={setPreviewPanel}
className="min-h-0 min-w-0 flex-1"
>
<PreviewPanel />
@ -318,10 +83,9 @@ function EditorLayout({}: {}) {
<ResizableHandle withHandle />
<ResizablePanel
defaultSize={propertiesPanel}
defaultSize={panels.properties}
minSize={15}
maxSize={40}
onResize={setPropertiesPanel}
className="min-w-0 rounded-sm"
>
<PropertiesPanel />
@ -332,10 +96,9 @@ function EditorLayout({}: {}) {
<ResizableHandle withHandle />
<ResizablePanel
defaultSize={timeline}
defaultSize={panels.timeline}
minSize={15}
maxSize={70}
onResize={setTimeline}
className="min-h-0 px-3 pb-3"
>
<Timeline />

View File

@ -3,8 +3,7 @@ import { PropertyGroup } from "../../properties-panel/property-item";
import { PanelBaseView as BaseView } from "@/components/editor/panel-base-view";
import { LanguageSelect } from "@/components/language-select";
import { useState, useRef, useEffect } from "react";
import { extractTimelineAudio } from "@/lib/mediabunny-utils";
import { encryptWithRandomKey, arrayBufferToBase64 } from "@/lib/zk-encryption";
import { extractTimelineAudio } from "@/lib/media/mediabunny";
import { useEditor } from "@/hooks/use-editor";
import { DEFAULT_TEXT_ELEMENT } from "@/constants/text-constants";
import { LANGUAGES } from "@/constants/captions-constants";
@ -136,8 +135,8 @@ export function Captions() {
});
shortCaptions.forEach((caption, index) => {
editor.timeline.addElementToTrack({
trackId: captionTrackId,
editor.timeline.insertElement({
placement: { mode: "explicit", trackId: captionTrackId },
element: {
...DEFAULT_TEXT_ELEMENT,
name: `Caption ${index + 1}`,

View File

@ -11,16 +11,16 @@ import {
Music,
Video,
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { toast } from "sonner";
import { useEditor } from "@/hooks/use-editor";
import { useFileUpload } from "@/hooks/use-file-upload";
import { useRevealItem } from "@/hooks/use-reveal-item";
import { processMediaAssets } from "@/lib/media-processing-utils";
import { processMediaAssets } from "@/lib/media/processing";
import { cn } from "@/lib/utils";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { canElementGoOnTrack } from "@/lib/timeline/track-utils";
import { wouldElementOverlap } from "@/lib/timeline/element-utils";
import type { MediaAsset } from "@/types/assets";
import type { CreateTimelineElement, TrackType } from "@/types/timeline";
import type { CreateTimelineElement } from "@/types/timeline";
import { Button } from "@/components/ui/button";
import { MediaDragOverlay } from "@/components/editor/assets-panel/drag-overlay";
import {
@ -42,7 +42,6 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { usePanelStore } from "@/stores/panel-store";
import { useAssetsPanelStore } from "@/stores/assets-panel-store";
export function MediaView() {
@ -50,8 +49,7 @@ export function MediaView() {
const mediaFiles = editor.media.getAssets();
const activeProject = editor.project.getActive();
const { mediaViewMode, setMediaViewMode } = usePanelStore();
const { highlightMediaId, clearHighlight } = useAssetsPanelStore();
const { mediaViewMode, setMediaViewMode, highlightMediaId, clearHighlight } = useAssetsPanelStore();
const { highlightedId, registerElement } = useRevealItem(
highlightMediaId,
clearHighlight,
@ -129,39 +127,9 @@ export function MediaView() {
startTime: number;
}): boolean => {
const element = createElementFromMedia({ asset, startTime });
const trackType = getTrackTypeForMedia({ mediaType: asset.type });
const tracks = editor.timeline.getTracks();
const duration =
asset.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
const existingTrack = tracks.find((track) => {
if (
!canElementGoOnTrack({
elementType: element.type,
trackType: track.type,
})
) {
return false;
}
return !wouldElementOverlap({
elements: track.elements,
startTime,
endTime: startTime + duration,
});
});
if (existingTrack) {
editor.timeline.addElementToTrack({
trackId: existingTrack.id,
element,
});
return true;
}
const newTrackId = editor.timeline.addTrack({ type: trackType });
editor.timeline.addElementToTrack({
trackId: newTrackId,
editor.timeline.insertElement({
element,
placement: { mode: "auto" },
});
return true;
};
@ -531,12 +499,58 @@ function ListView({
);
}
const formatDuration = ({ duration }: { duration: number }) => {
const min = Math.floor(duration / 60);
const sec = Math.floor(duration % 60);
return `${min}:${sec.toString().padStart(2, "0")}`;
};
function MediaDurationBadge({ duration }: { duration?: number }) {
if (!duration) return null;
return (
<div className="absolute bottom-1 right-1 rounded bg-black/70 px-1 text-xs text-white">
{formatDuration({ duration })}
</div>
);
}
function MediaDurationLabel({ duration }: { duration?: number }) {
if (!duration) return null;
return (
<span className="text-xs opacity-70">{formatDuration({ duration })}</span>
);
}
function MediaTypePlaceholder({
icon: Icon,
label,
duration,
variant,
}: {
icon: LucideIcon;
label: string;
duration?: number;
variant: "muted" | "bordered";
}) {
const iconClassName = cn("size-6", variant === "bordered" && "mb-1");
return (
<div
className={cn(
"text-muted-foreground flex size-full flex-col items-center justify-center rounded",
variant === "muted" ? "bg-muted/30" : "border",
)}
>
<Icon className={iconClassName} aria-hidden="true" />
<span className="text-xs">{label}</span>
<MediaDurationLabel duration={duration} />
</div>
);
}
function MediaPreview({ item }: { item: MediaAsset }) {
const formatDuration = (duration: number) => {
const min = Math.floor(duration / 60);
const sec = Math.floor(duration % 60);
return `${min}:${sec.toString().padStart(2, "0")}`;
};
if (item.type === "image") {
return (
@ -564,47 +578,38 @@ function MediaPreview({ item }: { item: MediaAsset }) {
<div className="absolute inset-0 flex items-center justify-center rounded bg-black/20">
<Video className="size-6 text-white drop-shadow-md" />
</div>
{item.duration && (
<div className="absolute bottom-1 right-1 rounded bg-black/70 px-1 text-xs text-white">
{formatDuration(item.duration)}
</div>
)}
<MediaDurationBadge duration={item.duration} />
</div>
);
}
return (
<div className="bg-muted/30 text-muted-foreground flex size-full flex-col items-center justify-center rounded">
<Video className="mb-1 size-6" />
<span className="text-xs">Video</span>
{item.duration && (
<span className="text-xs opacity-70">
{formatDuration(item.duration)}
</span>
)}
</div>
<MediaTypePlaceholder
icon={Video}
label="Video"
duration={item.duration}
variant="muted"
/>
);
}
if (item.type === "audio") {
return (
<div className="bg-linear-to-br text-muted-foreground flex size-full flex-col items-center justify-center rounded border border-green-500/20 from-green-500/20 to-emerald-500/20">
<Music className="mb-1 size-6" />
<span className="text-xs">Audio</span>
{item.duration && (
<span className="text-xs opacity-70">
{formatDuration(item.duration)}
</span>
)}
</div>
<MediaTypePlaceholder
icon={Music}
label="Audio"
duration={item.duration}
variant="bordered"
/>
);
}
return (
<div className="bg-muted/30 text-muted-foreground flex size-full flex-col items-center justify-center rounded">
<Image className="size-6" />
<span className="mt-1 text-xs">Unknown</span>
</div>
<MediaTypePlaceholder
icon={Image}
label="Unknown"
variant="muted"
/>
);
}
@ -631,22 +636,6 @@ function SortMenuItem({
);
}
function getTrackTypeForMedia({
mediaType,
}: {
mediaType: MediaAsset["type"];
}): TrackType {
switch (mediaType) {
case "video":
case "image":
return "video";
case "audio":
return "audio";
default:
return "video";
}
}
function createElementFromMedia({
asset,
startTime,
@ -697,7 +686,6 @@ function createElementFromMedia({
trimEnd: 0,
volume: 1,
muted: false,
buffer: new AudioBuffer({ length: 1, sampleRate: 44100 }),
};
default:
throw new Error(`Unsupported media type: ${asset.type}`);

View File

@ -124,14 +124,23 @@ function ProjectInfoView() {
const currentCanvasSize = getCurrentCanvasSize({ activeProject });
const currentAspectRatio = dimensionToAspectRatio(currentCanvasSize);
const originalCanvasSize = activeProject.settings.originalCanvasSize ?? null;
const presetIndex = findPresetIndexByAspectRatio({
presets: canvasPresets,
targetAspectRatio: currentAspectRatio,
});
const selectedPresetIndex =
presetIndex !== -1 ? presetIndex.toString() : undefined;
const originalPresetValue = "original";
const selectedPresetValue =
presetIndex !== -1 ? presetIndex.toString() : originalPresetValue;
const handleAspectRatioChange = ({ value }: { value: string }) => {
if (value === originalPresetValue) {
const canvasSize = originalCanvasSize ?? currentCanvasSize;
editor.project.updateSettings({
settings: { canvasSize },
});
return;
}
const index = parseInt(value, 10);
const preset = canvasPresets[index];
if (preset) {
@ -157,13 +166,14 @@ function ProjectInfoView() {
<PropertyItemLabel>Aspect ratio</PropertyItemLabel>
<PropertyItemValue>
<Select
value={selectedPresetIndex}
value={selectedPresetValue}
onValueChange={(value) => handleAspectRatioChange({ value })}
>
<SelectTrigger className="bg-panel-accent">
<SelectValue placeholder="Select an aspect ratio" />
</SelectTrigger>
<SelectContent>
<SelectItem value={originalPresetValue}>Original</SelectItem>
{canvasPresets.map((preset, index) => {
const label = dimensionToAspectRatio({
width: preset.width,
@ -262,18 +272,18 @@ const BackgroundPreviews = memo(
className={cn(
"border-foreground/15 hover:border-primary aspect-square w-full cursor-pointer rounded-sm border",
isColorBackground &&
bg === currentBackgroundColor &&
"border-primary border-2",
bg === currentBackgroundColor &&
"border-primary border-2",
)}
style={
useBackgroundColor
? { backgroundColor: bg }
: {
background: bg,
backgroundSize: "cover",
backgroundPosition: "center",
backgroundRepeat: "no-repeat",
}
background: bg,
backgroundSize: "cover",
backgroundPosition: "center",
backgroundRepeat: "no-repeat",
}
}
onClick={() => handleColorSelect({ bg })}
/>

View File

@ -53,25 +53,25 @@ export function StickersView() {
{
value: "all",
label: "All",
icon: <Grid3X3 className="h-3 w-3" />,
icon: <Grid3X3 className="size-3" />,
content: <StickersContentView category="all" />,
},
{
value: "general",
label: "Icons",
icon: <Sparkles className="h-3 w-3" />,
icon: <Sparkles className="size-3" />,
content: <StickersContentView category="general" />,
},
{
value: "brands",
label: "Brands",
icon: <Hash className="h-3 w-3" />,
icon: <Hash className="size-3" />,
content: <StickersContentView category="brands" />,
},
{
value: "emoji",
label: "Emoji",
icon: <Smile className="h-3 w-3" />,
icon: <Smile className="size-3" />,
content: <StickersContentView category="emoji" />,
},
]}
@ -328,7 +328,7 @@ function StickersContentView({ category }: { category: StickerCategory }) {
{recentStickers.length > 0 && viewMode === "browse" && (
<div className="h-full">
<div className="mb-2 flex items-center gap-2">
<Clock className="text-muted-foreground h-4 w-4" />
<Clock className="text-muted-foreground size-4" />
<span className="text-sm font-medium">Recent</span>
<TooltipProvider>
<Tooltip>
@ -337,7 +337,7 @@ function StickersContentView({ category }: { category: StickerCategory }) {
onClick={clearRecentStickers}
className="hover:bg-accent ml-auto flex h-5 w-5 items-center justify-center rounded p-0"
>
<X className="text-muted-foreground h-3 w-3" />
<X className="text-muted-foreground size-3" />
</button>
</TooltipTrigger>
<TooltipContent>
@ -359,7 +359,7 @@ function StickersContentView({ category }: { category: StickerCategory }) {
<div className="h-full">
{isLoadingCollection ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="text-muted-foreground h-6 w-6 animate-spin" />
<Loader2 className="text-muted-foreground size-6 animate-spin" />
</div>
) : showCollectionItems ? (
<StickerGrid
@ -379,7 +379,7 @@ function StickersContentView({ category }: { category: StickerCategory }) {
<div className="h-full">
{isSearching ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="text-muted-foreground h-6 w-6 animate-spin" />
<Loader2 className="text-muted-foreground size-6 animate-spin" />
</div>
) : searchResults?.icons.length ? (
<>
@ -426,7 +426,7 @@ function StickersContentView({ category }: { category: StickerCategory }) {
<div className="h-full space-y-4">
{isLoadingCollections ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="text-muted-foreground h-6 w-6 animate-spin" />
<Loader2 className="text-muted-foreground size-6 animate-spin" />
</div>
) : (
<>
@ -482,7 +482,7 @@ function CollectionItem({ title, subtitle, onClick }: CollectionItemProps) {
<p className="font-medium">{title}</p>
<p className="text-muted-foreground text-xs">{subtitle}</p>
</div>
<ArrowRight className="h-4 w-4" />
<ArrowRight className="size-4" />
</Button>
);
}
@ -583,7 +583,7 @@ function StickerItem({
/>
{isAdding && (
<div className="absolute inset-0 z-10 flex items-center justify-center rounded-md bg-black/60">
<Loader2 className="h-6 w-6 animate-spin text-white" />
<Loader2 className="size-6 animate-spin text-white" />
</div>
)}
</div>

View File

@ -15,13 +15,11 @@ export function TextView() {
raw: DEFAULT_TEXT_ELEMENT,
startTime: currentTime,
});
const textTrack = activeScene.tracks.find((t) => t.type === "text");
if (textTrack) {
editor.timeline.addElementToTrack({
trackId: textTrack.id,
element,
});
}
editor.timeline.insertElement({
element,
placement: { mode: "auto" },
});
};
return (

View File

@ -22,7 +22,6 @@ import { RenameProjectDialog } from "../rename-project-dialog";
import { DeleteProjectDialog } from "../delete-project-dialog";
import { useRouter } from "next/navigation";
import { FaDiscord } from "react-icons/fa6";
import { PanelPresetSelector } from "./panel-preset-selector";
import { ExportButton } from "./export-button";
import { ThemeToggle } from "../theme-toggle";
import { SOCIAL_LINKS } from "@/constants/site-constants";
@ -36,7 +35,6 @@ export function EditorHeader() {
<ProjectDropdown />
</div>
<nav className="flex items-center gap-2">
<PanelPresetSelector />
<KeyboardShortcutsHelp />
<ExportButton />
<ThemeToggle />
@ -51,7 +49,7 @@ function ProjectDropdown() {
const [isExiting, setIsExiting] = useState(false);
const router = useRouter();
const editor = useEditor();
const activeProject = editor.project.getActiveOrNull();
const activeProject = editor.project.getActive();
const handleExit = async () => {
if (isExiting) return;

View File

@ -9,11 +9,7 @@ import { RadioGroup, RadioGroupItem } from "../ui/radio-group";
import { Progress } from "../ui/progress";
import { Checkbox } from "../ui/checkbox";
import { cn } from "@/lib/utils";
import {
exportProject,
getExportMimeType,
getExportFileExtension,
} from "@/lib/export-utils";
import { getExportMimeType, getExportFileExtension } from "@/lib/export";
import { Check, Copy, Download, RotateCcw, X } from "lucide-react";
import { ExportFormat, ExportQuality, ExportResult } from "@/types/export";
import { PropertyGroup } from "./properties-panel/property-item";
@ -91,13 +87,15 @@ function ExportPopover({
setProgress(0);
setExportResult(null);
const result = await exportProject({
format,
quality,
fps: activeProject.settings.fps,
includeAudio,
onProgress: ({ progress }) => setProgress(progress),
onCancel: () => false, // TODO: add cancel functionality
const result = await editor.project.export({
options: {
format,
quality,
fps: activeProject.settings.fps,
includeAudio,
onProgress: ({ progress }) => setProgress(progress),
onCancel: () => false, // TODO: add cancel functionality
},
});
setIsExporting(false);
@ -255,7 +253,7 @@ function ExportPopover({
<Button
variant="outline"
className="w-full rounded-md"
onClick={() => {}}
onClick={() => { }}
>
Cancel
</Button>

View File

@ -1,91 +0,0 @@
"use client";
import { Button } from "../ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "../ui/dropdown-menu";
import { ChevronDown, RotateCcw, LayoutPanelTop } from "lucide-react";
import { usePanelStore, type PanelPreset } from "@/stores/panel-store";
const PRESET_LABELS: Record<PanelPreset, string> = {
default: "Default",
media: "Media",
inspector: "Inspector",
"vertical-preview": "Vertical Preview",
};
const PRESET_DESCRIPTIONS: Record<PanelPreset, string> = {
default: "Media, preview, and inspector on top row, timeline on bottom",
media: "Full height media on left, preview and inspector on top row",
inspector: "Full height inspector on right, media and preview on top row",
"vertical-preview": "Full height preview on right for vertical videos",
};
export function PanelPresetSelector() {
const { activePreset, setActivePreset, resetPreset } = usePanelStore();
const handlePresetChange = (preset: PanelPreset) => {
setActivePreset(preset);
};
const handleResetPreset = (preset: PanelPreset, event: React.MouseEvent) => {
event.stopPropagation();
resetPreset(preset);
};
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="secondary"
size="sm"
className="h-8 px-2 flex items-center gap-1 text-xs"
title="Panel Presets"
>
<LayoutPanelTop className="h-4 w-4" />
<ChevronDown className="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-64">
<div className="px-3 py-2 text-xs font-medium text-muted-foreground">
Panel Presets
</div>
<DropdownMenuSeparator />
{(Object.keys(PRESET_LABELS) as PanelPreset[]).map((preset) => (
<DropdownMenuItem
key={preset}
onClick={() => handlePresetChange(preset)}
className="flex items-start justify-between gap-2 py-2 px-3 cursor-pointer"
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-sm">
{PRESET_LABELS[preset]}
</span>
{activePreset === preset && (
<div className="h-2 w-2 rounded-full bg-primary" />
)}
</div>
<p className="text-xs text-muted-foreground mt-0.5 leading-tight">
{PRESET_DESCRIPTIONS[preset]}
</p>
</div>
<Button
variant="secondary"
size="icon"
className="h-6 w-6 shrink-0 opacity-60 hover:opacity-100"
onClick={(e) => handleResetPreset(preset, e)}
title={`Reset ${PRESET_LABELS[preset]} preset`}
>
<RotateCcw className="h-3 w-3" />
</Button>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@ -18,7 +18,7 @@ import {
PropertyItemValue,
} from "./property-item";
import { ColorPicker } from "@/components/ui/color-picker";
import { cn, capitalizeFirstLetter, clamp } from "@/lib/utils";
import { cn, capitalizeFirstLetter, clamp, uppercase } from "@/lib/utils";
import { Grid2x2 } from "lucide-react";
import {
Tooltip,
@ -296,7 +296,7 @@ export function TextProperties({
<PropertyItemLabel>Color</PropertyItemLabel>
<PropertyItemValue>
<ColorPicker
value={capitalizeFirstLetter({ string:
value={uppercase({ string:
(element.color || "FFFFFF").replace("#", "")
})}
onChange={(color) => {

View File

@ -27,11 +27,10 @@ export function TimelineBookmarksRow({
return (
<div
className="relative mt-0.5 h-4 flex-1 overflow-hidden"
className="relative h-4 flex-1 overflow-hidden"
onWheel={handleWheel}
onClick={handleTimelineContentClick}
onMouseDown={handleRulerTrackingMouseDown}
data-bookmarks-area
>
<ScrollArea className="scrollbar-hidden w-full" ref={bookmarksScrollRef}>
<div

View File

@ -29,6 +29,7 @@ import {
canTracktHaveAudio,
canTrackBeHidden,
isMainTrack,
getTimelineZoomMin,
} from "@/lib/timeline";
import { TimelineToolbar } from "./timeline-toolbar";
import { useScrollSync } from "@/hooks/timeline/use-scroll-sync";
@ -43,7 +44,7 @@ import { useTimelinePlayhead } from "@/hooks/timeline/use-timeline-playhead";
import { DragLine } from "./drag-line";
export function Timeline() {
const tracksContainerHeight = { min: 200, max: 800 };
const tracksContainerHeight = { min: 0, max: 800 };
const { snappingEnabled } = useTimelineStore();
const { clearElementSelection, setElementSelection } = useElementSelection();
const editor = useEditor();
@ -64,6 +65,7 @@ export function Timeline() {
// state
const [isInTimeline, setIsInTimeline] = useState(false);
const [isResizing, setIsResizing] = useState(false);
const [currentSnapPoint, setCurrentSnapPoint] = useState<SnapPoint | null>(
null,
);
@ -71,10 +73,26 @@ export function Timeline() {
const handleSnapPointChange = useCallback((snapPoint: SnapPoint | null) => {
setCurrentSnapPoint(snapPoint);
}, []);
const handleResizeStateChange = useCallback(
({ isResizing: nextIsResizing }: { isResizing: boolean }) => {
setIsResizing(nextIsResizing);
if (!nextIsResizing) {
setCurrentSnapPoint(null);
}
},
[],
);
const timelineDuration = timeline.getTotalDuration() || 0;
const minZoomLevel = getTimelineZoomMin({
duration: timelineDuration,
containerWidth: timelineRef.current?.clientWidth,
});
const { zoomLevel, setZoomLevel, handleWheel } = useTimelineZoom({
containerRef: timelineRef,
isInTimeline,
minZoom: minZoomLevel,
});
const {
@ -88,6 +106,7 @@ export function Timeline() {
timelineRef,
tracksContainerRef,
tracksScrollRef,
snappingEnabled,
onSnapPointChange: handleSnapPointChange,
});
@ -118,7 +137,6 @@ export function Timeline() {
zoomLevel,
});
const timelineDuration = timeline.getTotalDuration() || 0;
const paddedDuration =
timelineDuration + TIMELINE_CONSTANTS.PLAYHEAD_LOOKAHEAD_SECONDS;
const dynamicTimelineWidth = Math.max(
@ -127,7 +145,9 @@ export function Timeline() {
);
const showSnapIndicator =
dragState.isDragging && snappingEnabled && currentSnapPoint !== null;
snappingEnabled &&
currentSnapPoint !== null &&
(dragState.isDragging || isResizing);
const {
handleTracksMouseDown,
@ -164,6 +184,7 @@ export function Timeline() {
>
<TimelineToolbar
zoomLevel={zoomLevel}
minZoom={minZoomLevel}
setZoomLevel={({ zoom }) => setZoomLevel(zoom)}
/>
@ -197,7 +218,6 @@ export function Timeline() {
<div className="bg-panel flex h-4 w-28 shrink-0 items-center justify-between border-r px-3">
<span className="opacity-0">.</span>
</div>
<TimelineRuler
zoomLevel={zoomLevel}
dynamicTimelineWidth={dynamicTimelineWidth}
@ -210,7 +230,7 @@ export function Timeline() {
/>
</div>
<div className="flex">
<div className="bg-panel flex h-6 w-28 shrink-0 items-center justify-between border-r px-3">
<div className="bg-panel flex h-4 w-28 shrink-0 items-center justify-between border-r px-3">
<span className="opacity-0">.</span>
</div>
<TimelineBookmarksRow
@ -230,7 +250,7 @@ export function Timeline() {
<div
ref={trackLabelsRef}
className="z-100 bg-panel w-28 shrink-0 overflow-y-auto border-r"
data-track-labels
style={{ paddingTop: TIMELINE_CONSTANTS.PADDING_TOP }}
>
<ScrollArea className="h-full w-full" ref={trackLabelsScrollRef}>
<div className="flex flex-col gap-1">
@ -244,9 +264,9 @@ export function Timeline() {
>
<div className="flex min-w-0 flex-1 items-center justify-end gap-2">
{/* Debug main track */}
{isMainTrack(track) && (
{/* {isMainTrack(track) && (
<div className="size-2 rounded-full bg-red-500" />
)}
)} */}
{canTracktHaveAudio(track) && (
<TrackToggleIcon
@ -289,6 +309,7 @@ export function Timeline() {
<div
className="relative flex-1 overflow-hidden"
style={{ paddingTop: TIMELINE_CONSTANTS.PADDING_TOP }}
onWheel={(e) => {
if (e.shiftKey || Math.abs(e.deltaX) > Math.abs(e.deltaY)) {
return;
@ -319,7 +340,23 @@ export function Timeline() {
isVisible={dragState.isDragging}
/>
<ScrollArea className="h-full w-full" ref={tracksScrollRef}>
<ScrollArea
className="h-full w-full"
ref={tracksScrollRef}
onMouseDown={(event) => {
const isDirectTarget = event.target === event.currentTarget;
if (!isDirectTarget) return;
event.stopPropagation();
handleTracksMouseDown(event);
handleSelectionMouseDown(event);
}}
onClick={(event) => {
const isDirectTarget = event.target === event.currentTarget;
if (!isDirectTarget) return;
event.stopPropagation();
handleTracksClick(event);
}}
>
<div
className="relative flex-1"
style={{
@ -357,6 +394,8 @@ export function Timeline() {
rulerScrollRef={rulerScrollRef}
tracksScrollRef={tracksScrollRef}
lastMouseXRef={lastMouseXRef}
onSnapPointChange={handleSnapPointChange}
onResizeStateChange={handleResizeStateChange}
onElementMouseDown={handleElementMouseDown}
onElementClick={handleElementClick}
/>

View File

@ -12,11 +12,11 @@ import {
VolumeX,
ArrowUpDown,
} from "lucide-react";
import { useEffect, useRef } from "react";
import { useEditor } from "@/hooks/use-editor";
import { useAssetsPanelStore } from "@/stores/assets-panel-store";
import AudioWaveform from "./audio-waveform";
import { useTimelineElementResize } from "@/hooks/timeline/element/use-element-resize";
import type { SnapPoint } from "@/hooks/timeline/use-timeline-snapping";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import {
getTrackClasses,
@ -47,6 +47,8 @@ interface TimelineElementProps {
track: TimelineTrack;
zoomLevel: number;
isSelected: boolean;
onSnapPointChange?: (snapPoint: SnapPoint | null) => void;
onResizeStateChange?: (params: { isResizing: boolean }) => void;
onElementMouseDown: (
e: React.MouseEvent,
element: TimelineElementType,
@ -60,12 +62,13 @@ export function TimelineElement({
track,
zoomLevel,
isSelected,
onSnapPointChange,
onResizeStateChange,
onElementMouseDown,
onElementClick,
dragState,
}: TimelineElementProps) {
const editor = useEditor();
const lastDragStateRef = useRef(false);
const { selectedElements } = useElementSelection();
const { requestRevealMedia } = useAssetsPanelStore();
@ -88,6 +91,8 @@ export function TimelineElement({
element,
track,
zoomLevel,
onSnapPointChange,
onResizeStateChange,
});
const isCurrentElementSelected = selectedElements.some(
@ -144,8 +149,6 @@ export function TimelineElement({
? `translate3d(0, ${dragOffsetY}px, 0)`
: undefined,
}}
data-element-id={element.id}
data-track-id={track.id}
>
<ElementInner
element={element}
@ -307,28 +310,46 @@ function ElementInner({
{isSelected && (
<>
<div
className="bg-primary absolute bottom-0 left-0 top-0 z-50 flex w-[0.6rem] cursor-w-resize items-center justify-center"
onMouseDown={(e) =>
handleResizeStart({ e, elementId: element.id, side: "left" })
}
>
<div className="bg-foreground/75 h-[1.5rem] w-[0.2rem] rounded-full" />
</div>
<div
className="bg-primary absolute bottom-0 right-0 top-0 z-50 flex w-[0.6rem] cursor-e-resize items-center justify-center"
onMouseDown={(e) =>
handleResizeStart({ e, elementId: element.id, side: "right" })
}
>
<div className="bg-foreground/75 h-[1.5rem] w-[0.2rem] rounded-full" />
</div>
<ResizeHandle
side="left"
elementId={element.id}
handleResizeStart={handleResizeStart}
/>
<ResizeHandle
side="right"
elementId={element.id}
handleResizeStart={handleResizeStart}
/>
</>
)}
</div>
);
}
function ResizeHandle({
side,
elementId,
handleResizeStart,
}: {
side: "left" | "right";
elementId: string;
handleResizeStart: (params: {
e: React.MouseEvent;
elementId: string;
side: "left" | "right";
}) => void;
}) {
const isLeft = side === "left";
return (
<div
className={`bg-primary absolute bottom-0 top-0 z-50 flex w-[0.6rem] items-center justify-center ${isLeft ? "left-0 cursor-w-resize" : "right-0 cursor-e-resize"}`}
onMouseDown={(e) => handleResizeStart({ e, elementId, side })}
>
<div className="bg-foreground h-[1.5rem] w-[0.2rem] rounded-full" />
</div>
);
}
function ElementContent({
element,
track,
@ -362,13 +383,12 @@ function ElementContent({
}
if (element.type === "audio") {
const audioBuffer =
element.sourceType === "library" ? element.buffer : undefined;
const audioBuffer = element.sourceType === "library" ? element.buffer : undefined;
const audioUrl =
element.sourceType === "upload"
? mediaAssets.find((asset) => asset.id === element.mediaId)?.url
: undefined;
element.sourceType === "library"
? element.sourceUrl
: mediaAssets.find((asset) => asset.id === element.mediaId)?.url;
if (audioBuffer || audioUrl) {
return (

View File

@ -99,7 +99,7 @@ export function TimelinePlayhead({
<div className="bg-foreground absolute left-0 h-full w-0.5 cursor-col-resize" />
<div
className={`shadow-xs absolute left-1/2 top-1 h-3 w-3 -translate-x-1/2 transform rounded-full border-2 ${isSnappingToPlayhead ? "bg-foreground border-foreground" : "bg-foreground border-foreground/50"}`}
className={`shadow-xs absolute left-1/2 top-1 size-3 -translate-x-1/2 transform rounded-full border-2 ${isSnappingToPlayhead ? "bg-foreground border-foreground" : "bg-foreground border-foreground/50"}`}
/>
</div>
);

View File

@ -26,14 +26,12 @@ export function TimelineRuler({
handleRulerMouseDown,
}: TimelineRulerProps) {
const editor = useEditor();
const activeScene = editor.scenes.getActiveScene();
const duration = editor.timeline.getTotalDuration();
const pixelsPerSecond = TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
const visibleDuration = dynamicTimelineWidth / pixelsPerSecond;
const effectiveDuration = Math.max(duration, visibleDuration);
const project = editor.project.getActiveOrNull();
const project = editor.project.getActive();
const fps = project?.settings.fps ?? DEFAULT_FPS;
const interval = getOptimalTimeInterval({ zoomLevel, fps });
const markerCount = Math.ceil(effectiveDuration / interval) + 1;
@ -53,7 +51,6 @@ export function TimelineRuler({
onWheel={handleWheel}
onClick={handleTimelineContentClick}
onMouseDown={handleRulerTrackingMouseDown}
data-ruler-area
>
<ScrollArea className="scrollbar-hidden w-full" ref={rulerScrollRef}>
<div

View File

@ -41,9 +41,11 @@ import { useTimelineStore } from "@/stores/timeline-store";
export function TimelineToolbar({
zoomLevel,
minZoom,
setZoomLevel,
}: {
zoomLevel: number;
minZoom: number;
setZoomLevel: ({ zoom }: { zoom: number }) => void;
}) {
const handleZoom = ({ direction }: { direction: "in" | "out" }) => {
@ -54,7 +56,7 @@ export function TimelineToolbar({
zoomLevel + TIMELINE_CONSTANTS.ZOOM_STEP,
)
: Math.max(
TIMELINE_CONSTANTS.ZOOM_MIN,
minZoom,
zoomLevel - TIMELINE_CONSTANTS.ZOOM_STEP,
);
setZoomLevel({ zoom: newZoomLevel });
@ -68,6 +70,7 @@ export function TimelineToolbar({
<ToolbarRightSection
zoomLevel={zoomLevel}
minZoom={minZoom}
onZoomChange={(zoom) => setZoomLevel({ zoom })}
onZoom={handleZoom}
/>
@ -229,7 +232,6 @@ function SceneSelector() {
<SplitButtonSeparator />
<ScenesView>
<SplitButtonRight
disabled={scenesCount === 1}
onClick={() => { }}
type="button"
>
@ -243,10 +245,12 @@ function SceneSelector() {
function ToolbarRightSection({
zoomLevel,
minZoom,
onZoomChange,
onZoom,
}: {
zoomLevel: number;
minZoom: number;
onZoomChange: (zoom: number) => void;
onZoom: (options: { direction: "in" | "out" }) => void;
}) {
@ -283,7 +287,7 @@ function ToolbarRightSection({
className="w-24"
value={[zoomLevel]}
onValueChange={(values) => onZoomChange(values[0])}
min={TIMELINE_CONSTANTS.ZOOM_MIN}
min={minZoom}
max={TIMELINE_CONSTANTS.ZOOM_MAX}
step={TIMELINE_CONSTANTS.ZOOM_STEP}
/>

View File

@ -4,10 +4,12 @@ import { useElementSelection } from "@/hooks/timeline/element/use-element-select
import { TimelineElement } from "./timeline-element";
import { TimelineTrack } from "@/types/timeline";
import type { TimelineElement as TimelineElementType } from "@/types/timeline";
import type { SnapPoint } from "@/hooks/timeline/use-timeline-snapping";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { useEdgeAutoScroll } from "@/hooks/timeline/use-edge-auto-scroll";
import { ElementDragState } from "@/types/timeline";
import { useEditor } from "@/hooks/use-editor";
import { cn } from "@/lib/utils";
interface TimelineTrackContentProps {
track: TimelineTrack;
@ -16,6 +18,8 @@ interface TimelineTrackContentProps {
rulerScrollRef: React.RefObject<HTMLDivElement>;
tracksScrollRef: React.RefObject<HTMLDivElement>;
lastMouseXRef: React.RefObject<number>;
onSnapPointChange?: (snapPoint: SnapPoint | null) => void;
onResizeStateChange?: (params: { isResizing: boolean }) => void;
onElementMouseDown: (params: {
event: React.MouseEvent;
element: TimelineElementType;
@ -35,6 +39,8 @@ export function TimelineTrackContent({
rulerScrollRef,
tracksScrollRef,
lastMouseXRef,
onSnapPointChange,
onResizeStateChange,
onElementMouseDown,
onElementClick,
}: TimelineTrackContentProps) {
@ -51,9 +57,13 @@ export function TimelineTrackContent({
contentWidth: duration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel,
});
const hasSelectedElements = track.elements.some((element) =>
isElementSelected({ trackId: track.id, elementId: element.id })
);
return (
<div
className="size-full"
className={cn("size-full", hasSelectedElements && "bg-panel-accent/35")}
onClick={clearElementSelection}
>
<div className="relative h-full min-w-full">
@ -74,6 +84,8 @@ export function TimelineTrackContent({
track={track}
zoomLevel={zoomLevel}
isSelected={isSelected}
onSnapPointChange={onSnapPointChange}
onResizeStateChange={onResizeStateChange}
onElementMouseDown={(event, element) =>
onElementMouseDown({ event, element, track })
}

View File

@ -129,7 +129,7 @@ export function KeyboardShortcutsHelp() {
<h3 className="text-xs text-muted-foreground uppercase tracking-wide font-medium">
{category}
</h3>
<div className="space-y-0.5">
<div className="space-y-1">
{shortcuts
.filter((shortcut) => shortcut.category === category)
.map((shortcut) => (
@ -147,12 +147,13 @@ export function KeyboardShortcutsHelp() {
))}
</div>
</div>
<DialogFooter className="flex-shrink-0 p-6 pt-4">
<DialogFooter className="p-4 pt-0">
<Button size="sm" variant="destructive" onClick={resetToDefaults}>
Reset to Default
Reset to default
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@ -185,10 +186,10 @@ function ShortcutItem({
)}
<span className="text-sm">{shortcut.description}</span>
</div>
<div className="flex items-center gap-1">
<div className="flex items-center gap-2">
{displayKeys.map((key: string, index: number) => (
<div key={key} className="flex items-center gap-1">
<div className="flex items-center">
<div key={key} className="flex items-center gap-2">
<div className="flex items-center gap-1">
{key.split("+").map((keyPart: string, partIndex: number) => {
const keyId = `${shortcut.id}-${index}-${partIndex}`;
return (
@ -231,9 +232,6 @@ function EditableShortcutKey({
<Button
variant="outline"
size="sm"
className={`font-sans px-2 min-w-6 min-h-6 leading-none mr-1 hover:bg-opacity-80 ${
isRecording ? "border-primary bg-primary/10" : "border bg-accent/50"
}`}
onClick={handleClick}
title={
isRecording ? "Press any key combination..." : "Click to edit shortcut"

View File

@ -2,7 +2,7 @@
import { createContext, useContext, useEffect, useRef, useState } from "react";
import { useEditor } from "@/hooks/use-editor";
import { storageService } from "@/lib/storage/storage-service";
import { storageService } from "@/services/storage/storage-service";
import { toast } from "sonner";
interface StorageContextType {

View File

@ -18,7 +18,7 @@ const buttonVariants = cva(
"primary-gradient":
"bg-gradient-to-r from-cyan-400 to-blue-500 text-white hover:opacity-85 transition-opacity",
destructive:
"bg-destructive/0 border border-destructive/25 text-destructive shadow-xs hover:bg-destructive hover:text-destructive-foreground",
"bg-destructive text-destructive-foreground shadow-xs hover:bg-destructive/80",
outline:
"border border-input bg-transparent shadow-xs transition-colors hover:bg-accent",
secondary:

View File

@ -64,7 +64,7 @@ const DialogHeader = ({
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
"flex flex-col space-y-2 text-left",
className
)}
{...props}

View File

@ -12,7 +12,7 @@ import { createPortal } from "react-dom";
import { Plus } from "lucide-react";
import { cn } from "@/lib/utils";
import { useEditor } from "@/hooks/use-editor";
import { setDragData } from "@/lib/drag-data";
import { clearDragData, setDragData } from "@/lib/drag-data";
import type { TimelineDragData } from "@/types/drag";
export interface DraggableItemProps {
@ -90,6 +90,7 @@ export function DraggableItem({
const handleDragEnd = () => {
setIsDragging(false);
clearDragData();
};
return (

View File

@ -55,7 +55,7 @@ const NavigationMenuTrigger = React.forwardRef<
>
{children}{" "}
<ChevronDown
className="relative top-px ml-1 h-3 w-3 transition duration-300 group-data-[state=open]:rotate-180"
className="relative top-px ml-1 size-3 transition duration-300 group-data-[state=open]:rotate-180"
aria-hidden="true"
/>
</NavigationMenuPrimitive.Trigger>

View File

@ -3,3 +3,13 @@ import type { TPlatformLayout } from "@/types/editor";
export const PLATFORM_LAYOUTS: Record<TPlatformLayout, string> = {
tiktok: "TikTok",
};
export const PANEL_CONFIG = {
panels: {
tools: 25,
preview: 50,
properties: 25,
mainContent: 70,
timeline: 30,
},
};

View File

@ -3,27 +3,19 @@ import { Video, TypeIcon, Music, Sticker } from "lucide-react";
export const TRACK_COLORS: Record<
TrackType,
{ solid: string; background: string; border: string }
{ background: string; }
> = {
video: {
solid: "bg-blue-500",
background: "",
border: "",
background: "transparent",
},
text: {
solid: "bg-[#5DBAA0]",
background: "bg-[#5DBAA0]",
border: "",
},
audio: {
solid: "bg-green-500",
background: "bg-[#915DBE]",
border: "",
},
sticker: {
solid: "bg-amber-500",
background: "bg-amber-500",
border: "",
},
} as const;
@ -38,10 +30,9 @@ export const TRACK_GAP = 4;
export const TIMELINE_CONSTANTS = {
PIXELS_PER_SECOND: 50,
TRACK_HEIGHT: 60,
DEFAULT_ELEMENT_DURATION: 5,
MIN_DURATION_SECONDS: 10,
PLAYHEAD_LOOKAHEAD_SECONDS: 30, // Padding ahead of playhead
PLAYHEAD_LOOKAHEAD_SECONDS: 30, // padding ahead
PADDING_TOP: 0, // px
ZOOM_LEVELS: [0.1, 0.25, 0.5, 1, 1.5, 2, 3, 4, 6, 8, 10, 15, 20, 30, 50],
ZOOM_MIN: 0.1,
ZOOM_MAX: 50,

View File

@ -6,9 +6,6 @@ import { MediaManager } from "./managers/media-manager";
import { RendererManager } from "./managers/renderer-manager";
import { CommandManager } from "./managers/commands";
import { SaveManager } from "./managers/save-manager";
import { buildScene } from "@/services/renderer/scene-builder";
import { SceneExporter } from "@/services/renderer/scene-exporter";
import type { ExportOptions } from "@/types/export";
export class EditorCore {
private static instance: EditorCore | null = null;
@ -45,64 +42,4 @@ export class EditorCore {
EditorCore.instance = null;
}
async export({
format,
quality,
includeAudio = true,
onProgress,
}: ExportOptions): Promise<{
success: boolean;
buffer?: ArrayBuffer;
error?: string;
}> {
const project = this.project.getActive();
if (!project) {
return { success: false, error: "No active project" };
}
const duration = this.timeline.getTotalDuration();
if (duration === 0) {
return { success: false, error: "Timeline is empty" };
}
try {
const sceneGraph = buildScene({
tracks: this.timeline.getTracks(),
mediaAssets: this.media.getAssets(),
duration,
canvasSize: project.settings.canvasSize,
background: project.settings.background,
});
const exporter = new SceneExporter({
width: project.settings.canvasSize.width,
height: project.settings.canvasSize.height,
fps: project.settings.fps,
format,
quality,
includeAudio,
});
let progressHandler: ((progress: number) => void) | undefined;
if (onProgress) {
progressHandler = (progress: number) => onProgress({ progress });
exporter.on("progress", progressHandler);
}
const buffer = await exporter.export(sceneGraph);
if (progressHandler) {
exporter.off("progress", progressHandler);
}
if (!buffer) {
return { success: false, error: "Export was cancelled" };
}
return { success: true, buffer };
} catch (error) {
const message = error instanceof Error ? error.message : "Export failed";
return { success: false, error: message };
}
}
}

View File

@ -1,8 +1,8 @@
import type { EditorCore } from "@/core";
import type { MediaAsset } from "@/types/assets";
import { storageService } from "@/lib/storage/storage-service";
import { storageService } from "@/services/storage/storage-service";
import { generateUUID } from "@/lib/utils";
import { videoCache } from "@/lib/video-cache";
import { videoCache } from "@/services/media/video-cache";
import { hasMediaId } from "@/lib/timeline/element-utils";
export class MediaManager {
@ -10,7 +10,7 @@ export class MediaManager {
private isLoading = false;
private listeners = new Set<() => void>();
constructor(private editor: EditorCore) {}
constructor(private editor: EditorCore) { }
async addMediaAsset({
projectId,
@ -45,7 +45,7 @@ export class MediaManager {
}): Promise<void> {
const asset = this.assets.find((asset) => asset.id === id);
videoCache.clearVideo(id);
videoCache.clearVideo({ mediaId: id });
if (asset?.url) {
URL.revokeObjectURL(asset.url);

View File

@ -4,8 +4,9 @@ import type {
TProjectMetadata,
TProjectSettings,
} from "@/types/project";
import type { ExportOptions, ExportResult } from "@/types/export";
import type { TimelineElement } from "@/types/timeline";
import { storageService } from "@/lib/storage/storage-service";
import { storageService } from "@/services/storage/storage-service";
import { toast } from "sonner";
import { generateUUID } from "@/lib/utils";
import { UpdateProjectSettingsCommand } from "@/lib/commands/project";
@ -15,12 +16,12 @@ import {
DEFAULT_COLOR,
} from "@/constants/project-constants";
import { buildDefaultScene } from "@/lib/scene-utils";
import { generateThumbnail } from "@/lib/media-processing-utils";
import { generateThumbnail } from "@/lib/media/processing";
import {
CURRENT_STORAGE_VERSION,
migrations,
runStorageMigrations,
} from "@/lib/migrations";
} from "@/services/storage/migrations";
export interface MigrationState {
isMigrating: boolean;
@ -44,7 +45,7 @@ export class ProjectManager {
projectName: null,
};
constructor(private editor: EditorCore) {}
constructor(private editor: EditorCore) { }
private async ensureStorageMigrations(): Promise<void> {
if (this.storageMigrationPromise) {
@ -97,6 +98,7 @@ export class ProjectManager {
settings: {
fps: DEFAULT_FPS,
canvasSize: DEFAULT_CANVAS_SIZE,
originalCanvasSize: null,
background: {
type: "color",
color: DEFAULT_COLOR,
@ -186,6 +188,10 @@ export class ProjectManager {
}
}
async export({ options }: { options: ExportOptions }): Promise<ExportResult> {
return this.editor.renderer.exportProject({ options });
}
async loadAllProjects(): Promise<void> {
if (!this.isInitialized) {
this.isLoading = true;
@ -474,6 +480,11 @@ export class ProjectManager {
return this.active;
}
/**
* for agents:
* in most cases, the project is guaranteed to be active, in which getActive() should be used instead.
* for very rare cases, this function may be used.
*/
getActiveOrNull(): TProject | null {
return this.active;
}

View File

@ -78,7 +78,7 @@ export class SaveManager {
if (this.isSaving) return;
if (!this.hasPendingSave) return;
const activeProject = this.editor.project.getActiveOrNull();
const activeProject = this.editor.project.getActive();
if (!activeProject) return;
if (this.editor.project.getIsLoading()) return;
if (this.editor.project.getMigrationState().isMigrating) return;

View File

@ -1,6 +1,6 @@
import type { EditorCore } from "@/core";
import type { TimelineTrack, TScene } from "@/types/timeline";
import { storageService } from "@/lib/storage/storage-service";
import { storageService } from "@/services/storage/storage-service";
import {
getMainScene,
ensureMainScene,
@ -10,7 +10,7 @@ import {
import {
getFrameTime,
isBookmarkAtTime,
} from "@/lib/timeline/bookmark-utils";
} from "@/lib/timeline/bookmarks";
import { ensureMainTrack } from "@/lib/timeline/track-utils";
import {
CreateSceneCommand,
@ -25,7 +25,7 @@ export class ScenesManager {
private list: TScene[] = [];
private listeners = new Set<() => void>();
constructor(private editor: EditorCore) {}
constructor(private editor: EditorCore) { }
async createScene({
name,

View File

@ -13,7 +13,7 @@ import {
RemoveTrackCommand,
ToggleTrackMuteCommand,
ToggleTrackVisibilityCommand,
AddElementToTrackCommand,
InsertElementCommand,
UpdateElementTrimCommand,
UpdateElementDurationCommand,
DeleteElementsCommand,
@ -26,11 +26,12 @@ import {
UpdateElementStartTimeCommand,
MoveElementCommand,
} from "@/lib/commands/timeline";
import { InsertElementParams } from "@/lib/commands/timeline/element/insert-element";
export class TimelineManager {
private listeners = new Set<() => void>();
constructor(private editor: EditorCore) {}
constructor(private editor: EditorCore) { }
addTrack({ type, index }: { type: TrackType; index?: number }): string {
const command = new AddTrackCommand(type, index);
@ -43,14 +44,11 @@ export class TimelineManager {
this.editor.command.execute({ command });
}
addElementToTrack({
trackId,
insertElement({
element,
}: {
trackId: string;
element: CreateTimelineElement;
}): void {
const command = new AddElementToTrackCommand(trackId, element);
placement,
}: InsertElementParams): void {
const command = new InsertElementCommand({ element, placement });
this.editor.command.execute({ command });
}

View File

@ -2,7 +2,7 @@
export const patternCraftGradients = [
// Dreamy Sky Pink Glow
"radial-gradient(circle at 30% 70%, rgba(173, 216, 230, 0.35), transparent 60%), radial-gradient(circle at 70% 30%, rgba(255, 182, 193, 0.4), transparent 60%)",
"radial-gradient(circle at 30% 70%, rgba(173, 216, 230, 0.35), transparent 60%), radial-gradient(circle at 70% 30%, rgba(255, 182, 193, 0.4), transparent 60%), white",
// Soft Warm Pastel Texture
"radial-gradient(circle at 20% 80%, rgba(255, 182, 153, 0.3) 0%, transparent 50%), radial-gradient(circle at 80% 20%, rgba(255, 244, 214, 0.5) 0%, transparent 50%), radial-gradient(circle at 40% 40%, rgba(255, 182, 153, 0.1) 0%, transparent 50%), #fff8f0",
@ -14,20 +14,56 @@ export const patternCraftGradients = [
"radial-gradient(circle at center, #8FFFB0, transparent), white",
// Purple Glow Right
"radial-gradient(circle at top right, rgba(173, 109, 244, 0.5), transparent 70%)",
"radial-gradient(circle at top right, rgba(173, 109, 244, 0.5), transparent 70%), white",
// Teal Glow Right
"radial-gradient(circle at top right, rgba(56, 193, 182, 0.5), transparent 70%)",
"radial-gradient(circle at top right, rgba(56, 193, 182, 0.5), transparent 70%), white",
// Warm Orange Glow Right
"radial-gradient(circle at top right, rgba(255, 140, 60, 0.5), transparent 70%)",
"radial-gradient(circle at top right, rgba(255, 140, 60, 0.5), transparent 70%), white",
// Cool Blue Glow Right
"radial-gradient(circle at top right, rgba(70, 130, 180, 0.5), transparent 70%)",
"radial-gradient(circle at top right, rgba(70, 130, 180, 0.5), transparent 70%), white",
// Purple Glow Left
"radial-gradient(circle at top left, rgba(173, 109, 244, 0.5), transparent 70%)",
"radial-gradient(circle at top left, rgba(173, 109, 244, 0.5), transparent 70%), white",
// Pastel Wave
"linear-gradient(120deg, #d5c5ff 0%, #a7f3d0 50%, #f0f0f0 100%)",
// Aurora Dream Corner Whispers
"radial-gradient(ellipse 85% 65% at 8% 8%, rgba(175, 109, 255, 0.42), transparent 60%), radial-gradient(ellipse 75% 60% at 75% 35%, rgba(255, 235, 170, 0.55), transparent 62%), radial-gradient(ellipse 70% 60% at 15% 80%, rgba(255, 100, 180, 0.40), transparent 62%), radial-gradient(ellipse 70% 60% at 92% 92%, rgba(120, 190, 255, 0.45), transparent 62%), linear-gradient(180deg, #f7eaff 0%, #fde2ea 100%)",
// Aurora Dream Vivid Bloom
"radial-gradient(ellipse 80% 60% at 70% 20%, rgba(175, 109, 255, 0.85), transparent 68%), radial-gradient(ellipse 70% 60% at 20% 80%, rgba(255, 100, 180, 0.75), transparent 68%), radial-gradient(ellipse 60% 50% at 60% 65%, rgba(255, 235, 170, 0.98), transparent 68%), radial-gradient(ellipse 65% 40% at 50% 60%, rgba(120, 190, 255, 0.3), transparent 68%), linear-gradient(180deg, #f7eaff 0%, #fde2ea 100%)",
// Soft Pastel Dream Gradient
"linear-gradient(135deg, #F8BBD9 0%, #FDD5B4 25%, #FFF2CC 50%, #E1F5FE 75%, #BBDEFB 100%)",
// Dreamy Sunset Gradient Background
"linear-gradient(180deg, rgba(245,245,220,1) 0%, rgba(255,223,186,0.8) 25%, rgba(255,182,193,0.6) 50%, rgba(147,112,219,0.7) 75%, rgba(72,61,139,0.9) 100%), radial-gradient(circle at 30% 20%, rgba(255,255,224,0.4) 0%, transparent 50%), radial-gradient(circle at 70% 80%, rgba(72,61,139,0.6) 0%, transparent 70%), radial-gradient(circle at 50% 60%, rgba(147,112,219,0.3) 0%, transparent 60%)",
// Cotton Candy Sky Gradient
"linear-gradient(45deg, #FFB3D9 0%, #FFD1DC 20%, #FFF0F5 40%, #E6F3FF 60%, #D1E7FF 80%, #C7E9F1 100%)",
// Rose Gold Whisper Gradient
"linear-gradient(270deg, #FFECB3 0%, #FFE0B2 20%, #FFCDD2 40%, #F8BBD9 60%, #E1BEE7 80%, #D1C4E9 100%)",
// Ember Glow Background
"radial-gradient(circle at 50% 100%, rgba(255, 69, 0, 0.6) 0%, transparent 60%), radial-gradient(circle at 50% 100%, rgba(255, 140, 0, 0.4) 0%, transparent 70%), radial-gradient(circle at 50% 100%, rgba(255, 215, 0, 0.3) 0%, transparent 80%)",
// Cosmic Aurora
"radial-gradient(ellipse at 20% 30%, rgba(56, 189, 248, 0.4) 0%, transparent 60%), radial-gradient(ellipse at 80% 70%, rgba(139, 92, 246, 0.3) 0%, transparent 70%), radial-gradient(ellipse at 60% 20%, rgba(236, 72, 153, 0.25) 0%, transparent 50%), radial-gradient(ellipse at 40% 80%, rgba(34, 197, 94, 0.2) 0%, transparent 65%), #000000",
// Deep Ocean Glow
"radial-gradient(70% 55% at 50% 50%, #2a5d77 0%, #184058 18%, #0f2a43 34%, #0a1b30 50%, #071226 66%, #040d1c 80%, #020814 92%, #01040d 97%, #000309 100%), radial-gradient(160% 130% at 10% 10%, rgba(0,0,0,0) 38%, #000309 76%, #000208 100%), radial-gradient(160% 130% at 90% 90%, rgba(0,0,0,0) 38%, #000309 76%, #000208 100%)",
// Crimson Core Glow
"linear-gradient(0deg, rgba(0,0,0,0.6), rgba(0,0,0,0.6)), radial-gradient(68% 58% at 50% 50%, #c81e3a 0%, #a51d35 16%, #7d1a2f 32%, #591828 46%, #3c1722 60%, #2a151d 72%, #1f1317 84%, #141013 94%, #0a0a0a 100%), radial-gradient(90% 75% at 50% 50%, rgba(228,42,66,0.06) 0%, rgba(228,42,66,0) 55%), radial-gradient(150% 120% at 8% 8%, rgba(0,0,0,0) 42%, #0b0a0a 82%, #070707 100%), radial-gradient(150% 120% at 92% 92%, rgba(0,0,0,0) 42%, #0b0a0a 82%, #070707 100%), radial-gradient(60% 50% at 50% 60%, rgba(240,60,80,0.06), rgba(0,0,0,0) 60%), #050505",
// Northern Aurora
"radial-gradient(ellipse 70% 55% at 50% 50%, rgba(255, 20, 147, 0.15), transparent 50%), radial-gradient(ellipse 160% 130% at 10% 10%, rgba(0, 255, 255, 0.12), transparent 60%), radial-gradient(ellipse 160% 130% at 90% 90%, rgba(138, 43, 226, 0.18), transparent 65%), radial-gradient(ellipse 110% 50% at 80% 30%, rgba(255, 215, 0, 0.08), transparent 40%), #000000",
// Royal Purple Background
"radial-gradient(circle at 50% 50%, rgba(147, 51, 234, 0.2) 0%, rgba(147, 51, 234, 0.12) 25%, rgba(147, 51, 234, 0.05) 35%, transparent 50%), #000000",
];

View File

@ -3,8 +3,6 @@
import { useTimelineStore } from "@/stores/timeline-store";
import { useActionHandler } from "@/hooks/actions/use-action-handler";
import { useEditor } from "../use-editor";
import { PasteCommand } from "@/lib/commands/timeline/clipboard/paste";
import { toast } from "sonner";
import { useElementSelection } from "../timeline/element/use-element-selection";
export function useEditorActions() {
@ -126,14 +124,10 @@ export function useEditorActions() {
useActionHandler(
"split-selected",
() => {
const splitElementIds = editor.timeline.splitElements({
editor.timeline.splitElements({
elements: selectedElements,
splitTime: editor.playback.getCurrentTime(),
});
if (splitElementIds.length === 0) {
toast.error("Playhead must be positioned over the selected element(s)");
}
},
undefined,
);
@ -141,15 +135,11 @@ export function useEditorActions() {
useActionHandler(
"split-selected-left",
() => {
const splitElementIds = editor.timeline.splitElements({
editor.timeline.splitElements({
elements: selectedElements,
splitTime: editor.playback.getCurrentTime(),
retainSide: "left",
});
if (splitElementIds.length === 0) {
toast.error("Playhead must be positioned over the selected element(s)");
}
},
undefined,
);
@ -157,15 +147,11 @@ export function useEditorActions() {
useActionHandler(
"split-selected-right",
() => {
const splitElementIds = editor.timeline.splitElements({
editor.timeline.splitElements({
elements: selectedElements,
splitTime: editor.playback.getCurrentTime(),
retainSide: "right",
});
if (splitElementIds.length === 0) {
toast.error("Playhead must be positioned over the selected element(s)");
}
},
undefined,
);
@ -240,6 +226,7 @@ export function useEditorActions() {
const items = results.map(({ track, element }) => {
const { id, ...elementWithoutId } = element;
return {
trackId: track.id,
trackType: track.type,
element: elementWithoutId,
};
@ -255,9 +242,9 @@ export function useEditorActions() {
() => {
if (!clipboard?.items.length) return;
const currentTime = editor.playback.getCurrentTime();
editor.command.execute({
command: new PasteCommand(currentTime, clipboard.items),
editor.timeline.pasteAtTime({
time: editor.playback.getCurrentTime(),
clipboardItems: clipboard.items,
});
},
undefined,

View File

@ -12,6 +12,7 @@ import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { snapTimeToFrame } from "@/lib/time-utils";
import { computeDropTarget } from "@/lib/timeline/drop-utils";
import { generateUUID } from "@/lib/utils";
import { useTimelineSnapping } from "@/hooks/timeline/use-timeline-snapping";
import type {
DropTarget,
ElementDragState,
@ -27,6 +28,7 @@ interface UseElementInteractionProps {
timelineRef: RefObject<HTMLDivElement | null>;
tracksContainerRef: RefObject<HTMLDivElement | null>;
tracksScrollRef: RefObject<HTMLDivElement | null>;
snappingEnabled: boolean;
onSnapPointChange?: (snapPoint: SnapPoint | null) => void;
}
@ -82,8 +84,16 @@ function getClickOffsetTime({
return clickOffsetX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel);
}
function getElementDuration({ element }: { element: TimelineElement }): number {
return element.duration - element.trimStart - element.trimEnd;
function getVerticalDragDirection({
startMouseY,
currentMouseY,
}: {
startMouseY: number;
currentMouseY: number;
}): "up" | "down" | null {
if (currentMouseY < startMouseY) return "up";
if (currentMouseY > startMouseY) return "down";
return null;
}
function getDragDropTarget({
@ -96,6 +106,7 @@ function getDragDropTarget({
tracksScrollRef,
zoomLevel,
snappedTime,
verticalDragDirection,
}: {
clientX: number;
clientY: number;
@ -106,6 +117,7 @@ function getDragDropTarget({
tracksScrollRef: RefObject<HTMLDivElement | null>;
zoomLevel: number;
snappedTime: number;
verticalDragDirection?: "up" | "down" | null;
}): DropTarget | null {
const containerRect = tracksContainerRef.current?.getBoundingClientRect();
const scrollContainer = tracksScrollRef.current;
@ -115,7 +127,7 @@ function getDragDropTarget({
const movingElement = sourceTrack?.elements.find(({ id }) => id === elementId);
if (!movingElement) return null;
const elementDuration = getElementDuration({ element: movingElement });
const elementDuration = movingElement.duration;
const scrollLeft = scrollContainer.scrollLeft;
const scrollContainerRect = scrollContainer.getBoundingClientRect();
const mouseX = clientX - scrollContainerRect.left + scrollLeft;
@ -133,6 +145,7 @@ function getDragDropTarget({
zoomLevel,
startTimeOverride: snappedTime,
excludeElementId: movingElement.id,
verticalDragDirection,
});
}
@ -147,10 +160,12 @@ export function useElementInteraction({
timelineRef,
tracksContainerRef,
tracksScrollRef,
snappingEnabled,
onSnapPointChange,
}: UseElementInteractionProps) {
const editor = useEditor();
const tracks = editor.timeline.getTracks();
const { snapElementEdge } = useTimelineSnapping();
const {
isElementSelected,
selectElement,
@ -196,6 +211,55 @@ export function useElementInteraction({
setDragDropTarget(null);
}, []);
const getDragSnapResult = useCallback(
({
frameSnappedTime,
movingElement,
}: {
frameSnappedTime: number;
movingElement: TimelineElement | null | undefined;
}) => {
if (!snappingEnabled || !movingElement) {
return { snappedTime: frameSnappedTime, snapPoint: null };
}
const elementDuration = movingElement.duration;
const playheadTime = editor.playback.getCurrentTime();
const startSnap = snapElementEdge({
targetTime: frameSnappedTime,
elementDuration,
tracks,
playheadTime,
zoomLevel,
excludeElementId: movingElement.id,
snapToStart: true,
});
const endSnap = snapElementEdge({
targetTime: frameSnappedTime,
elementDuration,
tracks,
playheadTime,
zoomLevel,
excludeElementId: movingElement.id,
snapToStart: false,
});
const snapResult =
startSnap.snapDistance <= endSnap.snapDistance ? startSnap : endSnap;
if (!snapResult.snapPoint) {
return { snappedTime: frameSnappedTime, snapPoint: null };
}
return {
snappedTime: snapResult.snappedTime,
snapPoint: snapResult.snapPoint,
};
},
[snappingEnabled, editor.playback, snapElementEdge, tracks, zoomLevel],
);
useEffect(() => {
if (!dragState.isDragging && !isPendingDrag) return;
@ -269,14 +333,28 @@ export function useElementInteraction({
});
const adjustedTime = Math.max(0, mouseTime - dragState.clickOffsetTime);
const fps = activeProject.settings.fps;
const snappedTime = snapTimeToFrame({ time: adjustedTime, fps });
const frameSnappedTime = snapTimeToFrame({ time: adjustedTime, fps });
const sourceTrack = tracks.find(({ id }) => id === dragState.trackId);
const movingElement = sourceTrack?.elements.find(
({ id }) => id === dragState.elementId,
);
const { snappedTime, snapPoint } = getDragSnapResult({
frameSnappedTime,
movingElement,
});
setDragState((previousDragState) => ({
...previousDragState,
currentTime: snappedTime,
currentMouseY: clientY,
}));
onSnapPointChange?.(snapPoint);
if (dragState.elementId && dragState.trackId) {
const verticalDragDirection = getVerticalDragDirection({
startMouseY: dragState.startMouseY,
currentMouseY: clientY,
});
const dropTarget = getDragDropTarget({
clientX,
clientY,
@ -287,6 +365,7 @@ export function useElementInteraction({
tracksScrollRef,
zoomLevel,
snappedTime,
verticalDragDirection,
});
setDragDropTarget(dropTarget?.isNewTrack ? dropTarget : null);
}
@ -303,12 +382,15 @@ export function useElementInteraction({
isElementSelected,
selectElement,
editor.project,
editor.playback,
timelineRef,
tracksScrollRef,
tracksContainerRef,
tracks,
isPendingDrag,
startDrag,
getDragSnapResult,
onSnapPointChange,
]);
useEffect(() => {
@ -338,6 +420,10 @@ export function useElementInteraction({
tracksScrollRef,
zoomLevel,
snappedTime: dragState.currentTime,
verticalDragDirection: getVerticalDragDirection({
startMouseY: dragState.startMouseY,
currentMouseY: clientY,
}),
});
if (!dropTarget) {
endDrag();

View File

@ -2,6 +2,11 @@ import { useState, useEffect, useRef } from "react";
import { TimelineElement, TimelineTrack } from "@/types/timeline";
import { snapTimeToFrame } from "@/lib/time-utils";
import { EditorCore } from "@/core";
import {
useTimelineSnapping,
type SnapPoint,
} from "@/hooks/timeline/use-timeline-snapping";
import { useTimelineStore } from "@/stores/timeline-store";
export interface ResizeState {
elementId: string;
@ -17,15 +22,21 @@ interface UseTimelineElementResizeProps {
element: TimelineElement;
track: TimelineTrack;
zoomLevel: number;
onSnapPointChange?: (snapPoint: SnapPoint | null) => void;
onResizeStateChange?: (params: { isResizing: boolean }) => void;
}
export function useTimelineElementResize({
element,
track,
zoomLevel,
onSnapPointChange,
onResizeStateChange,
}: UseTimelineElementResizeProps) {
const editor = EditorCore.getInstance();
const activeProject = editor.project.getActive();
const snappingEnabled = useTimelineStore((state) => state.snappingEnabled);
const { findSnapPoints, snapToNearestPoint } = useTimelineSnapping();
const [resizing, setResizing] = useState<ResizeState | null>(null);
const [currentTrimStart, setCurrentTrimStart] = useState(element.trimStart);
@ -87,6 +98,7 @@ export function useTimelineElementResize({
currentTrimEndRef.current = element.trimEnd;
currentStartTimeRef.current = element.startTime;
currentDurationRef.current = element.duration;
onResizeStateChange?.({ isResizing: true });
};
const canExtendElementDuration = () => {
@ -101,16 +113,55 @@ export function useTimelineElementResize({
if (!resizing) return;
const deltaX = clientX - resizing.startX;
const deltaTime = deltaX / (50 * zoomLevel);
let deltaTime = deltaX / (50 * zoomLevel);
let resizeSnapPoint: SnapPoint | null = null;
const projectFps = activeProject.settings.fps;
const minDurationSeconds = 1 / projectFps;
const canSnap = snappingEnabled;
if (canSnap) {
const tracks = editor.timeline.getTracks();
const playheadTime = editor.playback.getCurrentTime();
const snapPoints = findSnapPoints({
tracks,
playheadTime,
excludeElementId: element.id,
});
if (resizing.side === "left") {
const targetStartTime = resizing.initialStartTime + deltaTime;
const snapResult = snapToNearestPoint({
targetTime: targetStartTime,
snapPoints,
zoomLevel,
});
resizeSnapPoint = snapResult.snapPoint;
if (snapResult.snapPoint) {
deltaTime = snapResult.snappedTime - resizing.initialStartTime;
}
} else {
const baseEndTime =
resizing.initialStartTime + resizing.initialDuration;
const targetEndTime = baseEndTime + deltaTime;
const snapResult = snapToNearestPoint({
targetTime: targetEndTime,
snapPoints,
zoomLevel,
});
resizeSnapPoint = snapResult.snapPoint;
if (snapResult.snapPoint) {
deltaTime = snapResult.snappedTime - baseEndTime;
}
}
}
onSnapPointChange?.(resizeSnapPoint);
if (resizing.side === "left") {
const sourceDuration =
resizing.initialTrimStart +
resizing.initialDuration +
resizing.initialTrimEnd;
const maxAllowed = sourceDuration - resizing.initialTrimEnd - 0.1;
const maxAllowed =
sourceDuration - resizing.initialTrimEnd - minDurationSeconds;
const calculated = resizing.initialTrimStart + deltaTime;
if (calculated >= 0 && calculated <= maxAllowed) {
@ -183,8 +234,9 @@ export function useTimelineElementResize({
if (newTrimEnd < 0) {
if (canExtendElementDuration()) {
const extensionNeeded = Math.abs(newTrimEnd);
const baseDuration = resizing.initialDuration + resizing.initialTrimEnd;
const newDuration = snapTimeToFrame({
time: resizing.initialDuration + extensionNeeded,
time: baseDuration + extensionNeeded,
fps: projectFps,
});
@ -205,7 +257,8 @@ export function useTimelineElementResize({
currentTrimEndRef.current = 0;
}
} else {
const maxTrimEnd = sourceDuration - resizing.initialTrimStart - 0.1;
const maxTrimEnd =
sourceDuration - resizing.initialTrimStart - minDurationSeconds;
const clampedTrimEnd = Math.min(maxTrimEnd, Math.max(0, newTrimEnd));
const finalTrimEnd = snapTimeToFrame({
time: clampedTrimEnd,
@ -261,6 +314,8 @@ export function useTimelineElementResize({
}
setResizing(null);
onResizeStateChange?.({ isResizing: false });
onSnapPointChange?.(null);
};
return {

View File

@ -208,7 +208,7 @@ export function useSelectionBox({
}, [selectionBox, selectElementsInBox]);
useEffect(() => {
if (!selectionBox?.isActive) return;
if (!selectionBox) return;
const previousBodyUserSelect = document.body.style.userSelect;
const container = containerRef.current;
@ -221,7 +221,7 @@ export function useSelectionBox({
document.body.style.userSelect = previousBodyUserSelect;
if (container) container.style.userSelect = previousContainerUserSelect;
};
}, [selectionBox?.isActive, containerRef]);
}, [selectionBox, containerRef]);
return {
selectionBox,

View File

@ -1,6 +1,6 @@
import { useState, useCallback, type RefObject } from "react";
import { useEditor } from "@/hooks/use-editor";
import { processMediaAssets } from "@/lib/media-processing-utils";
import { processMediaAssets } from "@/lib/media/processing";
import { toast } from "sonner";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { snapTimeToFrame } from "@/lib/time-utils";
@ -98,9 +98,10 @@ export function useTimelineDragDrop({
let elementType = getElementType({ dataTransfer: e.dataTransfer });
// external drops default to video until determined on drop
if (!elementType && hasFiles) {
elementType = "video";
if (!elementType && hasFiles && isExternal) {
setDropTarget(null);
setElementType(null);
return;
}
if (!elementType) return;
@ -194,7 +195,10 @@ export function useTimelineDragDrop({
startTime: target.xPosition,
});
editor.timeline.addElementToTrack({ trackId, element });
editor.timeline.insertElement({
placement: { mode: "explicit", trackId },
element,
});
},
[editor.timeline, tracks],
);
@ -225,7 +229,10 @@ export function useTimelineDragDrop({
startTime: target.xPosition,
});
editor.timeline.addElementToTrack({ trackId, element });
editor.timeline.insertElement({
placement: { mode: "explicit", trackId },
element,
});
},
[editor.timeline, tracks],
);
@ -254,8 +261,8 @@ export function useTimelineDragDrop({
mediaAsset.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
if (dragData.mediaType === "audio") {
editor.timeline.addElementToTrack({
trackId,
editor.timeline.insertElement({
placement: { mode: "explicit", trackId },
element: {
type: "audio",
sourceType: "upload",
@ -266,13 +273,12 @@ export function useTimelineDragDrop({
trimStart: 0,
trimEnd: 0,
volume: 1,
buffer: new AudioBuffer({ length: 1, sampleRate: 44100 }),
muted: false,
},
});
} else if (dragData.mediaType === "video") {
editor.timeline.addElementToTrack({
trackId,
editor.timeline.insertElement({
placement: { mode: "explicit", trackId },
element: {
type: "video",
mediaId: mediaAsset.id,
@ -293,8 +299,8 @@ export function useTimelineDragDrop({
},
});
} else {
editor.timeline.addElementToTrack({
trackId,
editor.timeline.insertElement({
placement: { mode: "explicit", trackId },
element: {
type: "image",
mediaId: mediaAsset.id,
@ -320,7 +326,15 @@ export function useTimelineDragDrop({
);
const executeFileDrop = useCallback(
async ({ files }: { files: File[] }) => {
async ({
files,
mouseX,
mouseY,
}: {
files: File[];
mouseX: number;
mouseY: number;
}) => {
if (!activeProject) return;
const processedAssets = await processMediaAssets({ files });
@ -336,26 +350,42 @@ export function useTimelineDragDrop({
.find((m) => m.name === asset.name && m.url === asset.url);
if (added) {
const trackType: TrackType =
added.type === "audio" ? "audio" : "video";
const trackId = editor.timeline.addTrack({
type: trackType,
index: 0,
});
const duration =
added.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
const currentTracks = editor.timeline.getTracks();
const dropTarget = computeDropTarget({
elementType: added.type,
mouseX,
mouseY,
tracks: currentTracks,
playheadTime: currentTime,
isExternalDrop: true,
elementDuration: duration,
pixelsPerSecond: TIMELINE_CONSTANTS.PIXELS_PER_SECOND,
zoomLevel,
});
const trackType: TrackType =
added.type === "audio" ? "audio" : "video";
const trackId = dropTarget.isNewTrack
? editor.timeline.addTrack({
type: trackType,
index: dropTarget.trackIndex,
})
: currentTracks[dropTarget.trackIndex]?.id;
if (!trackId) return;
if (added.type === "audio") {
editor.timeline.addElementToTrack({
trackId,
editor.timeline.insertElement({
placement: { mode: "explicit", trackId },
element: {
type: "audio",
sourceType: "upload",
mediaId: added.id,
name: added.name,
duration,
startTime: currentTime,
startTime: dropTarget.xPosition,
trimStart: 0,
trimEnd: 0,
volume: 1,
@ -364,14 +394,14 @@ export function useTimelineDragDrop({
},
});
} else if (added.type === "video") {
editor.timeline.addElementToTrack({
trackId,
editor.timeline.insertElement({
placement: { mode: "explicit", trackId },
element: {
type: "video",
mediaId: added.id,
name: added.name,
duration,
startTime: currentTime,
startTime: dropTarget.xPosition,
trimStart: 0,
trimEnd: 0,
transform: {
@ -386,14 +416,14 @@ export function useTimelineDragDrop({
},
});
} else {
editor.timeline.addElementToTrack({
trackId,
editor.timeline.insertElement({
placement: { mode: "explicit", trackId },
element: {
type: "image",
mediaId: added.id,
name: added.name,
duration,
startTime: currentTime,
startTime: dropTarget.xPosition,
trimStart: 0,
trimEnd: 0,
transform: {
@ -411,27 +441,32 @@ export function useTimelineDragDrop({
}
}
},
[activeProject, editor.media, editor.timeline, currentTime],
[
activeProject,
editor.media,
editor.timeline,
currentTime,
zoomLevel,
],
);
const handleDrop = useCallback(
async (e: React.DragEvent) => {
e.preventDefault();
const currentTarget = dropTarget;
setIsDragOver(false);
setDropTarget(null);
setElementType(null);
if (!currentTarget) return;
const hasAsset = hasDragData({ dataTransfer: e.dataTransfer });
const hasFiles = e.dataTransfer.files?.length > 0;
if (!hasAsset && !hasFiles) return;
const currentTarget = dropTarget;
setIsDragOver(false);
setDropTarget(null);
setElementType(null);
try {
if (hasAsset) {
if (!currentTarget) return;
const dragData = getDragData({ dataTransfer: e.dataTransfer });
if (!dragData) return;
@ -443,7 +478,15 @@ export function useTimelineDragDrop({
executeMediaDrop({ target: currentTarget, dragData });
}
} else if (hasFiles) {
await executeFileDrop({ files: Array.from(e.dataTransfer.files) });
const rect = containerRef.current?.getBoundingClientRect();
if (!rect) return;
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
await executeFileDrop({
files: Array.from(e.dataTransfer.files),
mouseX,
mouseY,
});
}
} catch (err) {
console.error("Failed to process drop:", err);

View File

@ -89,20 +89,18 @@ export function useTimelineInteractions({
({ event }: { event: React.MouseEvent }) => {
const target = event.target as HTMLElement;
const { isMouseDown, downX, downY, downTime } = mouseTrackingRef.current;
if (!isMouseDown) return false;
const deltaX = Math.abs(event.clientX - downX);
const deltaY = Math.abs(event.clientY - downY);
const deltaTime = event.timeStamp - downTime;
const isPlayhead = !!playheadRef.current?.contains(target);
const isTrackLabels = !!trackLabelsRef.current?.contains(target);
const shouldBlockForDrag = deltaX > 5 || deltaY > 5 || deltaTime > 500;
if (deltaX > 5 || deltaY > 5 || deltaTime > 500) return false;
if (!isMouseDown) return false;
if (shouldBlockForDrag) return false;
if (isSelecting) return false;
if (playheadRef.current?.contains(target)) return false;
if (trackLabelsRef.current?.contains(target)) {
if (isPlayhead) return false;
if (isTrackLabels) {
clearSelectedElements();
return false;
}
@ -134,7 +132,7 @@ export function useTimelineInteractions({
Math.min(
duration,
(mouseX + scrollLeft) /
(TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel),
(TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel),
),
);
@ -158,9 +156,10 @@ export function useTimelineInteractions({
const handleTracksClick = useCallback(
(event: React.MouseEvent) => {
const shouldProcess = shouldProcessTimelineClick({ event });
resetMouseTracking({ mouseTrackingRef });
if (shouldProcessTimelineClick({ event })) {
if (shouldProcess) {
clearSelectedElements();
handleTimelineSeek({ event, source: "tracks" });
}
@ -170,9 +169,10 @@ export function useTimelineInteractions({
const handleRulerClick = useCallback(
(event: React.MouseEvent) => {
const shouldProcess = shouldProcessTimelineClick({ event });
resetMouseTracking({ mouseTrackingRef });
if (shouldProcessTimelineClick({ event })) {
if (shouldProcess) {
clearSelectedElements();
handleTimelineSeek({ event, source: "ruler" });
}

View File

@ -10,6 +10,7 @@ import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
interface UseTimelineZoomProps {
containerRef: RefObject<HTMLDivElement>;
isInTimeline?: boolean;
minZoom?: number;
}
interface UseTimelineZoomReturn {
@ -21,6 +22,7 @@ interface UseTimelineZoomReturn {
export function useTimelineZoom({
containerRef,
isInTimeline = false,
minZoom = TIMELINE_CONSTANTS.ZOOM_MIN,
}: UseTimelineZoomProps): UseTimelineZoomReturn {
const [zoomLevel, setZoomLevel] = useState(1);
@ -38,7 +40,7 @@ export function useTimelineZoom({
const zoomMultiplier = event.deltaY > 0 ? 1 / 1.1 : 1.1;
setZoomLevel((prev) => {
const nextZoom = Math.max(
TIMELINE_CONSTANTS.ZOOM_MIN,
minZoom,
Math.min(TIMELINE_CONSTANTS.ZOOM_MAX, prev * zoomMultiplier),
);
return nextZoom;
@ -47,7 +49,11 @@ export function useTimelineZoom({
// let the event bubble up to allow ScrollArea to handle it
return;
}
}, []);
}, [minZoom]);
useEffect(() => {
setZoomLevel((prev) => (prev < minZoom ? minZoom : prev));
}, [minZoom]);
// prevent browser zoom in the timeline
useEffect(() => {

View File

@ -12,7 +12,7 @@ import { mediaSupportsAudio } from "@/lib/media-utils";
export type CollectedAudioElement = Omit<
AudioElement,
"type" | "mediaId" | "volume" | "id" | "name" | "sourceType" | "sourceUrl"
>;
> & { buffer: AudioBuffer };
export function createAudioContext(): AudioContext {
const AudioContextConstructor =
@ -32,10 +32,10 @@ export async function collectAudioElements({
mediaAssets: MediaAsset[];
audioContext: AudioContext;
}): Promise<CollectedAudioElement[]> {
const audioElements: CollectedAudioElement[] = [];
const mediaMap = new Map<string, MediaAsset>(
mediaAssets.map((media) => [media.id, media]),
);
const pendingElements: Array<Promise<CollectedAudioElement | null>> = [];
for (const track of tracks) {
if (canTracktHaveAudio(track) && track.muted) continue;
@ -44,39 +44,68 @@ export async function collectAudioElements({
if (element.type !== "audio") continue;
if (element.duration <= 0) continue;
try {
let audioBuffer: AudioBuffer;
if (element.sourceType === "upload") {
const asset = mediaMap.get(element.mediaId);
if (!asset || asset.type !== "audio") continue;
const arrayBuffer = await asset.file.arrayBuffer();
audioBuffer = await audioContext.decodeAudioData(
arrayBuffer.slice(0),
);
} else {
// library audio - already has decoded buffer
audioBuffer = element.buffer;
}
audioElements.push({
buffer: audioBuffer,
startTime: element.startTime,
duration: element.duration,
trimStart: element.trimStart,
trimEnd: element.trimEnd,
muted: element.muted || (canTracktHaveAudio(track) && track.muted),
});
} catch (error) {
console.warn("Failed to decode audio:", error);
}
const isTrackMuted = canTracktHaveAudio(track) && track.muted;
pendingElements.push(
resolveAudioBufferForElement({
element,
mediaMap,
audioContext,
}).then((audioBuffer) => {
if (!audioBuffer) return null;
return {
buffer: audioBuffer,
startTime: element.startTime,
duration: element.duration,
trimStart: element.trimStart,
trimEnd: element.trimEnd,
muted: element.muted || isTrackMuted,
};
}),
);
}
}
const resolvedElements = await Promise.all(pendingElements);
const audioElements: CollectedAudioElement[] = [];
for (const element of resolvedElements) {
if (element) audioElements.push(element);
}
return audioElements;
}
async function resolveAudioBufferForElement({
element,
mediaMap,
audioContext,
}: {
element: AudioElement;
mediaMap: Map<string, MediaAsset>;
audioContext: AudioContext;
}): Promise<AudioBuffer | null> {
try {
if (element.sourceType === "upload") {
const asset = mediaMap.get(element.mediaId);
if (!asset || asset.type !== "audio") return null;
const arrayBuffer = await asset.file.arrayBuffer();
return await audioContext.decodeAudioData(arrayBuffer.slice(0));
}
if (element.buffer) return element.buffer;
const response = await fetch(element.sourceUrl);
if (!response.ok) {
throw new Error(`Library audio fetch failed: ${response.status}`);
}
const arrayBuffer = await response.arrayBuffer();
return await audioContext.decodeAudioData(arrayBuffer.slice(0));
} catch (error) {
console.warn("Failed to decode audio:", error);
return null;
}
}
interface AudioMixSource {
file: File;
startTime: number;
@ -233,10 +262,7 @@ function mixAudioChannels({
outputLength,
sampleRate,
}: {
element: Omit<
AudioElement,
"type" | "mediaId" | "volume" | "id" | "name" | "sourceType" | "sourceUrl"
>;
element: CollectedAudioElement;
outputBuffer: AudioBuffer;
outputLength: number;
sampleRate: number;

View File

@ -1,264 +0,0 @@
interface ColorStop {
color: string;
position?: number;
}
interface LinearGradientConfig {
x0: number;
y0: number;
x1: number;
y1: number;
colors: string[];
}
interface RadialGradientConfig {
cx: number;
cy: number;
r: number;
colors: string[];
}
function splitBackgroundLayers({ input }: { input: string }): string[] {
const layers: string[] = [];
let depth = 0;
let start = 0;
for (const [index, char] of [...input].entries()) {
if (char === "(") {
depth += 1;
} else if (char === ")") {
depth -= 1;
} else if (char === "," && depth === 0) {
layers.push(input.slice(start, index).trim());
start = index + 1;
}
}
layers.push(input.slice(start).trim());
return layers;
}
function parseColorStop({ stop }: { stop: string }): ColorStop {
const trimmed = stop.trim();
const colorFunctions = ["rgba(", "rgb(", "hsla(", "hsl("];
let color = "";
let remaining = "";
for (const fn of colorFunctions) {
if (trimmed.startsWith(fn)) {
let depth = 0;
for (const [index, char] of [...trimmed].entries()) {
if (char === "(") {
depth += 1;
} else if (char === ")") {
depth -= 1;
if (depth === 0) {
color = trimmed.slice(0, index + 1);
remaining = trimmed.slice(index + 1).trim();
break;
}
}
}
break;
}
}
if (!color) {
const parts = trimmed.split(/\s+/);
color = parts[0] ?? "";
remaining = parts.slice(1).join(" ");
}
if (color === "transparent") {
color = "rgba(255, 255, 255, 0)";
}
const posMatch = remaining.match(/(\d+(?:\.\d+)?)%/);
const position = posMatch ? parseFloat(posMatch[1]) / 100 : undefined;
return { color, position };
}
function parseLinearGradient({
layer,
width,
height,
}: {
layer: string;
width: number;
height: number;
}): LinearGradientConfig {
const inside = layer.slice(layer.indexOf("(") + 1, layer.lastIndexOf(")"));
const parts = splitBackgroundLayers({ input: inside });
const dir = (parts.shift() ?? "").trim();
let x0 = 0;
let y0 = 0;
let x1 = width;
let y1 = 0;
if (dir.endsWith("deg")) {
const deg = parseFloat(dir);
const rad = (deg * Math.PI) / 180;
const cx = width / 2;
const cy = height / 2;
const r = Math.hypot(width, height) / 2;
x0 = cx - Math.cos(rad) * r;
y0 = cy - Math.sin(rad) * r;
x1 = cx + Math.cos(rad) * r;
y1 = cy + Math.sin(rad) * r;
} else if (dir.startsWith("to ")) {
const direction = dir.slice(3).trim();
const directionMap = {
right: { x0: 0, y0: 0, x1: width, y1: 0 },
left: { x0: width, y0: 0, x1: 0, y1: 0 },
bottom: { x0: 0, y0: 0, x1: 0, y1: height },
top: { x0: 0, y0: height, x1: 0, y1: 0 },
};
const mapped = directionMap[direction as keyof typeof directionMap];
if (mapped) {
x0 = mapped.x0;
y0 = mapped.y0;
x1 = mapped.x1;
y1 = mapped.y1;
}
} else {
parts.unshift(dir);
}
return { x0, y0, x1, y1, colors: parts };
}
function parseRadialGradient({
layer,
width,
height,
}: {
layer: string;
width: number;
height: number;
}): RadialGradientConfig {
const inside = layer.slice(layer.indexOf("(") + 1, layer.lastIndexOf(")"));
const parts = splitBackgroundLayers({ input: inside });
const first = (parts.shift() ?? "").trim();
let cx = width / 2;
let cy = height / 2;
if (first.startsWith("circle at")) {
const pos = first.replace("circle at", "").trim();
const coords = pos.split(/\s+/);
for (const [index, coord] of coords.entries()) {
if (coord.endsWith("%")) {
const val = parseFloat(coord) / 100;
if (index === 0) cx = val * width;
else if (index === 1) cy = val * height;
} else if (coord === "left") {
cx = 0;
} else if (coord === "right") {
cx = width;
} else if (coord === "top") {
cy = 0;
} else if (coord === "bottom") {
cy = height;
} else if (coord === "center") {
if (index === 0) cx = width / 2;
else if (index === 1) cy = height / 2;
}
}
} else {
parts.unshift(first);
}
const r = Math.max(
Math.hypot(cx, cy),
Math.hypot(width - cx, cy),
Math.hypot(cx, height - cy),
Math.hypot(width - cx, height - cy),
);
return { cx, cy, r, colors: parts };
}
function createGradientStops({
colors,
ctx,
createGradient,
}: {
colors: string[];
ctx: CanvasRenderingContext2D;
createGradient: () => CanvasGradient;
}): void {
const gradient = createGradient();
const colorStops = colors.map((color) =>
parseColorStop({ stop: color }),
);
for (const [index, stop] of colorStops.entries()) {
const position =
stop.position ?? index / Math.max(1, colorStops.length - 1);
gradient.addColorStop(Math.max(0, Math.min(1, position)), stop.color);
}
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height);
}
export function drawCssBackground({
ctx,
width,
height,
css,
}: {
ctx: CanvasRenderingContext2D;
width: number;
height: number;
css: string;
}): void {
const layers = splitBackgroundLayers({ input: css }).filter(Boolean);
for (let i = layers.length - 1; i >= 0; i -= 1) {
const layer = layers[i] ?? "";
if (layer.startsWith("linear-gradient(")) {
const { x0, y0, x1, y1, colors } = parseLinearGradient({
layer,
width,
height,
});
createGradientStops({
colors,
ctx,
createGradient: () => ctx.createLinearGradient(x0, y0, x1, y1),
});
} else if (layer.startsWith("radial-gradient(")) {
const { cx, cy, r, colors } = parseRadialGradient({
layer,
width,
height,
});
createGradientStops({
colors,
ctx,
createGradient: () => ctx.createRadialGradient(cx, cy, 0, cx, cy, r),
});
} else if (
layer.startsWith("#") ||
layer.startsWith("rgb") ||
layer.startsWith("hsl") ||
layer === "transparent" ||
layer === "white" ||
layer === "black"
) {
if (layer !== "transparent") {
ctx.fillStyle = layer;
ctx.fillRect(0, 0, width, height);
}
}
}
}

View File

@ -2,7 +2,7 @@ import { Command } from "@/lib/commands/base-command";
import { EditorCore } from "@/core";
import type { MediaAsset } from "@/types/assets";
import { generateUUID } from "@/lib/utils";
import { storageService } from "@/lib/storage/storage-service";
import { storageService } from "@/services/storage/storage-service";
export class AddMediaAssetCommand extends Command {
private assetId: string;

View File

@ -1,8 +1,8 @@
import { Command } from "@/lib/commands/base-command";
import { EditorCore } from "@/core";
import type { MediaAsset } from "@/types/assets";
import { storageService } from "@/lib/storage/storage-service";
import { videoCache } from "@/lib/video-cache";
import { storageService } from "@/services/storage/storage-service";
import { videoCache } from "@/services/media/video-cache";
import { hasMediaId } from "@/lib/timeline/element-utils";
import type { TimelineTrack } from "@/types/timeline";
@ -33,7 +33,7 @@ export class RemoveMediaAssetCommand extends Command {
return;
}
videoCache.clearVideo(this.assetId);
videoCache.clearVideo({ mediaId: this.assetId });
editor.media.setAssets({
assets: assets.filter((media) => media.id !== this.assetId),

View File

@ -12,7 +12,7 @@ export class UpdateProjectSettingsCommand extends Command {
execute(): void {
const editor = EditorCore.getInstance();
const activeProject = editor.project.getActiveOrNull();
const activeProject = editor.project.getActive();
if (!activeProject) return;
this.savedSettings = activeProject.settings;
@ -31,7 +31,7 @@ export class UpdateProjectSettingsCommand extends Command {
undo(): void {
if (!this.savedSettings || !this.savedUpdatedAt) return;
const editor = EditorCore.getInstance();
const activeProject = editor.project.getActiveOrNull();
const activeProject = editor.project.getActive();
if (!activeProject) return;
const updatedProject: TProject = {

View File

@ -5,7 +5,7 @@ import { updateSceneInArray } from "@/lib/scene-utils";
import {
getFrameTime,
removeBookmarkFromArray,
} from "@/lib/timeline/bookmark-utils";
} from "@/lib/timeline/bookmarks";
export class RemoveBookmarkCommand extends Command {
private savedScenes: TScene[] | null = null;

View File

@ -5,7 +5,7 @@ import { updateSceneInArray } from "@/lib/scene-utils";
import {
getFrameTime,
toggleBookmarkInArray,
} from "@/lib/timeline/bookmark-utils";
} from "@/lib/timeline/bookmarks";
export class ToggleBookmarkCommand extends Command {
private savedScenes: TScene[] | null = null;

View File

@ -6,6 +6,11 @@ import type {
ClipboardItem,
} from "@/types/timeline";
import { generateUUID } from "@/lib/utils";
import { wouldElementOverlap } from "@/lib/timeline/element-utils";
import {
buildEmptyTrack,
getHighestInsertIndexForTrack,
} from "@/lib/timeline/track-utils";
export class PasteCommand extends Command {
private savedState: TimelineTrack[] | null = null;
@ -29,31 +34,54 @@ export class PasteCommand extends Command {
...this.clipboardItems.map((item) => item.element.startTime),
);
const updatedTracks = this.savedState.map((track) => {
const elementsToAdd: TimelineElement[] = [];
const updatedTracks = [...this.savedState];
const itemsByTrackId = groupClipboardItemsByTrackId({
clipboardItems: this.clipboardItems,
});
for (const item of this.clipboardItems) {
if (item.trackType !== track.type) continue;
for (const [trackId, items] of itemsByTrackId) {
const elementsToAdd = buildPastedElements({
items,
minStart,
time: this.time,
pastedElementIds: this.pastedElementIds,
});
const relativeOffset = item.element.startTime - minStart;
const startTime = Math.max(0, this.time + relativeOffset);
const newElementId = generateUUID();
this.pastedElementIds.push(newElementId);
const pastedElement: TimelineElement = {
...item.element,
id: newElementId,
startTime,
} as TimelineElement;
elementsToAdd.push(pastedElement);
if (elementsToAdd.length === 0) {
continue;
}
return elementsToAdd.length > 0
? { ...track, elements: [...track.elements, ...elementsToAdd] }
: track;
}) as TimelineTrack[];
const trackType = items[0].trackType;
const sourceTrackIndex = updatedTracks.findIndex(
(track) => track.id === trackId,
);
const resolvedTargetIndex = resolveTargetTrackIndex({
tracks: updatedTracks,
sourceTrackIndex,
trackType,
elements: elementsToAdd,
});
if (resolvedTargetIndex >= 0) {
const targetTrack = updatedTracks[resolvedTargetIndex];
updatedTracks[resolvedTargetIndex] = {
...targetTrack,
elements: [...targetTrack.elements, ...elementsToAdd],
} as TimelineTrack;
continue;
}
const insertIndex = resolveInsertIndexForNewTrack({
tracks: updatedTracks,
sourceTrackIndex,
trackType,
});
const newTrack = buildTrackWithElements({
trackType,
elements: elementsToAdd,
});
updatedTracks.splice(insertIndex, 0, newTrack);
}
editor.timeline.updateTracks(updatedTracks);
}
@ -65,3 +93,152 @@ export class PasteCommand extends Command {
}
}
}
function groupClipboardItemsByTrackId({
clipboardItems,
}: {
clipboardItems: ClipboardItem[];
}): Map<string, ClipboardItem[]> {
const groupedItems = new Map<string, ClipboardItem[]>();
for (const item of clipboardItems) {
const existingItems = groupedItems.get(item.trackId) ?? [];
groupedItems.set(item.trackId, [...existingItems, item]);
}
return groupedItems;
}
function buildPastedElements({
items,
minStart,
time,
pastedElementIds,
}: {
items: ClipboardItem[];
minStart: number;
time: number;
pastedElementIds: string[];
}): TimelineElement[] {
const elementsToAdd: TimelineElement[] = [];
for (const item of items) {
const relativeOffset = item.element.startTime - minStart;
const startTime = Math.max(0, time + relativeOffset);
const newElementId = generateUUID();
pastedElementIds.push(newElementId);
elementsToAdd.push({
...item.element,
id: newElementId,
startTime,
} as TimelineElement);
}
return elementsToAdd;
}
function resolveTargetTrackIndex({
tracks,
sourceTrackIndex,
trackType,
elements,
}: {
tracks: TimelineTrack[];
sourceTrackIndex: number;
trackType: ClipboardItem["trackType"];
elements: TimelineElement[];
}): number {
if (sourceTrackIndex >= 0) {
const aboveIndex = sourceTrackIndex - 1;
if (aboveIndex < 0) {
return -1;
}
const aboveTrack = tracks[aboveIndex];
if (aboveTrack.type !== trackType) {
return -1;
}
const canPlaceOnAbove = canPlaceElementsOnTrack({
track: aboveTrack,
elements,
});
return canPlaceOnAbove ? aboveIndex : -1;
}
const highestCompatibleIndex = tracks.findIndex(
(track) => track.type === trackType,
);
if (highestCompatibleIndex < 0) {
return -1;
}
const highestCompatibleTrack = tracks[highestCompatibleIndex];
const canPlaceOnHighest = canPlaceElementsOnTrack({
track: highestCompatibleTrack,
elements,
});
return canPlaceOnHighest ? highestCompatibleIndex : -1;
}
function resolveInsertIndexForNewTrack({
tracks,
sourceTrackIndex,
trackType,
}: {
tracks: TimelineTrack[];
sourceTrackIndex: number;
trackType: ClipboardItem["trackType"];
}): number {
if (sourceTrackIndex >= 0) {
return sourceTrackIndex;
}
const highestCompatibleIndex = tracks.findIndex(
(track) => track.type === trackType,
);
if (highestCompatibleIndex >= 0) {
return highestCompatibleIndex;
}
return getHighestInsertIndexForTrack({ tracks, trackType });
}
function canPlaceElementsOnTrack({
track,
elements,
}: {
track: TimelineTrack;
elements: TimelineElement[];
}): boolean {
for (const element of elements) {
const endTime = element.startTime + element.duration;
const hasOverlap = wouldElementOverlap({
elements: track.elements,
startTime: element.startTime,
endTime,
});
if (hasOverlap) {
return false;
}
}
return true;
}
function buildTrackWithElements({
trackType,
elements,
}: {
trackType: ClipboardItem["trackType"];
elements: TimelineElement[];
}): TimelineTrack {
const newTrackId = generateUUID();
const newTrackBase = buildEmptyTrack({ id: newTrackId, type: trackType });
return {
...newTrackBase,
elements,
} as TimelineTrack;
}

View File

@ -1,131 +0,0 @@
import { Command } from "@/lib/commands/base-command";
import { EditorCore } from "@/core";
import type {
CreateTimelineElement,
TimelineTrack,
TimelineElement,
} from "@/types/timeline";
import { generateUUID } from "@/lib/utils";
import { requiresMediaId } from "@/lib/timeline/element-utils";
import { validateElementTrackCompatibility } from "@/lib/timeline/track-utils";
import type { MediaAsset } from "@/types/assets";
export class AddElementToTrackCommand extends Command {
private elementId: string;
private savedState: TimelineTrack[] | null = null;
constructor(
private trackId: string,
private element: CreateTimelineElement,
) {
super();
this.elementId = generateUUID();
}
execute(): void {
const editor = EditorCore.getInstance();
this.savedState = editor.timeline.getTracks();
const track = this.savedState.find((t) => t.id === this.trackId);
if (!track) {
console.error("Track not found:", this.trackId);
return;
}
const validation = validateElementTrackCompatibility({
element: this.element,
track,
});
if (!validation.isValid) {
console.error(validation.errorMessage);
return;
}
if (
requiresMediaId({ element: this.element }) &&
!("mediaId" in this.element)
) {
console.error("Element requires mediaId");
return;
}
if (
this.element.type === "audio" &&
this.element.sourceType === "library" &&
!this.element.sourceUrl
) {
console.error("Library audio element must have sourceUrl");
return;
}
if (this.element.type === "sticker" && !this.element.iconName) {
console.error("Sticker element must have iconName");
return;
}
if (this.element.type === "text" && !this.element.content) {
console.error("Text element must have content");
return;
}
const totalElementsInTimeline = this.savedState.reduce(
(total, t) => total + t.elements.length,
0,
);
const isFirstElement = totalElementsInTimeline === 0;
const newElement: TimelineElement = {
...this.element,
id: this.elementId,
startTime: this.element.startTime,
trimStart: this.element.trimStart ?? 0,
trimEnd: this.element.trimEnd ?? 0,
} as TimelineElement;
const isVisualMedia =
newElement.type === "video" || newElement.type === "image";
if (isFirstElement && isVisualMedia) {
const mediaAssets = editor.media.getAssets();
const asset = mediaAssets.find(
(item: MediaAsset) => item.id === newElement.mediaId,
);
if (asset?.width && asset?.height) {
editor.project.updateSettings({
settings: {
canvasSize: { width: asset.width, height: asset.height },
},
pushHistory: false,
});
}
if (asset?.type === "video" && asset?.fps) {
editor.project.updateSettings({
settings: { fps: asset.fps },
pushHistory: false,
});
}
}
const updatedTracks = this.savedState.map((t) =>
t.id === this.trackId
? { ...t, elements: [...t.elements, newElement] }
: t,
) as TimelineTrack[];
editor.timeline.updateTracks(updatedTracks);
}
undo(): void {
if (this.savedState) {
const editor = EditorCore.getInstance();
editor.timeline.updateTracks(this.savedState);
}
}
getElementId(): string {
return this.elementId;
}
}

View File

@ -1,7 +1,11 @@
import { Command } from "@/lib/commands/base-command";
import type { TimelineTrack } from "@/types/timeline";
import type { TimelineElement, TimelineTrack } from "@/types/timeline";
import { generateUUID } from "@/lib/utils";
import { EditorCore } from "@/core";
import {
buildEmptyTrack,
getHighestInsertIndexForTrack,
} from "@/lib/timeline/track-utils";
interface DuplicateElementsParams {
elements: { trackId: string; elementId: string }[];
@ -22,40 +26,51 @@ export class DuplicateElementsCommand extends Command {
this.savedState = editor.timeline.getTracks();
this.duplicatedIds = [];
const updatedTracks = this.savedState.map((track) => {
const updatedTracks = [...this.savedState];
for (const track of this.savedState) {
const elementsToDuplicate = this.elements.filter(
(el) => el.trackId === track.id,
);
if (elementsToDuplicate.length === 0) {
return track;
continue;
}
const newElements = track.elements.flatMap((element) => {
const shouldDuplicate = elementsToDuplicate.some(
(el) => el.elementId === element.id,
);
const elementIdsToDuplicate = new Set(
elementsToDuplicate.map((element) => element.elementId),
);
const newTrackElements: TimelineElement[] = [];
if (!shouldDuplicate) {
return [element];
for (const element of track.elements) {
if (!elementIdsToDuplicate.has(element.id)) {
continue;
}
const newId = generateUUID();
this.duplicatedIds.push(newId);
return [
element,
{
...element,
newTrackElements.push(
buildDuplicateElement({
element,
id: newId,
name: `${element.name} (copy)`,
startTime: element.startTime + element.duration + 0.1,
},
];
});
startTime: element.startTime,
}),
);
}
return { ...track, elements: newElements } as typeof track;
});
const newTrackId = generateUUID();
const newTrackBase = buildEmptyTrack({ id: newTrackId, type: track.type });
const newTrack = {
...newTrackBase,
elements: newTrackElements,
} as TimelineTrack;
const insertIndex = getHighestInsertIndexForTrack({
tracks: updatedTracks,
trackType: track.type,
});
updatedTracks.splice(insertIndex, 0, newTrack);
}
editor.timeline.updateTracks(updatedTracks);
}
@ -67,3 +82,15 @@ export class DuplicateElementsCommand extends Command {
}
}
}
function buildDuplicateElement({
element,
id,
startTime,
}: {
element: TimelineElement;
id: string;
startTime: number;
}): TimelineElement {
return { ...element, id, name: `${element.name} (copy)`, startTime };
}

View File

@ -1,4 +1,4 @@
export { AddElementToTrackCommand } from "./add-element";
export { InsertElementCommand } from "./insert-element";
export { DeleteElementsCommand } from "./delete-elements";
export { DuplicateElementsCommand } from "./duplicate-elements";
export { UpdateElementTrimCommand } from "./update-element-trim";
@ -8,4 +8,4 @@ export { SplitElementsCommand } from "./split-elements";
export { UpdateTextElementCommand } from "./update-text-element";
export { ToggleElementsVisibilityCommand } from "./toggle-elements-visibility";
export { ToggleElementsMutedCommand } from "./toggle-elements-muted";
export { MoveElementCommand } from "./move-element";
export { MoveElementCommand } from "./move-elements";

View File

@ -0,0 +1,312 @@
import { Command } from "@/lib/commands/base-command";
import { EditorCore } from "@/core";
import type {
CreateTimelineElement,
TimelineTrack,
TimelineElement,
TrackType,
ElementType,
} from "@/types/timeline";
import { generateUUID } from "@/lib/utils";
import {
requiresMediaId,
wouldElementOverlap,
} from "@/lib/timeline/element-utils";
import {
buildEmptyTrack,
canElementGoOnTrack,
getDefaultInsertIndexForTrack,
validateElementTrackCompatibility,
} from "@/lib/timeline/track-utils";
import type { MediaAsset } from "@/types/assets";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
type InsertElementPlacement =
| { mode: "explicit"; trackId: string }
| { mode: "auto"; trackType?: TrackType; insertIndex?: number };
export interface InsertElementParams {
element: CreateTimelineElement;
placement: InsertElementPlacement;
};
export class InsertElementCommand extends Command {
private elementId: string;
private savedState: TimelineTrack[] | null = null;
private targetTrackId: string | null = null;
constructor({ element, placement }: InsertElementParams) {
super();
this.elementId = generateUUID();
this.element = element;
this.placement = placement;
}
private element: CreateTimelineElement;
private placement: InsertElementPlacement;
execute(): void {
const editor = EditorCore.getInstance();
this.savedState = editor.timeline.getTracks();
if (!this.savedState) {
console.error("Tracks not available");
return;
}
if (!this.validateElementBasics({ element: this.element })) {
return;
}
const totalElementsInTimeline = this.savedState.reduce(
(total, t) => total + t.elements.length,
0,
);
const isFirstElement = totalElementsInTimeline === 0;
const newElement = this.buildElement({ element: this.element });
const updateResult = this.resolveTracksWithElement({
tracks: this.savedState,
element: newElement,
});
if (!updateResult) {
return;
}
const { updatedTracks, targetTrackId } = updateResult;
this.targetTrackId = targetTrackId;
const isVisualMedia =
newElement.type === "video" || newElement.type === "image";
if (isFirstElement && isVisualMedia) {
const mediaAssets = editor.media.getAssets();
const activeProject = editor.project.getActive();
const asset = mediaAssets.find(
(item: MediaAsset) => item.id === newElement.mediaId,
);
if (asset?.width && asset?.height) {
const nextCanvasSize = { width: asset.width, height: asset.height };
const shouldSetOriginalCanvasSize =
!activeProject?.settings.originalCanvasSize;
editor.project.updateSettings({
settings: {
canvasSize: nextCanvasSize,
...(shouldSetOriginalCanvasSize
? { originalCanvasSize: nextCanvasSize }
: {}),
},
pushHistory: false,
});
}
if (asset?.type === "video" && asset?.fps) {
editor.project.updateSettings({
settings: { fps: asset.fps },
pushHistory: false,
});
}
}
editor.timeline.updateTracks(updatedTracks);
}
undo(): void {
if (this.savedState) {
const editor = EditorCore.getInstance();
editor.timeline.updateTracks(this.savedState);
}
}
getElementId(): string {
return this.elementId;
}
getTrackId(): string | null {
return this.targetTrackId;
}
private buildElement({
element,
}: {
element: CreateTimelineElement;
}): TimelineElement {
return {
...element,
id: this.elementId,
startTime: element.startTime,
trimStart: element.trimStart ?? 0,
trimEnd: element.trimEnd ?? 0,
duration:
element.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION,
} as TimelineElement;
}
private validateElementBasics({
element,
}: {
element: CreateTimelineElement;
}): boolean {
if (requiresMediaId({ element }) && !("mediaId" in element)) {
console.error("Element requires mediaId");
return false;
}
if (
element.type === "audio" &&
element.sourceType === "library" &&
!element.sourceUrl
) {
console.error("Library audio element must have sourceUrl");
return false;
}
if (element.type === "sticker" && !element.iconName) {
console.error("Sticker element must have iconName");
return false;
}
if (element.type === "text" && !element.content) {
console.error("Text element must have content");
return false;
}
return true;
}
private resolveTracksWithElement({
tracks,
element,
}: {
tracks: TimelineTrack[];
element: TimelineElement;
}): { updatedTracks: TimelineTrack[]; targetTrackId: string } | null {
const placement = this.placement;
if (placement.mode === "explicit") {
const targetTrack = tracks.find(
(track) => track.id === placement.trackId,
);
if (!targetTrack) {
console.error("Track not found:", placement.trackId);
return null;
}
const validation = validateElementTrackCompatibility({
element,
track: targetTrack,
});
if (!validation.isValid) {
console.error(validation.errorMessage);
return null;
}
const updatedTracks = tracks.map((track) =>
track.id === targetTrack.id
? { ...track, elements: [...track.elements, element] }
: track,
) as TimelineTrack[];
return { updatedTracks, targetTrackId: targetTrack.id };
}
const trackType =
placement.trackType ?? this.getTrackTypeForElement({ element });
if (
placement.trackType &&
!canElementGoOnTrack({
elementType: element.type,
trackType,
})
) {
console.error(
`${element.type} elements cannot be placed on ${trackType} tracks`,
);
return null;
}
const elementEndTime = element.startTime + element.duration;
const existingTrack = tracks.find((track) => {
if (
!canElementGoOnTrack({
elementType: element.type,
trackType: track.type,
})
) {
return false;
}
return !wouldElementOverlap({
elements: track.elements,
startTime: element.startTime,
endTime: elementEndTime,
});
});
if (existingTrack) {
const updatedTracks = tracks.map((track) =>
track.id === existingTrack.id
? { ...track, elements: [...track.elements, element] }
: track,
) as TimelineTrack[];
return { updatedTracks, targetTrackId: existingTrack.id };
}
const newTrackId = generateUUID();
const newTrack = buildEmptyTrack({
id: newTrackId,
type: trackType,
});
const newTrackWithElement = {
...newTrack,
elements: [...newTrack.elements, element],
} as TimelineTrack;
const updatedTracks = [...tracks];
const insertIndex =
placement.insertIndex ??
this.getAutoInsertIndex({ tracks: updatedTracks, trackType });
updatedTracks.splice(insertIndex, 0, newTrackWithElement);
return { updatedTracks, targetTrackId: newTrackId };
}
private getAutoInsertIndex({
tracks,
trackType,
}: {
tracks: TimelineTrack[];
trackType: TrackType;
}): number {
if (trackType === "text") {
const firstVideoTrackIndex = tracks.findIndex(
(track) => track.type === "video",
);
if (firstVideoTrackIndex >= 0) {
return firstVideoTrackIndex;
}
}
return getDefaultInsertIndexForTrack({
tracks,
trackType,
});
}
private getTrackTypeForElement({
element,
}: {
element: { type: ElementType };
}): TrackType {
if (element.type === "video" || element.type === "image") {
return "video";
}
return element.type;
}
}

View File

@ -9,6 +9,8 @@ export class UpdateElementTrimCommand extends Command {
private elementId: string,
private trimStart: number,
private trimEnd: number,
private startTime?: number,
private duration?: number,
) {
super();
}
@ -18,11 +20,19 @@ export class UpdateElementTrimCommand extends Command {
this.savedState = editor.timeline.getTracks();
const updatedTracks = this.savedState.map((track) => {
const newElements = track.elements.map((element) =>
element.id === this.elementId
? { ...element, trimStart: this.trimStart, trimEnd: this.trimEnd }
: element,
);
const newElements = track.elements.map((element) => {
if (element.id !== this.elementId) {
return element;
}
return {
...element,
trimStart: this.trimStart,
trimEnd: this.trimEnd,
startTime: this.startTime ?? element.startTime,
duration: this.duration ?? element.duration,
};
});
return { ...track, elements: newElements } as typeof track;
});

View File

@ -2,7 +2,10 @@ import { Command } from "@/lib/commands/base-command";
import type { TrackType, TimelineTrack } from "@/types/timeline";
import { generateUUID } from "@/lib/utils";
import { EditorCore } from "@/core";
import { buildEmptyTrack } from "@/lib/timeline/track-utils";
import {
buildEmptyTrack,
getDefaultInsertIndexForTrack,
} from "@/lib/timeline/track-utils";
export class AddTrackCommand extends Command {
private trackId: string;
@ -25,13 +28,14 @@ export class AddTrackCommand extends Command {
type: this.type,
});
let updatedTracks: TimelineTrack[];
if (this.index !== undefined) {
updatedTracks = [...(this.savedState || [])];
updatedTracks.splice(this.index, 0, newTrack);
} else {
updatedTracks = [...(this.savedState || []), newTrack];
}
const updatedTracks = [...(this.savedState || [])];
const insertIndex =
this.index ??
getDefaultInsertIndexForTrack({
tracks: updatedTracks,
trackType: this.type,
});
updatedTracks.splice(insertIndex, 0, newTrack);
editor.timeline.updateTracks(updatedTracks);
}

View File

@ -1,6 +1,7 @@
import type { TimelineDragData } from "@/types/drag";
const MIME_TYPE = "application/x-timeline-drag";
let lastDragData: TimelineDragData | null = null;
export function setDragData({
dataTransfer,
@ -10,6 +11,8 @@ export function setDragData({
dragData: TimelineDragData;
}): void {
dataTransfer.setData(MIME_TYPE, JSON.stringify(dragData));
dataTransfer.setData("text/plain", JSON.stringify(dragData));
lastDragData = dragData;
}
export function getDragData({
@ -18,7 +21,18 @@ export function getDragData({
dataTransfer: DataTransfer;
}): TimelineDragData | null {
const data = dataTransfer.getData(MIME_TYPE);
return data ? (JSON.parse(data) as TimelineDragData) : null;
if (data) return JSON.parse(data) as TimelineDragData;
const textData = dataTransfer.getData("text/plain");
if (textData) {
try {
return JSON.parse(textData) as TimelineDragData;
} catch {
return lastDragData;
}
}
return lastDragData;
}
export function hasDragData({
@ -26,5 +40,9 @@ export function hasDragData({
}: {
dataTransfer: DataTransfer;
}): boolean {
return dataTransfer.types.includes(MIME_TYPE);
return dataTransfer.types.includes(MIME_TYPE) || lastDragData !== null;
}
export function clearDragData(): void {
lastDragData = null;
}

View File

@ -1,118 +0,0 @@
import { SceneExporter } from "../services/renderer/scene-exporter";
import { buildScene } from "../services/renderer/scene-builder";
import { EditorCore } from "@/core";
import { ExportOptions, ExportResult } from "@/types/export";
import { createTimelineAudioBuffer } from "@/lib/audio-utils";
export async function exportProject({
format,
quality,
fps,
includeAudio,
onProgress,
onCancel,
}: ExportOptions): Promise<ExportResult> {
try {
const editor = EditorCore.getInstance();
const activeProject = editor.project.getActive();
if (!activeProject) {
return { success: false, error: "No active project" };
}
const duration = editor.timeline.getTotalDuration();
if (duration === 0) {
return { success: false, error: "Project is empty" };
}
const exportFps = fps || activeProject.settings.fps;
const canvasSize = activeProject.settings.canvasSize;
const tracks = editor.timeline.getTracks();
const mediaAssets = editor.media.getAssets();
let audioBuffer: AudioBuffer | null = null;
if (includeAudio) {
onProgress?.({ progress: 0.05 });
audioBuffer = await createTimelineAudioBuffer({
tracks,
mediaAssets,
duration,
});
}
const scene = buildScene({
tracks,
mediaAssets,
duration,
canvasSize,
background: activeProject.settings.background,
});
const exporter = new SceneExporter({
width: canvasSize.width,
height: canvasSize.height,
fps: exportFps,
format,
quality,
includeAudio: !!includeAudio,
audioBuffer: audioBuffer || undefined,
});
exporter.on("progress", (progress) => {
const adjustedProgress = includeAudio ? 0.05 + progress * 0.95 : progress;
onProgress?.({ progress: adjustedProgress });
});
let isCancelled = false;
const checkCancel = () => {
if (onCancel?.()) {
isCancelled = true;
exporter.cancel();
}
};
const cancelInterval = setInterval(checkCancel, 100);
try {
const buffer = await exporter.export(scene);
clearInterval(cancelInterval);
if (isCancelled) {
return { success: false, cancelled: true };
}
if (!buffer) {
return { success: false, error: "Export failed to produce buffer" };
}
return {
success: true,
buffer,
};
} finally {
clearInterval(cancelInterval);
}
} catch (error) {
console.error("Export failed:", error);
return {
success: false,
error: error instanceof Error ? error.message : "Unknown export error",
};
}
}
export function getExportMimeType({
format,
}: {
format: "mp4" | "webm";
}): string {
return format === "webm" ? "video/webm" : "video/mp4";
}
export function getExportFileExtension({
format,
}: {
format: "mp4" | "webm";
}): string {
return `.${format}`;
}

View File

@ -0,0 +1,15 @@
export function getExportMimeType({
format,
}: {
format: "mp4" | "webm";
}): string {
return format === "webm" ? "video/webm" : "video/mp4";
}
export function getExportFileExtension({
format,
}: {
format: "mp4" | "webm";
}): string {
return `.${format}`;
}

View File

@ -0,0 +1,906 @@
import type {
GradientAst,
Color,
ColorStop,
GradientOrientation,
} from "./parser";
import { parseGradient } from "./parser";
type BackgroundLayer =
| { type: "color"; value: string }
| { type: "gradient"; value: GradientAst };
type LinearPoints = {
x0: number;
y0: number;
x1: number;
y1: number;
length: number;
};
type RadialDimensions = {
cx: number;
cy: number;
rx: number;
ry: number;
};
type PositionKeyword = "left" | "center" | "right" | "top" | "bottom";
type Distance =
| { type: "%"; value: string }
| { type: "position-keyword"; value: string }
| { type: "calc"; value: string }
| { type: "px"; value: string }
| { type: "em"; value: string };
type Position = { type: "position"; value: { x?: Distance; y?: Distance } };
type Shape = {
type: "shape";
value: "circle" | "ellipse";
style?: Distance | { type: "extent-keyword"; value: string } | Position;
at?: Position;
};
type DefaultRadial = { type: "default-radial"; at: Position };
type ExtentKeyword = { type: "extent-keyword"; value: string; at?: Position };
type RadialOrientation = Shape | ExtentKeyword | DefaultRadial;
const gradientLayerPattern =
/^(?:\-(webkit|o|ms|moz)\-)?(linear-gradient|repeating-linear-gradient|radial-gradient|repeating-radial-gradient)/i;
export function drawCssBackground({
ctx,
width,
height,
css,
}: {
ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D;
width: number;
height: number;
css: string;
}): void {
const layers = parseBackgroundLayers({ css });
const layersInPaintOrder = layers.slice().reverse();
for (const layer of layersInPaintOrder) {
if (layer.type === "color") {
ctx.fillStyle = layer.value;
ctx.fillRect(0, 0, width, height);
continue;
}
drawGradientLayer({ ctx, width, height, gradient: layer.value });
}
}
const parseBackgroundLayers = ({ css }: { css: string }): Array<BackgroundLayer> => {
const segments = splitCssLayers({ css });
const layers: Array<BackgroundLayer> = [];
for (const segment of segments) {
if (!segment) {
continue;
}
if (gradientLayerPattern.test(segment)) {
try {
const parsed = parseGradient({ code: segment.trim() });
for (const gradient of parsed) {
layers.push({ type: "gradient", value: gradient });
}
continue;
} catch {
layers.push({ type: "color", value: segment });
continue;
}
}
layers.push({ type: "color", value: segment });
}
return layers;
};
const splitCssLayers = ({ css }: { css: string }): Array<string> => {
const layers: Array<string> = [];
let current = "";
let depth = 0;
for (const char of css) {
if (char === "(") {
depth += 1;
}
if (char === ")") {
depth = Math.max(0, depth - 1);
}
if (char === "," && depth === 0) {
layers.push(current.trim());
current = "";
continue;
}
current += char;
}
if (current.trim()) {
layers.push(current.trim());
}
return layers;
};
const drawGradientLayer = ({
ctx,
width,
height,
gradient,
}: {
ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D;
width: number;
height: number;
gradient: GradientAst;
}): void => {
if (gradient.type.includes("linear")) {
drawLinearGradient({ ctx, width, height, gradient });
return;
}
drawRadialGradient({ ctx, width, height, gradient });
};
const drawLinearGradient = ({
ctx,
width,
height,
gradient,
}: {
ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D;
width: number;
height: number;
gradient: GradientAst;
}): void => {
const { x0, y0, x1, y1, length } = resolveLinearPoints({
width,
height,
orientation: gradient.orientation,
});
const canvasGradient = ctx.createLinearGradient(x0, y0, x1, y1);
const colorStops = normalizeColorStops({
colorStops: gradient.colorStops,
gradientLength: length,
});
for (const stop of colorStops) {
canvasGradient.addColorStop(stop.offset, stop.color);
}
ctx.fillStyle = canvasGradient;
ctx.fillRect(0, 0, width, height);
};
const drawRadialGradient = ({
ctx,
width,
height,
gradient,
}: {
ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D;
width: number;
height: number;
gradient: GradientAst;
}): void => {
const { cx, cy, rx, ry } = resolveRadialDimensions({
width,
height,
orientation: gradient.orientation,
});
const gradientLength = Math.max(rx, ry);
const colorStops = normalizeColorStops({
colorStops: gradient.colorStops,
gradientLength,
});
if (rx === ry || ry === 0) {
const canvasGradient = ctx.createRadialGradient(cx, cy, 0, cx, cy, rx);
for (const stop of colorStops) {
canvasGradient.addColorStop(stop.offset, stop.color);
}
ctx.fillStyle = canvasGradient;
ctx.fillRect(0, 0, width, height);
return;
}
const scaleY = ry / rx;
ctx.save();
ctx.translate(cx, cy);
ctx.scale(1, scaleY);
const canvasGradient = ctx.createRadialGradient(0, 0, 0, 0, 0, rx);
for (const stop of colorStops) {
canvasGradient.addColorStop(stop.offset, stop.color);
}
ctx.fillStyle = canvasGradient;
ctx.fillRect(-cx, -cy / scaleY, width, height / scaleY);
ctx.restore();
};
const resolveLinearPoints = ({
width,
height,
orientation,
}: {
width: number;
height: number;
orientation: GradientOrientation | undefined;
}): LinearPoints => {
const angle = resolveLinearAngle({ orientation });
const radians = (angle * Math.PI) / 180;
const dx = Math.sin(radians);
const dy = -Math.cos(radians);
const centerX = width / 2;
const centerY = height / 2;
const halfLength = (Math.abs(width * dx) + Math.abs(height * dy)) / 2;
const x0 = centerX - dx * halfLength;
const y0 = centerY - dy * halfLength;
const x1 = centerX + dx * halfLength;
const y1 = centerY + dy * halfLength;
const length = Math.hypot(x1 - x0, y1 - y0);
return { x0, y0, x1, y1, length };
};
const resolveLinearAngle = ({
orientation,
}: {
orientation: GradientOrientation | undefined;
}): number => {
if (!orientation) {
return 180;
}
if (!Array.isArray(orientation)) {
if (orientation.type === "angular") {
return Number.parseFloat(orientation.value);
}
if (orientation.type === "directional") {
return angleFromDirectional({ value: orientation.value });
}
}
return 180;
};
const angleFromDirectional = ({ value }: { value: string }): number => {
const normalized = value.toLowerCase().replace("to", "").trim();
const parts = normalized.split(/\s+/).filter(Boolean);
let dx = 0;
let dy = 0;
for (const part of parts) {
if (part === "left") {
dx = -1;
}
if (part === "right") {
dx = 1;
}
if (part === "top") {
dy = -1;
}
if (part === "bottom") {
dy = 1;
}
}
if (dx === 0 && dy === 0) {
return 180;
}
const angle = (Math.atan2(dx, -dy) * 180) / Math.PI;
return (angle + 360) % 360;
};
const resolveRadialDimensions = ({
width,
height,
orientation,
}: {
width: number;
height: number;
orientation: GradientOrientation | undefined;
}): RadialDimensions => {
const centerFallback = { cx: width / 2, cy: height / 2 };
const radial = Array.isArray(orientation) ? (orientation[0] as RadialOrientation | undefined) : undefined;
if (!radial) {
const { rx, ry } = resolveRadialExtents({
width,
height,
cx: centerFallback.cx,
cy: centerFallback.cy,
shape: "ellipse",
extent: "farthest-corner",
});
return { ...centerFallback, rx, ry };
}
if (radial.type === "shape") {
const { cx, cy } = resolveRadialCenter({
width,
height,
position: radial.at,
});
const shape = radial.value;
if (radial.style && radial.style.type === "position") {
const xDist = radial.style.value.x;
const yDist = radial.style.value.y;
const rx = xDist ? resolveEllipseDimension({ distance: xDist, axisSize: width }) : width / 2;
const ry = yDist ? resolveEllipseDimension({ distance: yDist, axisSize: height }) : height / 2;
return { cx, cy, rx, ry };
}
if (
radial.style &&
radial.style.type !== "extent-keyword"
) {
const resolvedRadius = resolveDistanceInPixels({
distance: radial.style,
axisSize: Math.max(width, height),
});
if (shape === "circle") {
return {
cx,
cy,
rx: resolvedRadius ?? 0,
ry: resolvedRadius ?? 0,
};
}
const radius = resolvedRadius ?? 0;
return { cx, cy, rx: radius, ry: radius };
}
const extent =
radial.style && radial.style.type === "extent-keyword"
? radial.style.value
: "farthest-corner";
const { rx, ry } = resolveRadialExtents({
width,
height,
cx,
cy,
shape,
extent,
});
return { cx, cy, rx, ry };
}
if (radial.type === "extent-keyword") {
const { cx, cy } = resolveRadialCenter({
width,
height,
position: radial.at,
});
const { rx, ry } = resolveRadialExtents({
width,
height,
cx,
cy,
shape: "ellipse",
extent: radial.value,
});
return { cx, cy, rx, ry };
}
const { cx, cy } = resolveRadialCenter({
width,
height,
position: radial.at,
});
const { rx, ry } = resolveRadialExtents({
width,
height,
cx,
cy,
shape: "ellipse",
extent: "farthest-corner",
});
return { cx, cy, rx, ry };
};
const resolveRadialCenter = ({
width,
height,
position,
}: {
width: number;
height: number;
position?: Position;
}): { cx: number; cy: number } => {
if (!position) {
return { cx: width / 2, cy: height / 2 };
}
const normalized = normalizePositionKeywords({ position });
const cx = resolvePositionValue({
distance: normalized.value.x,
axisSize: width,
axis: "x",
});
const cy = resolvePositionValue({
distance: normalized.value.y,
axisSize: height,
axis: "y",
});
return { cx, cy };
};
const normalizePositionKeywords = ({
position,
}: {
position: Position;
}): Position => {
const xValue = position.value.x;
const yValue = position.value.y;
if (
xValue?.type === "position-keyword" &&
yValue?.type === "position-keyword"
) {
const xKeyword = xValue.value.toLowerCase() as PositionKeyword;
const yKeyword = yValue.value.toLowerCase() as PositionKeyword;
const xIsVertical = xKeyword === "top" || xKeyword === "bottom";
const yIsHorizontal = yKeyword === "left" || yKeyword === "right";
if (xIsVertical && yIsHorizontal) {
return {
type: "position",
value: {
x: { type: "position-keyword", value: yKeyword },
y: { type: "position-keyword", value: xKeyword },
},
};
}
}
return position;
};
const resolveRadialExtents = ({
width,
height,
cx,
cy,
shape,
extent,
}: {
width: number;
height: number;
cx: number;
cy: number;
shape: "circle" | "ellipse";
extent: string;
}): { rx: number; ry: number } => {
const left = cx;
const right = width - cx;
const top = cy;
const bottom = height - cy;
if (shape === "circle") {
const distances = [
Math.hypot(left, top),
Math.hypot(right, top),
Math.hypot(left, bottom),
Math.hypot(right, bottom),
];
if (extent === "closest-side") {
return { rx: Math.min(left, right, top, bottom), ry: 0 };
}
if (extent === "farthest-side") {
return { rx: Math.max(left, right, top, bottom), ry: 0 };
}
if (extent === "closest-corner") {
return { rx: Math.min(...distances), ry: 0 };
}
return { rx: Math.max(...distances), ry: 0 };
}
if (extent === "closest-side") {
return { rx: Math.min(left, right), ry: Math.min(top, bottom) };
}
if (extent === "farthest-side") {
return { rx: Math.max(left, right), ry: Math.max(top, bottom) };
}
const corners = [
{ dx: left, dy: top },
{ dx: right, dy: top },
{ dx: left, dy: bottom },
{ dx: right, dy: bottom },
];
const sorted = corners
.slice()
.sort((a, b) => Math.hypot(a.dx, a.dy) - Math.hypot(b.dx, b.dy));
const chosen =
extent === "closest-corner" ? sorted[0] : sorted[sorted.length - 1];
return { rx: Math.abs(chosen.dx), ry: Math.abs(chosen.dy) };
};
const resolvePositionValue = ({
distance,
axisSize,
axis,
}: {
distance?: Distance;
axisSize: number;
axis: "x" | "y";
}): number => {
if (!distance) {
return axisSize / 2;
}
if (distance.type === "%") {
return (Number.parseFloat(distance.value) / 100) * axisSize;
}
if (distance.type === "position-keyword") {
return keywordToPosition({
value: distance.value,
axisSize,
axis,
});
}
if (distance.type === "px") {
return Number.parseFloat(distance.value);
}
if (distance.type === "em") {
return Number.parseFloat(distance.value) * 16;
}
return axisSize / 2;
};
const resolveDistanceInPixels = ({
distance,
axisSize,
}: {
distance: Distance;
axisSize: number;
}): number | null => {
if (distance.type === "%") {
return (Number.parseFloat(distance.value) / 100) * axisSize;
}
if (distance.type === "px") {
return Number.parseFloat(distance.value);
}
if (distance.type === "em") {
return Number.parseFloat(distance.value) * 16;
}
return null;
};
const resolveEllipseDimension = ({
distance,
axisSize,
}: {
distance: Distance;
axisSize: number;
}): number => {
if (distance.type === "%") {
return (Number.parseFloat(distance.value) / 100) * axisSize;
}
if (distance.type === "px") {
return Number.parseFloat(distance.value);
}
if (distance.type === "em") {
return Number.parseFloat(distance.value) * 16;
}
return axisSize / 2;
};
const keywordToPosition = ({
value,
axisSize,
axis,
}: {
value: string;
axisSize: number;
axis: "x" | "y";
}): number => {
const keyword = value.toLowerCase() as PositionKeyword;
if (keyword === "center") {
return axisSize / 2;
}
if (axis === "x") {
if (keyword === "left") {
return 0;
}
if (keyword === "right") {
return axisSize;
}
}
if (axis === "y") {
if (keyword === "top") {
return 0;
}
if (keyword === "bottom") {
return axisSize;
}
}
return axisSize / 2;
};
const isTransparent = ({ color }: { color: string }): boolean => {
const lower = color.toLowerCase().trim();
if (lower === "transparent") {
return true;
}
const rgbaMatch = lower.match(/^rgba\(\s*[\d.]+\s*,\s*[\d.]+\s*,\s*[\d.]+\s*,\s*([\d.]+)\s*\)$/);
if (rgbaMatch && Number.parseFloat(rgbaMatch[1]) === 0) {
return true;
}
return false;
};
const parseColorToRgb = ({ color }: { color: string }): { r: number; g: number; b: number; a: number } | null => {
const lower = color.toLowerCase().trim();
const hexMatch = lower.match(/^#([0-9a-f]{3,8})$/);
if (hexMatch) {
const hex = hexMatch[1];
if (hex.length === 3) {
return {
r: Number.parseInt(hex[0] + hex[0], 16),
g: Number.parseInt(hex[1] + hex[1], 16),
b: Number.parseInt(hex[2] + hex[2], 16),
a: 1,
};
}
if (hex.length === 6) {
return {
r: Number.parseInt(hex.slice(0, 2), 16),
g: Number.parseInt(hex.slice(2, 4), 16),
b: Number.parseInt(hex.slice(4, 6), 16),
a: 1,
};
}
if (hex.length === 8) {
return {
r: Number.parseInt(hex.slice(0, 2), 16),
g: Number.parseInt(hex.slice(2, 4), 16),
b: Number.parseInt(hex.slice(4, 6), 16),
a: Number.parseInt(hex.slice(6, 8), 16) / 255,
};
}
}
const rgbMatch = lower.match(/^rgb\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*\)$/);
if (rgbMatch) {
return {
r: Number.parseFloat(rgbMatch[1]),
g: Number.parseFloat(rgbMatch[2]),
b: Number.parseFloat(rgbMatch[3]),
a: 1,
};
}
const rgbaMatch = lower.match(/^rgba\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*\)$/);
if (rgbaMatch) {
return {
r: Number.parseFloat(rgbaMatch[1]),
g: Number.parseFloat(rgbaMatch[2]),
b: Number.parseFloat(rgbaMatch[3]),
a: Number.parseFloat(rgbaMatch[4]),
};
}
return null;
};
const fixTransparentStops = ({
stops,
}: {
stops: Array<{ color: string; offset: number | null }>;
}): Array<{ color: string; offset: number | null }> => {
if (stops.length === 0) {
return stops;
}
const result = stops.map((stop) => ({ ...stop }));
for (let i = 0; i < result.length; i++) {
if (!isTransparent({ color: result[i].color })) {
continue;
}
let donorColor: { r: number; g: number; b: number } | null = null;
for (let j = i - 1; j >= 0; j--) {
if (!isTransparent({ color: result[j].color })) {
const parsed = parseColorToRgb({ color: result[j].color });
if (parsed) {
donorColor = parsed;
break;
}
}
}
if (!donorColor) {
for (let j = i + 1; j < result.length; j++) {
if (!isTransparent({ color: result[j].color })) {
const parsed = parseColorToRgb({ color: result[j].color });
if (parsed) {
donorColor = parsed;
break;
}
}
}
}
if (donorColor) {
result[i].color = `rgba(${donorColor.r},${donorColor.g},${donorColor.b},0)`;
}
}
return result;
};
const normalizeColorStops = ({
colorStops,
gradientLength,
}: {
colorStops: Array<ColorStop>;
gradientLength: number;
}): Array<{ color: string; offset: number }> => {
const mappedStops = colorStops.map((stop) => ({
color: colorToString({ color: stop }),
offset: resolveStopOffset({ stop, gradientLength }),
}));
const fixedStops = fixTransparentStops({ stops: mappedStops });
const resolvedStops = fixedStops.map((stop) => ({ ...stop }));
const knownIndices = resolvedStops
.map((stop, index) => (stop.offset === null ? null : index))
.filter((index): index is number => index !== null);
if (knownIndices.length === 0) {
const step = resolvedStops.length > 1 ? 1 / (resolvedStops.length - 1) : 1;
for (let index = 0; index < resolvedStops.length; index += 1) {
resolvedStops[index].offset = step * index;
}
return clampStops({ stops: resolvedStops });
}
const firstKnown = knownIndices[0];
if (resolvedStops[firstKnown].offset === null) {
resolvedStops[firstKnown].offset = 0;
}
for (let index = 0; index < firstKnown; index += 1) {
const nextOffset = resolvedStops[firstKnown].offset ?? 0;
resolvedStops[index].offset = (nextOffset * index) / firstKnown;
}
for (let i = 0; i < knownIndices.length - 1; i += 1) {
const startIndex = knownIndices[i];
const endIndex = knownIndices[i + 1];
const startOffset = resolvedStops[startIndex].offset ?? 0;
const endOffset = resolvedStops[endIndex].offset ?? startOffset;
const gap = endIndex - startIndex;
if (gap <= 1) {
continue;
}
const step = (endOffset - startOffset) / gap;
for (let index = 1; index < gap; index += 1) {
resolvedStops[startIndex + index].offset =
startOffset + step * index;
}
}
const lastKnown = knownIndices[knownIndices.length - 1];
const lastOffset = resolvedStops[lastKnown].offset ?? 1;
if (lastKnown < resolvedStops.length - 1) {
const gap = resolvedStops.length - 1 - lastKnown;
const step = (1 - lastOffset) / gap;
for (let index = 1; index <= gap; index += 1) {
resolvedStops[lastKnown + index].offset = lastOffset + step * index;
}
}
return clampStops({ stops: resolvedStops });
};
const clampStops = ({
stops,
}: {
stops: Array<{ color: string; offset: number | null }>;
}): Array<{ color: string; offset: number }> => {
return stops.map((stop) => ({
color: stop.color,
offset: clamp01({ value: stop.offset ?? 0 }),
}));
};
const resolveStopOffset = ({
stop,
gradientLength,
}: {
stop: ColorStop;
gradientLength: number;
}): number | null => {
if (!stop.length) {
return null;
}
if (stop.length.type === "%") {
return Number.parseFloat(stop.length.value) / 100;
}
if (stop.length.type === "px") {
return Number.parseFloat(stop.length.value) / gradientLength;
}
if (stop.length.type === "em") {
return (Number.parseFloat(stop.length.value) * 16) / gradientLength;
}
return null;
};
const clamp01 = ({ value }: { value: number }): number => {
if (value < 0) {
return 0;
}
if (value > 1) {
return 1;
}
return value;
};
const colorToString = ({ color }: { color: Color }): string => {
if (color.type === "hex") {
return `#${color.value}`;
}
if (color.type === "literal") {
return color.value;
}
if (color.type === "rgb") {
return `rgb(${color.value.join(",")})`;
}
if (color.type === "rgba") {
return `rgba(${color.value.join(",")})`;
}
if (color.type === "hsl") {
return `hsl(${color.value.join(",")})`;
}
if (color.type === "hsla") {
return `hsla(${color.value.join(",")})`;
}
return `var(${color.value})`;
};

View File

@ -0,0 +1,2 @@
export * from "./parser";
export * from "./canvas";

View File

@ -0,0 +1,641 @@
/*
* Original source: https://github.com/rafaelcaricio/gradient-parser/blob/master/lib/parser.js
*/
type GradientType =
| "linear-gradient"
| "repeating-linear-gradient"
| "radial-gradient"
| "repeating-radial-gradient";
type DirectionalOrientation = { type: "directional"; value: string };
type AngularOrientation = { type: "angular"; value: string };
type LinearOrientation = DirectionalOrientation | AngularOrientation;
type Distance =
| { type: "%"; value: string }
| { type: "position-keyword"; value: string }
| { type: "calc"; value: string }
| { type: "px"; value: string }
| { type: "em"; value: string };
type PositionValue = { x?: Distance; y?: Distance };
type Position = { type: "position"; value: PositionValue };
type ExtentKeyword = { type: "extent-keyword"; value: string };
type ShapeValue = "circle" | "ellipse";
type Shape = {
type: "shape";
value: ShapeValue;
style?: Distance | ExtentKeyword | Position;
at?: Position;
};
type DefaultRadial = { type: "default-radial"; at: Position };
type RadialOrientation = Shape | (ExtentKeyword & { at?: Position }) | DefaultRadial;
export type GradientOrientation = LinearOrientation | Array<RadialOrientation>;
export type Color =
| { type: "hex"; value: string }
| { type: "literal"; value: string }
| { type: "rgb"; value: Array<string> }
| { type: "rgba"; value: Array<string> }
| { type: "hsl"; value: [string, string, string] }
| { type: "hsla"; value: [string, string, string, string] }
| { type: "var"; value: string };
export type ColorStop = Color & { length?: Distance };
export type GradientAst = {
type: GradientType;
orientation: GradientOrientation | undefined;
colorStops: Array<ColorStop>;
};
type Tokens = {
linearGradient: RegExp;
repeatingLinearGradient: RegExp;
radialGradient: RegExp;
repeatingRadialGradient: RegExp;
sideOrCorner: RegExp;
extentKeywords: RegExp;
positionKeywords: RegExp;
pixelValue: RegExp;
percentageValue: RegExp;
emValue: RegExp;
angleValue: RegExp;
radianValue: RegExp;
startCall: RegExp;
endCall: RegExp;
comma: RegExp;
hexColor: RegExp;
literalColor: RegExp;
rgbColor: RegExp;
rgbaColor: RegExp;
varColor: RegExp;
calcValue: RegExp;
variableName: RegExp;
number: RegExp;
hslColor: RegExp;
hslaColor: RegExp;
};
const tokens: Tokens = {
linearGradient: /^(\-(webkit|o|ms|moz)\-)?(linear\-gradient)/i,
repeatingLinearGradient: /^(\-(webkit|o|ms|moz)\-)?(repeating\-linear\-gradient)/i,
radialGradient: /^(\-(webkit|o|ms|moz)\-)?(radial\-gradient)/i,
repeatingRadialGradient: /^(\-(webkit|o|ms|moz)\-)?(repeating\-radial\-gradient)/i,
sideOrCorner:
/^to (left (top|bottom)|right (top|bottom)|top (left|right)|bottom (left|right)|left|right|top|bottom)/i,
extentKeywords:
/^(closest\-side|closest\-corner|farthest\-side|farthest\-corner|contain|cover)/,
positionKeywords: /^(left|center|right|top|bottom)/i,
pixelValue: /^(-?(([0-9]*\.[0-9]+)|([0-9]+\.?)))px/,
percentageValue: /^(-?(([0-9]*\.[0-9]+)|([0-9]+\.?)))\%/,
emValue: /^(-?(([0-9]*\.[0-9]+)|([0-9]+\.?)))em/,
angleValue: /^(-?(([0-9]*\.[0-9]+)|([0-9]+\.?)))deg/,
radianValue: /^(-?(([0-9]*\.[0-9]+)|([0-9]+\.?)))rad/,
startCall: /^\(/,
endCall: /^\)/,
comma: /^,/,
hexColor: /^\#([0-9a-fA-F]+)/,
literalColor: /^([a-zA-Z]+)/,
rgbColor: /^rgb/i,
rgbaColor: /^rgba/i,
varColor: /^var/i,
calcValue: /^calc/i,
variableName: /^(--[a-zA-Z0-9-,\s\#]+)/,
number: /^(([0-9]*\.[0-9]+)|([0-9]+\.?))/,
hslColor: /^hsl/i,
hslaColor: /^hsla/i,
};
let input = "";
const error = ({ message }: { message: string }): never => {
const err = new Error(`${input}: ${message}`);
(err as Error & { source?: string }).source = input;
throw err;
};
const getAst = (): Array<GradientAst> => {
const ast = matchListDefinitions();
if (input.length > 0) {
error({ message: "Invalid input not EOF" });
}
return ast;
};
const matchListDefinitions = (): Array<GradientAst> =>
matchListing({ matcher: matchDefinition });
const matchDefinition = (): GradientAst | undefined =>
matchGradient({
gradientType: "linear-gradient",
pattern: tokens.linearGradient,
orientationMatcher: matchLinearOrientation,
}) ||
matchGradient({
gradientType: "repeating-linear-gradient",
pattern: tokens.repeatingLinearGradient,
orientationMatcher: matchLinearOrientation,
}) ||
matchGradient({
gradientType: "radial-gradient",
pattern: tokens.radialGradient,
orientationMatcher: matchListRadialOrientations,
}) ||
matchGradient({
gradientType: "repeating-radial-gradient",
pattern: tokens.repeatingRadialGradient,
orientationMatcher: matchListRadialOrientations,
});
const matchGradient = ({
gradientType,
pattern,
orientationMatcher,
}: {
gradientType: GradientType;
pattern: RegExp;
orientationMatcher: () => GradientOrientation | undefined;
}): GradientAst | undefined =>
matchCall({
pattern,
callback: () => {
const orientation = orientationMatcher();
if (orientation && !scan({ regexp: tokens.comma })) {
error({ message: "Missing comma before color stops" });
}
return {
type: gradientType,
orientation,
colorStops: matchListing({ matcher: matchColorStop }),
};
},
});
const matchCall = <T>({
pattern,
callback,
}: {
pattern: RegExp;
callback: (captures: RegExpExecArray) => T;
}): T | undefined => {
const captures = scan({ regexp: pattern });
if (!captures) {
return undefined;
}
if (!scan({ regexp: tokens.startCall })) {
error({ message: "Missing (" });
}
const result = callback(captures);
if (!scan({ regexp: tokens.endCall })) {
error({ message: "Missing )" });
}
return result;
};
const matchLinearOrientation = (): LinearOrientation | undefined => {
const sideOrCorner = matchSideOrCorner();
if (sideOrCorner) {
return sideOrCorner;
}
const legacyDirection = match({
type: "position-keyword",
pattern: tokens.positionKeywords,
captureIndex: 1,
});
if (legacyDirection) {
return {
type: "directional",
value: legacyDirection.value,
};
}
return matchAngle();
};
const matchSideOrCorner = (): DirectionalOrientation | undefined =>
match({ type: "directional", pattern: tokens.sideOrCorner, captureIndex: 1 });
const matchAngle = (): AngularOrientation | undefined =>
match({ type: "angular", pattern: tokens.angleValue, captureIndex: 1 }) ||
match({ type: "angular", pattern: tokens.radianValue, captureIndex: 1 });
const matchListRadialOrientations = (): Array<RadialOrientation> | undefined => {
const radialOrientation = matchRadialOrientation();
if (!radialOrientation) {
return undefined;
}
const radialOrientations: Array<RadialOrientation> = [radialOrientation];
const lookaheadCache = input;
if (!scan({ regexp: tokens.comma })) {
return radialOrientations;
}
const nextRadial = matchRadialOrientation();
if (!nextRadial) {
input = lookaheadCache;
return radialOrientations;
}
radialOrientations.push(nextRadial);
return radialOrientations;
};
const matchRadialOrientation = (): RadialOrientation | undefined => {
const radialType = matchCircle() || matchEllipse();
if (radialType) {
radialType.at = matchAtPosition();
return radialType;
}
const extent = matchExtentKeyword();
if (extent) {
const positionAt = matchAtPosition();
if (positionAt) {
return { ...extent, at: positionAt };
}
return extent;
}
const implicitEllipse = matchImplicitEllipse();
if (implicitEllipse) {
return implicitEllipse;
}
const atPosition = matchAtPosition();
if (atPosition) {
return { type: "default-radial", at: atPosition };
}
const defaultPosition = matchPositioning();
if (defaultPosition) {
return { type: "default-radial", at: defaultPosition };
}
return undefined;
};
const matchImplicitEllipse = (): Shape | undefined => {
const lookaheadCache = input;
const width = matchDistance();
if (!width) {
return undefined;
}
const height = matchDistance();
if (!height) {
input = lookaheadCache;
return undefined;
}
const atPos = matchAtPosition();
if (!atPos) {
input = lookaheadCache;
return undefined;
}
return {
type: "shape",
value: "ellipse",
style: { type: "position", value: { x: width, y: height } },
at: atPos,
};
};
const matchCircle = (): Shape | undefined => {
const circle = match({
type: "shape",
pattern: /^(circle)/i,
captureIndex: 0,
}) as Shape | undefined;
if (!circle) {
return undefined;
}
circle.style = matchLength() || matchExtentKeyword();
circle.value = "circle";
return circle;
};
const matchEllipse = (): Shape | undefined => {
const ellipse = match({
type: "shape",
pattern: /^(ellipse)/i,
captureIndex: 0,
}) as Shape | undefined;
if (!ellipse) {
return undefined;
}
ellipse.style =
matchPositioning() || matchDistance() || matchExtentKeyword();
ellipse.value = "ellipse";
return ellipse;
};
const matchExtentKeyword = (): ExtentKeyword | undefined =>
match({
type: "extent-keyword",
pattern: tokens.extentKeywords,
captureIndex: 1,
});
const matchAtPosition = (): Position | undefined => {
if (!match({ type: "position", pattern: /^at/, captureIndex: 0 })) {
return undefined;
}
const positioning = matchPositioning();
if (!positioning) {
error({ message: "Missing positioning value" });
}
return positioning;
};
const matchPositioning = (): Position | undefined => {
const location = matchCoordinates();
if (!location.x && !location.y) {
return undefined;
}
return {
type: "position",
value: location,
};
};
const matchCoordinates = (): PositionValue => ({
x: matchDistance(),
y: matchDistance(),
});
const matchListing = <T>({
matcher,
}: {
matcher: () => T | undefined;
}): Array<T> => {
const captures = matcher();
const result: Array<T> = [];
if (!captures) {
return result;
}
result.push(captures);
while (scan({ regexp: tokens.comma })) {
const nextCapture =
matcher() ?? error({ message: "One extra comma" });
result.push(nextCapture);
}
return result;
};
const matchColorStop = (): ColorStop => {
const color = matchColor() ?? error({ message: "Expected color definition" });
const length = matchDistance();
return { ...color, length };
};
const matchColor = (): Color | undefined =>
matchHexColor() ||
matchHSLAColor() ||
matchHSLColor() ||
matchRGBAColor() ||
matchRGBColor() ||
matchVarColor() ||
matchLiteralColor();
const matchLiteralColor = (): Color | undefined =>
match({ type: "literal", pattern: tokens.literalColor, captureIndex: 0 });
const matchHexColor = (): Color | undefined =>
match({ type: "hex", pattern: tokens.hexColor, captureIndex: 1 });
const matchRGBColor = (): Color | undefined =>
matchCall({
pattern: tokens.rgbColor,
callback: () => ({
type: "rgb",
value: matchListing({ matcher: matchNumber }),
}),
});
const matchRGBAColor = (): Color | undefined =>
matchCall({
pattern: tokens.rgbaColor,
callback: () => ({
type: "rgba",
value: matchListing({ matcher: matchNumber }),
}),
});
const matchVarColor = (): Color | undefined =>
matchCall({
pattern: tokens.varColor,
callback: () => ({
type: "var",
value: matchVariableName(),
}),
});
const matchHSLColor = (): Color | undefined =>
matchCall({
pattern: tokens.hslColor,
callback: () => {
const lookahead = scan({ regexp: tokens.percentageValue });
if (lookahead) {
error({
message:
"HSL hue value must be a number in degrees (0-360) or normalized (-360 to 360), not a percentage",
});
}
const hue = matchNumber();
scan({ regexp: tokens.comma });
let captures = scan({ regexp: tokens.percentageValue });
const sat = captures ? captures[1] : null;
scan({ regexp: tokens.comma });
captures = scan({ regexp: tokens.percentageValue });
const light = captures ? captures[1] : null;
const ensuredSat =
sat ??
error({
message: "Expected percentage value for saturation and lightness in HSL",
});
const ensuredLight =
light ??
error({
message: "Expected percentage value for saturation and lightness in HSL",
});
return {
type: "hsl",
value: [hue, ensuredSat, ensuredLight],
};
},
});
const matchHSLAColor = (): Color | undefined =>
matchCall({
pattern: tokens.hslaColor,
callback: () => {
const hue = matchNumber();
scan({ regexp: tokens.comma });
let captures = scan({ regexp: tokens.percentageValue });
const sat = captures ? captures[1] : null;
scan({ regexp: tokens.comma });
captures = scan({ regexp: tokens.percentageValue });
const light = captures ? captures[1] : null;
scan({ regexp: tokens.comma });
const alpha = matchNumber();
const ensuredSat =
sat ??
error({
message:
"Expected percentage value for saturation and lightness in HSLA",
});
const ensuredLight =
light ??
error({
message:
"Expected percentage value for saturation and lightness in HSLA",
});
return {
type: "hsla",
value: [hue, ensuredSat, ensuredLight, alpha],
};
},
});
const matchVariableName = (): string => {
const captures =
scan({ regexp: tokens.variableName }) ??
error({ message: "Expected CSS variable name" });
return captures[1];
};
const matchNumber = (): string => {
const captures =
scan({ regexp: tokens.number }) ?? error({ message: "Expected number" });
return captures[1];
};
const matchDistance = (): Distance | undefined =>
match({ type: "%", pattern: tokens.percentageValue, captureIndex: 1 }) ||
matchPositionKeyword() ||
matchCalc() ||
matchLength();
const matchPositionKeyword = (): Distance | undefined =>
match({
type: "position-keyword",
pattern: tokens.positionKeywords,
captureIndex: 1,
});
const matchCalc = (): Distance | undefined =>
matchCall({
pattern: tokens.calcValue,
callback: () => {
let openParenCount = 1;
let index = 0;
while (openParenCount > 0 && index < input.length) {
const char = input.charAt(index);
if (char === "(") {
openParenCount++;
} else if (char === ")") {
openParenCount--;
}
index++;
}
if (openParenCount > 0) {
error({ message: "Missing closing parenthesis in calc() expression" });
}
const calcContent = input.slice(0, index - 1);
consume({ size: index - 1 });
return {
type: "calc",
value: calcContent,
};
},
});
const matchLength = (): Distance | undefined =>
match({ type: "px", pattern: tokens.pixelValue, captureIndex: 1 }) ||
match({ type: "em", pattern: tokens.emValue, captureIndex: 1 });
const match = <TType extends string>({
type,
pattern,
captureIndex,
}: {
type: TType;
pattern: RegExp;
captureIndex: number;
}): { type: TType; value: string } | undefined => {
const captures = scan({ regexp: pattern });
if (!captures) {
return undefined;
}
return {
type,
value: captures[captureIndex],
};
};
const scan = ({ regexp }: { regexp: RegExp }): RegExpExecArray | null => {
const blankCaptures = /^[\n\r\t\s]+/.exec(input);
if (blankCaptures) {
consume({ size: blankCaptures[0].length });
}
const captures = regexp.exec(input);
if (captures) {
consume({ size: captures[0].length });
}
return captures;
};
const consume = ({ size }: { size: number }): void => {
input = input.slice(size);
};
export const parseGradient = ({ code }: { code: string }): Array<GradientAst> => {
input = code.toString().trim();
if (input.endsWith(";")) {
input = input.slice(0, -1);
}
return getAst();
};
export const GradientParser = {
parse: parseGradient,
};

View File

@ -1,10 +1,10 @@
import { toast } from "sonner";
import { MediaAsset } from "@/types/assets";
import { getMediaTypeFromFile } from "@/lib/media-utils";
import { getVideoInfo } from "./mediabunny-utils";
import { getVideoInfo } from "./mediabunny";
import { Input, ALL_FORMATS, BlobSource, VideoSampleSink } from "mediabunny";
export interface ProcessedMediaAsset extends Omit<MediaAsset, "id"> {}
export interface ProcessedMediaAsset extends Omit<MediaAsset, "id"> { }
export async function generateThumbnail({
videoFile,

View File

@ -7,9 +7,11 @@ import { isMainTrack } from "./track-utils";
function getTrackAtY({
mouseY,
tracks,
verticalDragDirection,
}: {
mouseY: number;
tracks: TimelineTrack[];
verticalDragDirection?: "up" | "down" | null;
}): { trackIndex: number; relativeY: number } | null {
let cumulativeHeight = 0;
@ -25,6 +27,18 @@ function getTrackAtY({
};
}
if (i < tracks.length - 1 && verticalDragDirection) {
const gapTop = trackBottom;
const gapBottom = gapTop + TRACK_GAP;
if (mouseY >= gapTop && mouseY < gapBottom) {
const isDraggingUp = verticalDragDirection === "up";
return {
trackIndex: isDraggingUp ? i : i + 1,
relativeY: isDraggingUp ? trackHeight - 1 : 0,
};
}
}
cumulativeHeight += trackHeight + TRACK_GAP;
}
@ -64,7 +78,7 @@ function findInsertIndex({
}): { index: number; position: "above" | "below" } {
const mainTrackIndex = getMainTrackIndex({ tracks });
if (elementType === "audio") {
if (elementType === "audio") {
if (preferredIndex <= mainTrackIndex) {
return { index: mainTrackIndex + 1, position: "below" };
}
@ -96,6 +110,7 @@ export function computeDropTarget({
elementDuration,
pixelsPerSecond,
zoomLevel,
verticalDragDirection,
startTimeOverride,
excludeElementId,
}: ComputeDropTargetParams): DropTarget {
@ -120,7 +135,7 @@ export function computeDropTarget({
return { trackIndex: 0, isNewTrack: true, insertPosition: null, xPosition };
}
const trackAtMouse = getTrackAtY({ mouseY, tracks });
const trackAtMouse = getTrackAtY({ mouseY, tracks, verticalDragDirection });
if (!trackAtMouse) {
const isAboveAllTracks = mouseY < 0;
@ -178,11 +193,16 @@ export function computeDropTarget({
};
}
let insertAbove = isInUpperHalf;
if (!isTrackCompatible && verticalDragDirection) {
insertAbove = verticalDragDirection === "up";
}
const { index, position } = findInsertIndex({
elementType,
tracks,
preferredIndex: trackIndex,
insertAbove: isInUpperHalf,
insertAbove,
});
return {
@ -200,24 +220,15 @@ export function getDropLineY({
dropTarget: DropTarget;
tracks: TimelineTrack[];
}): number {
const safeTrackIndex = Math.min(
Math.max(dropTarget.trackIndex, 0),
tracks.length,
);
let y = 0;
for (let i = 0; i < dropTarget.trackIndex && i < tracks.length; i++) {
for (let i = 0; i < safeTrackIndex; i++) {
y += TRACK_HEIGHTS[tracks[i].type] + TRACK_GAP;
}
if (!dropTarget.isNewTrack && dropTarget.trackIndex < tracks.length) {
return y;
}
if (
dropTarget.insertPosition === "below" &&
dropTarget.trackIndex <= tracks.length
) {
if (dropTarget.trackIndex > 0 && dropTarget.trackIndex <= tracks.length) {
y += TRACK_HEIGHTS[tracks[dropTarget.trackIndex - 1]?.type ?? "video"];
}
}
return y;
}

View File

@ -175,9 +175,9 @@ export function buildUploadAudioElement({
name: string;
duration: number;
startTime: number;
buffer: AudioBuffer;
buffer?: AudioBuffer;
}): CreateUploadAudioElement {
return {
const element: CreateUploadAudioElement = {
type: "audio",
sourceType: "upload",
mediaId,
@ -188,8 +188,11 @@ export function buildUploadAudioElement({
trimEnd: 0,
volume: 1,
muted: false,
buffer,
};
if (buffer) {
element.buffer = buffer;
}
return element;
}
export function buildLibraryAudioElement({
@ -203,9 +206,9 @@ export function buildLibraryAudioElement({
name: string;
duration: number;
startTime: number;
buffer: AudioBuffer;
buffer?: AudioBuffer;
}): CreateLibraryAudioElement {
return {
const element: CreateLibraryAudioElement = {
type: "audio",
sourceType: "library",
sourceUrl,
@ -216,6 +219,9 @@ export function buildLibraryAudioElement({
trimEnd: 0,
volume: 1,
muted: false,
buffer,
};
if (buffer) {
element.buffer = buffer;
}
return element;
}

View File

@ -2,6 +2,7 @@ import { TimelineTrack } from "@/types/timeline";
export * from "./track-utils";
export * from "./element-utils";
export * from "./zoom-utils";
export function calculateTotalDuration({
tracks,

View File

@ -32,7 +32,7 @@ export function getTrackColor({ type }: { type: TrackType }) {
export function getTrackClasses({ type }: { type: TrackType }) {
const colors = TRACK_COLORS[type];
return `${colors.solid} ${colors.background} ${colors.border}`.trim();
return `${colors.background}`.trim();
}
export function getTrackHeight({ type }: { type: TrackType }): number {
@ -128,6 +128,48 @@ export function buildEmptyTrack({
}
}
export function getDefaultInsertIndexForTrack({
tracks,
trackType,
}: {
tracks: TimelineTrack[];
trackType: TrackType;
}): number {
if (trackType === "audio") {
return tracks.length;
}
const mainTrackIndex = tracks.findIndex((track) => isMainTrack(track));
if (mainTrackIndex >= 0) {
return mainTrackIndex;
}
const firstAudioTrackIndex = tracks.findIndex(
(track) => track.type === "audio",
);
if (firstAudioTrackIndex >= 0) {
return firstAudioTrackIndex;
}
return tracks.length;
}
export function getHighestInsertIndexForTrack({
tracks,
trackType,
}: {
tracks: TimelineTrack[];
trackType: TrackType;
}): number {
const mainTrackIndex = tracks.findIndex((track) => isMainTrack(track));
if (trackType === "audio") {
return mainTrackIndex >= 0 ? mainTrackIndex + 1 : tracks.length;
}
return 0;
}
export function isMainTrack(track: TimelineTrack): track is VideoTrack {
return track.type === "video" && track.isMain === true;
}

View File

@ -0,0 +1,22 @@
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
export function getTimelineZoomMin({
duration,
containerWidth,
}: {
duration: number;
containerWidth: number | null | undefined;
}): number {
const safeDuration = Math.max(duration, 1);
const paddedDuration =
safeDuration + TIMELINE_CONSTANTS.PLAYHEAD_LOOKAHEAD_SECONDS;
const safeContainerWidth = containerWidth ?? 1000;
const zoomToFit =
safeContainerWidth /
(paddedDuration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND);
return Math.min(
TIMELINE_CONSTANTS.ZOOM_MAX,
Math.max(TIMELINE_CONSTANTS.ZOOM_MIN, zoomToFit),
);
}

View File

@ -1,5 +1,6 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
import { webEnv } from "@opencut/env/web";
export function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs));
@ -17,6 +18,10 @@ export function capitalizeFirstLetter({ string }: { string: string }) {
return string.charAt(0).toUpperCase() + string.slice(1);
}
export function uppercase({ string }: { string: string }) {
return string.toUpperCase();
}
export function generateUUID(): string {
if (
typeof crypto !== "undefined" &&

View File

@ -1,71 +0,0 @@
/**
* True zero-knowledge encryption utilities
* Keys are generated randomly in the browser and never derived from server secrets
*/
export interface ZeroKnowledgeEncryptionResult {
encryptedData: ArrayBuffer;
key: ArrayBuffer;
iv: ArrayBuffer;
}
/**
* Encrypt data with a randomly generated key (true zero-knowledge)
*/
export async function encryptWithRandomKey(
data: ArrayBuffer
): Promise<ZeroKnowledgeEncryptionResult> {
// Generate a truly random 256-bit key
const key = crypto.getRandomValues(new Uint8Array(32));
// Generate random IV
const iv = crypto.getRandomValues(new Uint8Array(12));
// Import the key for encryption
const cryptoKey = await crypto.subtle.importKey(
"raw",
key,
{ name: "AES-GCM" },
false,
["encrypt"]
);
// Encrypt the data
const encryptedResult = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv },
cryptoKey,
data
);
// For AES-GCM, we need to append the authentication tag
// The encrypted result contains both ciphertext and tag
return {
encryptedData: encryptedResult,
key: key.buffer,
iv: iv.buffer,
};
}
/**
* Convert ArrayBuffer to base64 string for transmission
*/
export function arrayBufferToBase64(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer);
let binary = "";
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
}
/**
* Convert base64 string back to ArrayBuffer
*/
export function base64ToArrayBuffer(base64: string): ArrayBuffer {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes.buffer;
}

View File

@ -16,19 +16,23 @@ export class VideoCache {
private sinks = new Map<string, VideoSinkData>();
private initPromises = new Map<string, Promise<void>>();
async getFrameAt(
mediaId: string,
file: File,
time: number
): Promise<WrappedCanvas | null> {
await this.ensureSink(mediaId, file);
async getFrameAt({
mediaId,
file,
time,
}: {
mediaId: string;
file: File;
time: number;
}): Promise<WrappedCanvas | null> {
await this.ensureSink({ mediaId, file });
const sinkData = this.sinks.get(mediaId);
if (!sinkData) return null;
if (
sinkData.currentFrame &&
this.isFrameValid(sinkData.currentFrame, time)
this.isFrameValid({ frame: sinkData.currentFrame, time })
) {
return sinkData.currentFrame;
}
@ -39,20 +43,29 @@ export class VideoCache {
time >= sinkData.lastTime &&
time < sinkData.lastTime + 2.0
) {
const frame = await this.iterateToTime(sinkData, time);
const frame = await this.iterateToTime({ sinkData, targetTime: time });
if (frame) return frame;
}
return await this.seekToTime(sinkData, time);
return await this.seekToTime({ sinkData, time });
}
private isFrameValid(frame: WrappedCanvas, time: number): boolean {
private isFrameValid({
frame,
time,
}: {
frame: WrappedCanvas;
time: number;
}): boolean {
return time >= frame.timestamp && time < frame.timestamp + frame.duration;
}
private async iterateToTime(
sinkData: VideoSinkData,
targetTime: number
): Promise<WrappedCanvas | null> {
private async iterateToTime({
sinkData,
targetTime,
}: {
sinkData: VideoSinkData;
targetTime: number;
}): Promise<WrappedCanvas | null> {
if (!sinkData.iterator) return null;
try {
@ -64,7 +77,7 @@ export class VideoCache {
sinkData.currentFrame = frame;
sinkData.lastTime = frame.timestamp;
if (this.isFrameValid(frame, targetTime)) {
if (this.isFrameValid({ frame, time: targetTime })) {
return frame;
}
@ -77,10 +90,13 @@ export class VideoCache {
return null;
}
private async seekToTime(
sinkData: VideoSinkData,
time: number
): Promise<WrappedCanvas | null> {
private async seekToTime({
sinkData,
time,
}: {
sinkData: VideoSinkData;
time: number;
}): Promise<WrappedCanvas | null> {
try {
if (sinkData.iterator) {
await sinkData.iterator.return();
@ -102,7 +118,13 @@ export class VideoCache {
return null;
}
private async ensureSink(mediaId: string, file: File): Promise<void> {
private async ensureSink({
mediaId,
file,
}: {
mediaId: string;
file: File;
}): Promise<void> {
if (this.sinks.has(mediaId)) return;
if (this.initPromises.has(mediaId)) {
@ -110,7 +132,7 @@ export class VideoCache {
return;
}
const initPromise = this.initializeSink(mediaId, file);
const initPromise = this.initializeSink({ mediaId, file });
this.initPromises.set(mediaId, initPromise);
try {
@ -119,7 +141,13 @@ export class VideoCache {
this.initPromises.delete(mediaId);
}
}
private async initializeSink(mediaId: string, file: File): Promise<void> {
private async initializeSink({
mediaId,
file,
}: {
mediaId: string;
file: File;
}): Promise<void> {
try {
const input = new Input({
source: new BlobSource(file),
@ -153,7 +181,7 @@ export class VideoCache {
}
}
clearVideo(mediaId: string): void {
clearVideo({ mediaId }: { mediaId: string }): void {
const sinkData = this.sinks.get(mediaId);
if (sinkData) {
if (sinkData.iterator) {
@ -168,7 +196,7 @@ export class VideoCache {
clearAll(): void {
for (const [mediaId] of this.sinks) {
this.clearVideo(mediaId);
this.clearVideo({ mediaId });
}
}

View File

@ -32,7 +32,7 @@ export class BlurBackgroundNode extends BaseNode<BlurBackgroundNodeParams> {
offscreen = new OffscreenCanvas(renderer.width, renderer.height);
const ctx = offscreen.getContext("2d");
if (!ctx) {
throw new Error("Failed to get offscreen canvas context");
throw new Error("failed to get offscreen canvas context");
}
offscreenCtx = ctx;
} catch {
@ -41,7 +41,7 @@ export class BlurBackgroundNode extends BaseNode<BlurBackgroundNodeParams> {
offscreen.height = renderer.height;
const ctx = offscreen.getContext("2d");
if (!ctx) {
throw new Error("Failed to get canvas context");
throw new Error("failed to get canvas context");
}
offscreenCtx = ctx;
}
@ -55,30 +55,21 @@ export class BlurBackgroundNode extends BaseNode<BlurBackgroundNodeParams> {
renderer.context = originalContext;
const zoomScale = 1.4;
const scaledWidth = renderer.width * zoomScale;
const scaledHeight = renderer.height * zoomScale;
const offsetX = (renderer.width - scaledWidth) / 2;
const offsetY = (renderer.height - scaledHeight) / 2;
renderer.context.save();
renderer.context.filter = `blur(${this.blurIntensity}px)`;
const scale = Math.max(
renderer.width / offscreen.width,
renderer.height / offscreen.height,
renderer.context.drawImage(
offscreen as CanvasImageSource,
offsetX,
offsetY,
scaledWidth,
scaledHeight,
);
const scaledWidth = offscreen.width * scale;
const scaledHeight = offscreen.height * scale;
const x = (renderer.width - scaledWidth) / 2;
const y = (renderer.height - scaledHeight) / 2;
if (offscreen instanceof OffscreenCanvas) {
renderer.context.drawImage(
offscreen as unknown as CanvasImageSource,
x,
y,
scaledWidth,
scaledHeight,
);
} else {
renderer.context.drawImage(offscreen, x, y, scaledWidth, scaledHeight);
}
renderer.context.restore();
}
}

View File

@ -1,3 +1,4 @@
import { drawCssBackground } from "@/lib/gradients";
import { CanvasRenderer } from "../canvas-renderer";
import { BaseNode } from "./base-node";
@ -14,6 +15,16 @@ export class ColorNode extends BaseNode<ColorNodeParams> {
}
async render({ renderer }: { renderer: CanvasRenderer }) {
if (/gradient\(/i.test(this.color)) {
drawCssBackground({
ctx: renderer.context,
width: renderer.width,
height: renderer.height,
css: this.color,
});
return;
}
renderer.context.fillStyle = this.color;
renderer.context.fillRect(0, 0, renderer.width, renderer.height);
}

View File

@ -1,4 +1,4 @@
import type { TimelineTrack, TimelineElement } from "@/types/timeline";
import type { TimelineTrack } from "@/types/timeline";
import { MediaAsset } from "@/types/assets";
import { RootNode } from "./nodes/root-node";
import { VideoNode } from "./nodes/video-node";
@ -9,7 +9,7 @@ import { ColorNode } from "./nodes/color-node";
import { BlurBackgroundNode } from "./nodes/blur-background-node";
import { TBackground, TCanvasSize } from "@/types/project";
import { DEFAULT_BLUR_INTENSITY } from "@/constants/project-constants";
import { canTracktHaveAudio } from "@/lib/timeline";
import { isMainTrack } from "@/lib/timeline";
export type BuildSceneParams = {
canvasSize: TCanvasSize;
@ -25,68 +25,81 @@ export function buildScene(params: BuildSceneParams) {
const rootNode = new RootNode({ duration });
const mediaMap = new Map(mediaAssets.map((m) => [m.id, m]));
const elements = tracks
.slice()
.reverse()
.filter((track) => !("hidden" in track && track.hidden))
.flatMap((track): TimelineElement[] =>
track.elements.filter((element) => !("hidden" in element && element.hidden)),
);
const visibleTracks = tracks.filter(
(track) => !("hidden" in track && track.hidden),
);
const mainTrack = visibleTracks.find((track) => isMainTrack(track)) ?? null;
const orderedTracksTopToBottom = [
...visibleTracks.filter((track) => !isMainTrack(track)),
...(mainTrack ? [mainTrack] : []),
];
const orderedTracksBottomToTop = orderedTracksTopToBottom.slice().reverse();
const contentNodes = [];
for (const element of elements) {
if (element.type === "video" || element.type === "image") {
const mediaAsset = mediaMap.get(element.mediaId);
if (mediaAsset && mediaAsset.file) {
if (mediaAsset.type === "video") {
contentNodes.push(
new VideoNode({
file: mediaAsset.file,
duration: element.duration,
timeOffset: element.startTime,
trimStart: element.trimStart,
trimEnd: element.trimEnd,
}),
);
} else if (mediaAsset.type === "image") {
contentNodes.push(
new ImageNode({
file: mediaAsset.file,
duration: element.duration,
timeOffset: element.startTime,
trimStart: element.trimStart,
trimEnd: element.trimEnd,
}),
);
for (const track of orderedTracksBottomToTop) {
const elements = track.elements
.filter((element) => !("hidden" in element && element.hidden))
.slice()
.sort((a, b) => {
if (a.startTime !== b.startTime) return a.startTime - b.startTime;
return a.id.localeCompare(b.id);
});
for (const element of elements) {
if (element.type === "video" || element.type === "image") {
const mediaAsset = mediaMap.get(element.mediaId);
if (mediaAsset && mediaAsset.file) {
if (mediaAsset.type === "video") {
contentNodes.push(
new VideoNode({
file: mediaAsset.file,
duration: element.duration,
timeOffset: element.startTime,
trimStart: element.trimStart,
trimEnd: element.trimEnd,
}),
);
} else if (mediaAsset.type === "image") {
contentNodes.push(
new ImageNode({
file: mediaAsset.file,
duration: element.duration,
timeOffset: element.startTime,
trimStart: element.trimStart,
trimEnd: element.trimEnd,
}),
);
}
}
// TODO: Add AudioNode for audio files
}
}
if (element.type === "text") {
contentNodes.push(
new TextNode({
...element,
canvasCenter: { x: canvasSize.width / 2, y: canvasSize.height / 2 },
textBaseline: "middle",
}),
);
}
if (element.type === "text") {
contentNodes.push(
new TextNode({
...element,
canvasCenter: { x: canvasSize.width / 2, y: canvasSize.height / 2 },
textBaseline: "middle",
}),
);
}
if (element.type === "sticker") {
contentNodes.push(
new StickerNode({
iconName: element.iconName,
duration: element.duration,
timeOffset: element.startTime,
trimStart: element.trimStart,
trimEnd: element.trimEnd,
transform: element.transform,
opacity: element.opacity,
color: element.color,
}),
);
if (element.type === "sticker") {
contentNodes.push(
new StickerNode({
iconName: element.iconName,
duration: element.duration,
timeOffset: element.startTime,
trimStart: element.trimStart,
trimEnd: element.trimEnd,
transform: element.transform,
opacity: element.opacity,
color: element.color,
}),
);
}
}
}
@ -97,15 +110,16 @@ export function buildScene(params: BuildSceneParams) {
contentNodes,
}),
);
} else if (
background.type === "color" &&
background.color !== "transparent"
) {
rootNode.add(new ColorNode({ color: background.color }));
}
for (const node of contentNodes) {
rootNode.add(node);
for (const node of contentNodes) {
rootNode.add(node);
}
} else {
if (background.type === "color" && background.color !== "transparent") {
rootNode.add(new ColorNode({ color: background.color }));
}
for (const node of contentNodes) {
rootNode.add(node);
}
}
return rootNode;

View File

@ -1,4 +1,4 @@
import { IndexedDBAdapter } from "@/lib/storage/indexeddb-adapter";
import { IndexedDBAdapter } from "@/services/storage/indexeddb-adapter";
import { StorageMigration } from "./base";
import { StorageVersionManager } from "./version-manager";

View File

@ -1,5 +1,5 @@
import { IndexedDBAdapter } from "@/lib/storage/indexeddb-adapter";
import { SerializedScene } from "@/lib/storage/types";
import { IndexedDBAdapter } from "@/services/storage/indexeddb-adapter";
import { SerializedScene } from "@/services/storage/types";
import { buildDefaultScene } from "@/lib/scene-utils";
import { TScene } from "@/types/timeline";
import { StorageMigration } from "./base";

View File

@ -4,7 +4,7 @@ import {
DEFAULT_COLOR,
DEFAULT_FPS,
} from "@/constants/project-constants";
import { IndexedDBAdapter } from "@/lib/storage/indexeddb-adapter";
import { IndexedDBAdapter } from "@/services/storage/indexeddb-adapter";
import { StorageMigration } from "./base";
type ProjectRecord = Record<string, unknown>;
@ -54,19 +54,19 @@ function migrateProject({
const metadata = isRecord(metadataValue)
? {
id: getStringValue({ value: metadataValue.id, fallback: projectId }),
name: getStringValue({ value: metadataValue.name, fallback: "" }),
thumbnail: getStringValue({ value: metadataValue.thumbnail }),
createdAt: normalizeDateString({ value: metadataValue.createdAt }),
updatedAt: normalizeDateString({ value: metadataValue.updatedAt }),
}
id: getStringValue({ value: metadataValue.id, fallback: projectId }),
name: getStringValue({ value: metadataValue.name, fallback: "" }),
thumbnail: getStringValue({ value: metadataValue.thumbnail }),
createdAt: normalizeDateString({ value: metadataValue.createdAt }),
updatedAt: normalizeDateString({ value: metadataValue.updatedAt }),
}
: {
id: projectId,
name: getStringValue({ value: project.name, fallback: "" }),
thumbnail: getStringValue({ value: project.thumbnail }),
createdAt,
updatedAt,
};
id: projectId,
name: getStringValue({ value: project.name, fallback: "" }),
thumbnail: getStringValue({ value: project.thumbnail }),
createdAt,
updatedAt,
};
const scenesValue = project.scenes;
const scenes = Array.isArray(scenesValue) ? scenesValue : [];
@ -81,31 +81,31 @@ function migrateProject({
const settingsValue = project.settings;
const settings = isRecord(settingsValue)
? {
fps: getNumberValue({
value: settingsValue.fps,
fallback: DEFAULT_FPS,
}),
canvasSize: getCanvasSizeValue({
value: settingsValue.canvasSize,
fallback: DEFAULT_CANVAS_SIZE,
}),
background: getBackgroundValue({
value: settingsValue.background,
}),
}
fps: getNumberValue({
value: settingsValue.fps,
fallback: DEFAULT_FPS,
}),
canvasSize: getCanvasSizeValue({
value: settingsValue.canvasSize,
fallback: DEFAULT_CANVAS_SIZE,
}),
background: getBackgroundValue({
value: settingsValue.background,
}),
}
: {
fps: getNumberValue({ value: project.fps, fallback: DEFAULT_FPS }),
canvasSize: getCanvasSizeValue({
value: project.canvasSize,
fallback: DEFAULT_CANVAS_SIZE,
}),
background: getBackgroundValue({
value: project.background,
backgroundType: project.backgroundType,
backgroundColor: project.backgroundColor,
blurIntensity: project.blurIntensity,
}),
};
fps: getNumberValue({ value: project.fps, fallback: DEFAULT_FPS }),
canvasSize: getCanvasSizeValue({
value: project.canvasSize,
fallback: DEFAULT_CANVAS_SIZE,
}),
background: getBackgroundValue({
value: project.background,
backgroundType: project.backgroundType,
backgroundColor: project.backgroundColor,
blurIntensity: project.blurIntensity,
}),
};
const currentSceneId = getCurrentSceneId({
value: project.currentSceneId,

View File

@ -1,4 +1,4 @@
import { IndexedDBAdapter } from "@/lib/storage/indexeddb-adapter";
import { IndexedDBAdapter } from "@/services/storage/indexeddb-adapter";
type StorageVersionRecord = {
version: number;

View File

@ -9,7 +9,8 @@ import type {
SerializedScene,
} from "./types";
import { SavedSoundsData, SavedSound, SoundEffect } from "@/types/sounds";
import { migrations, runStorageMigrations } from "@/lib/migrations";
import { migrations, runStorageMigrations } from "@/services/storage/migrations";
import { TimelineTrack } from "@/types/timeline";
class StorageService {
private projectsAdapter: IndexedDBAdapter<SerializedProject>;
@ -62,12 +63,29 @@ class StorageService {
return { mediaMetadataAdapter, mediaAssetsAdapter };
}
private stripAudioBuffers({
tracks,
}: {
tracks: TimelineTrack[];
}): TimelineTrack[] {
return tracks.map((track) => {
if (track.type !== "audio") return track;
return {
...track,
elements: track.elements.map((element) => {
const { buffer: _buffer, ...rest } = element;
return rest;
}),
};
});
}
async saveProject({ project }: { project: TProject }): Promise<void> {
const serializedScenes: SerializedScene[] = project.scenes.map((scene) => ({
id: scene.id,
name: scene.name,
isMain: scene.isMain,
tracks: scene.tracks,
tracks: this.stripAudioBuffers({ tracks: scene.tracks }),
bookmarks: scene.bookmarks,
createdAt: scene.createdAt.toISOString(),
updatedAt: scene.updatedAt.toISOString(),

View File

@ -68,12 +68,18 @@ export const tabs: { [key in Tab]: { icon: LucideIcon; label: string } } = {
},
};
type MediaViewMode = "grid" | "list";
interface AssetsPanelStore {
activeTab: Tab;
setActiveTab: (tab: Tab) => void;
highlightMediaId: string | null;
requestRevealMedia: (mediaId: string) => void;
clearHighlight: () => void;
/* Media */
mediaViewMode: MediaViewMode;
setMediaViewMode: (mode: MediaViewMode) => void;
}
export const useAssetsPanelStore = create<AssetsPanelStore>((set) => ({
@ -83,4 +89,6 @@ export const useAssetsPanelStore = create<AssetsPanelStore>((set) => ({
requestRevealMedia: (mediaId) =>
set({ activeTab: "media", highlightMediaId: mediaId }),
clearHighlight: () => set({ highlightMediaId: null }),
mediaViewMode: "grid",
setMediaViewMode: (mode) => set({ mediaViewMode: mode }),
}));

View File

@ -1,225 +1,93 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
import { PANEL_CONFIG } from "@/constants/editor-constants";
export type PanelPreset =
| "default"
| "media"
| "inspector"
| "vertical-preview";
interface PanelSizes {
toolsPanel: number;
previewPanel: number;
propertiesPanel: number;
export interface PanelSizes {
tools: number;
preview: number;
properties: number;
mainContent: number;
timeline: number;
}
export const PRESET_CONFIGS: Record<PanelPreset, PanelSizes> = {
default: {
toolsPanel: 25,
previewPanel: 50,
propertiesPanel: 25,
mainContent: 70,
timeline: 30,
},
media: {
toolsPanel: 30,
previewPanel: 45,
propertiesPanel: 25,
mainContent: 100,
timeline: 25,
},
inspector: {
toolsPanel: 30,
previewPanel: 70,
propertiesPanel: 30,
mainContent: 75,
timeline: 25,
},
"vertical-preview": {
toolsPanel: 30,
previewPanel: 40,
propertiesPanel: 30,
mainContent: 75,
timeline: 25,
},
};
export type PanelId = keyof PanelSizes;
interface PanelState extends PanelSizes {
activePreset: PanelPreset;
presetCustomSizes: Record<PanelPreset, Partial<PanelSizes>>;
resetCounter: number;
mediaViewMode: "grid" | "list";
setToolsPanel: (size: number) => void;
setPreviewPanel: (size: number) => void;
setPropertiesPanel: (size: number) => void;
setMainContent: (size: number) => void;
setTimeline: (size: number) => void;
setMediaViewMode: (mode: "grid" | "list") => void;
setActivePreset: (preset: PanelPreset) => void;
resetPreset: (preset: PanelPreset) => void;
getCurrentPresetSizes: () => PanelSizes;
interface PanelState {
panels: PanelSizes;
setPanel: (panel: PanelId, size: number) => void;
setPanels: (sizes: Partial<PanelSizes>) => void;
resetPanels: () => void;
}
export const usePanelStore = create<PanelState>()(
persist(
(set, get) => ({
...PRESET_CONFIGS.default,
activePreset: "default" as PanelPreset,
presetCustomSizes: {
default: {},
media: {},
inspector: {},
"vertical-preview": {},
},
resetCounter: 0,
mediaViewMode: "grid" as const,
setToolsPanel: (size) => {
const { activePreset, presetCustomSizes } = get();
set({
toolsPanel: size,
presetCustomSizes: {
...presetCustomSizes,
[activePreset]: {
...presetCustomSizes[activePreset],
toolsPanel: size,
},
(set) => ({
...PANEL_CONFIG,
setPanel: (panel, size) =>
set((state) => ({
panels: {
...state.panels,
[panel]: size,
},
});
},
setPreviewPanel: (size) => {
const { activePreset, presetCustomSizes } = get();
set({
previewPanel: size,
presetCustomSizes: {
...presetCustomSizes,
[activePreset]: {
...presetCustomSizes[activePreset],
previewPanel: size,
},
})),
setPanels: (sizes) =>
set((state) => ({
panels: {
...state.panels,
...sizes,
},
});
},
setPropertiesPanel: (size) => {
const { activePreset, presetCustomSizes } = get();
set({
propertiesPanel: size,
presetCustomSizes: {
...presetCustomSizes,
[activePreset]: {
...presetCustomSizes[activePreset],
propertiesPanel: size,
},
},
});
},
setMainContent: (size) => {
const { activePreset, presetCustomSizes } = get();
set({
mainContent: size,
presetCustomSizes: {
...presetCustomSizes,
[activePreset]: {
...presetCustomSizes[activePreset],
mainContent: size,
},
},
});
},
setTimeline: (size) => {
const { activePreset, presetCustomSizes } = get();
set({
timeline: size,
presetCustomSizes: {
...presetCustomSizes,
[activePreset]: {
...presetCustomSizes[activePreset],
timeline: size,
},
},
});
},
setMediaViewMode: (mode) => set({ mediaViewMode: mode }),
setActivePreset: (preset) => {
const {
activePreset: currentPreset,
presetCustomSizes,
toolsPanel,
previewPanel,
propertiesPanel,
mainContent,
timeline,
} = get();
const updatedPresetCustomSizes = {
...presetCustomSizes,
[currentPreset]: {
toolsPanel,
previewPanel,
propertiesPanel,
mainContent,
timeline,
},
};
const defaultSizes = PRESET_CONFIGS[preset];
const customSizes = updatedPresetCustomSizes[preset] || {};
const finalSizes = { ...defaultSizes, ...customSizes } as PanelSizes;
set({
activePreset: preset,
presetCustomSizes: updatedPresetCustomSizes,
...finalSizes,
});
},
resetPreset: (preset) => {
const { presetCustomSizes, activePreset, resetCounter } = get();
const defaultSizes = PRESET_CONFIGS[preset];
const newPresetCustomSizes = {
...presetCustomSizes,
[preset]: {},
};
const updates: Partial<PanelState> = {
presetCustomSizes: newPresetCustomSizes,
resetCounter: resetCounter + 1,
};
if (preset === activePreset) {
Object.assign(updates, defaultSizes);
}
set(updates);
},
getCurrentPresetSizes: () => {
const {
toolsPanel,
previewPanel,
propertiesPanel,
mainContent,
timeline,
} = get();
return {
toolsPanel,
previewPanel,
propertiesPanel,
mainContent,
timeline,
};
},
})),
resetPanels: () => set({ ...PANEL_CONFIG }),
}),
{
name: "panel-sizes",
version: 2,
migrate: (persistedState) => {
const state = persistedState as
| {
panels?: Partial<PanelSizes> | null;
toolsPanel?: number;
previewPanel?: number;
propertiesPanel?: number;
mainContent?: number;
timeline?: number;
tools?: number;
preview?: number;
properties?: number;
}
| undefined
| null;
if (!state) return { panels: { ...PANEL_CONFIG.panels } };
if (state.panels && typeof state.panels === "object") {
return {
panels: {
...PANEL_CONFIG.panels,
...state.panels,
},
};
}
return {
panels: {
tools: state.tools ?? state.toolsPanel ?? PANEL_CONFIG.panels.tools,
preview:
state.preview ??
state.previewPanel ??
PANEL_CONFIG.panels.preview,
properties:
state.properties ??
state.propertiesPanel ??
PANEL_CONFIG.panels.properties,
mainContent: state.mainContent ?? PANEL_CONFIG.panels.mainContent,
timeline: state.timeline ?? PANEL_CONFIG.panels.timeline,
},
};
},
partialize: (state) => ({
panels: state.panels,
}),
}
)
);

View File

@ -1,6 +1,6 @@
import { create } from "zustand";
import type { SoundEffect, SavedSound } from "@/types/sounds";
import { storageService } from "@/lib/storage/storage-service";
import { storageService } from "@/services/storage/storage-service";
import { toast } from "sonner";
import { EditorCore } from "@/core";
import { buildLibraryAudioElement } from "@/lib/timeline/element-utils";
@ -234,7 +234,10 @@ export const useSoundsStore = create<SoundsStore>((set, get) => ({
buffer,
});
editor.timeline.addElementToTrack({ trackId, element });
editor.timeline.insertElement({
placement: { mode: "explicit", trackId },
element,
});
return true;
} catch (error) {
console.error("Failed to add sound to timeline:", error);

View File

@ -149,7 +149,10 @@ export const useStickersStore = create<StickersStore>((set, get) => ({
}
const element = buildStickerElement({ iconName, startTime: currentTime });
editor.timeline.addElementToTrack({ trackId, element });
editor.timeline.insertElement({
placement: { mode: "explicit", trackId },
element,
});
get().addToRecentStickers({ iconName });
} finally {

View File

@ -1,7 +1,7 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
export type TextPropertiesTab = "transform" | "style";
export type TextPropertiesTab = "text" | "transform";
export interface TextPropertiesTabMeta {
value: TextPropertiesTab;
@ -9,8 +9,8 @@ export interface TextPropertiesTabMeta {
}
export const TEXT_PROPERTIES_TABS: ReadonlyArray<TextPropertiesTabMeta> = [
{ value: "text", label: "Text" },
{ value: "transform", label: "Transform" },
{ value: "style", label: "Style" },
] as const;
export function isTextPropertiesTab(value: string): value is TextPropertiesTab {
@ -25,7 +25,7 @@ interface TextPropertiesState {
export const useTextPropertiesStore = create<TextPropertiesState>()(
persist(
(set) => ({
activeTab: "transform",
activeTab: "text",
setActiveTab: (tab) => set({ activeTab: tab }),
}),
{ name: "text-properties" }

View File

@ -4,7 +4,7 @@
*/
import { create } from "zustand";
import type { TrackType, CreateTimelineElement } from "@/types/timeline";
import type { ClipboardItem } from "@/types/timeline";
interface TimelineStore {
selectedElements: { trackId: string; elementId: string }[];
@ -18,11 +18,11 @@ interface TimelineStore {
rippleEditingEnabled: boolean;
toggleRippleEditing: () => void;
clipboard: {
items: Array<{ trackType: TrackType; element: CreateTimelineElement }>;
items: ClipboardItem[];
} | null;
setClipboard: (
clipboard: {
items: Array<{ trackType: TrackType; element: CreateTimelineElement }>;
items: ClipboardItem[];
} | null,
) => void;
}

View File

@ -1,4 +1,4 @@
import type { MediaAssetData } from "@/lib/storage/types";
import type { MediaAssetData } from "@/services/storage/types";
export type MediaType = "image" | "video" | "audio";

View File

@ -2,13 +2,13 @@ import { TScene } from "./timeline";
export type TBackground =
| {
type: "color";
color: string;
}
type: "color";
color: string;
}
| {
type: "blur";
blurIntensity: number;
};
type: "blur";
blurIntensity: number;
};
export interface TCanvasSize {
width: number;
@ -26,6 +26,7 @@ export interface TProjectMetadata {
export interface TProjectSettings {
fps: number;
canvasSize: TCanvasSize;
originalCanvasSize?: TCanvasSize | null;
background: TBackground;
}

View File

@ -56,7 +56,7 @@ interface BaseAudioElement extends BaseTimelineElement {
type: "audio";
volume: number;
muted?: boolean;
buffer: AudioBuffer;
buffer?: AudioBuffer;
}
export interface UploadAudioElement extends BaseAudioElement {
@ -178,11 +178,13 @@ export interface ComputeDropTargetParams {
elementDuration: number;
pixelsPerSecond: number;
zoomLevel: number;
verticalDragDirection?: "up" | "down" | null;
startTimeOverride?: number;
excludeElementId?: string;
}
export interface ClipboardItem {
trackId: string;
trackType: TrackType;
element: CreateTimelineElement;
}

View File

@ -883,7 +883,7 @@ function FullscreenToolbar({
className="text-foreground h-auto p-0"
title="Skip backward 1s"
>
<SkipBack className="h-3 w-3" />
<SkipBack className="size-3" />
</Button>
<Button
variant="text"

View File

@ -111,11 +111,11 @@ export async function renderTimelineFrame({
try {
if (mediaItem.type === "video") {
const localTime = time - element.startTime + element.trimStart;
const frame = await videoCache.getFrameAt(
mediaItem.id,
mediaItem.file,
Math.max(0, localTime)
);
const frame = await videoCache.getFrameAt({
mediaId: mediaItem.id,
file: mediaItem.file,
time: Math.max(0, localTime),
});
if (frame) {
const mediaW = Math.max(1, mediaItem.width || canvasWidth);
const mediaH = Math.max(1, mediaItem.height || canvasHeight);
@ -167,11 +167,11 @@ export async function renderTimelineFrame({
try {
const localTime = time - element.startTime + element.trimStart;
const frame = await videoCache.getFrameAt(
mediaItem.id,
mediaItem.file,
localTime
);
const frame = await videoCache.getFrameAt({
mediaId: mediaItem.id,
file: mediaItem.file,
time: localTime,
});
if (!frame) continue;
const mediaW = Math.max(1, mediaItem.width || canvasWidth);